Skip to content

Commit be4d895

Browse files
authored
UI for offers #3004 (#3042)
1 parent 1030276 commit be4d895

File tree

17 files changed

+743
-35
lines changed

17 files changed

+743
-35
lines changed

frontend/src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ export const API = {
105105
SECRETS_UPDATE: (projectName: IProject['project_name']) =>
106106
`${API.BASE()}/project/${projectName}/secrets/create_or_update`,
107107
SECRETS_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/secrets/delete`,
108+
// GPUS
109+
GPUS_LIST: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/gpus/list`,
108110
},
109111

110112
BACKENDS: {

frontend/src/layouts/AppLayout/hooks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const useSideNavigation = () => {
2424

2525
const generalLinks = [
2626
{ type: 'link', text: t('navigation.runs'), href: ROUTES.RUNS.LIST },
27+
{ type: 'link', text: t('navigation.offers'), href: ROUTES.OFFERS.LIST },
2728
{ type: 'link', text: t('navigation.models'), href: ROUTES.MODELS.LIST },
2829
{ type: 'link', text: t('navigation.fleets'), href: ROUTES.FLEETS.LIST },
2930
{ type: 'link', text: t('navigation.instances'), href: ROUTES.INSTANCES.LIST },

frontend/src/libs/filters.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,36 +19,54 @@ export const tokensToSearchParams = <RequestParamsKeys extends string>(
1919
return params;
2020
};
2121

22+
export type RequestParam = string | { min: number } | { max: number };
23+
24+
const convertTokenValueToRequestParam = (token: PropertyFilterProps.Query['tokens'][number]): RequestParam => {
25+
const { value, operator } = token;
26+
27+
if (operator === '>=') {
28+
return { min: Number(value) };
29+
}
30+
31+
if (operator === '<=') {
32+
return { max: Number(value) };
33+
}
34+
35+
return value;
36+
};
37+
2238
export const tokensToRequestParams = <RequestParamsKeys extends string>({
2339
tokens,
2440
arrayFieldKeys,
2541
}: {
2642
tokens: PropertyFilterProps.Query['tokens'];
2743
arrayFieldKeys?: RequestParamsKeys[];
2844
}) => {
29-
return tokens.reduce<Record<RequestParamsKeys, string | string[]>>(
45+
return tokens.reduce<Record<RequestParamsKeys, RequestParam | string[]>>(
3046
(acc, token) => {
3147
const propertyKey = token.propertyKey as RequestParamsKeys;
3248

3349
if (!propertyKey) {
3450
return acc;
3551
}
3652

53+
const convertedValue = convertTokenValueToRequestParam(token);
54+
3755
if (arrayFieldKeys?.includes(propertyKey)) {
3856
if (Array.isArray(acc[propertyKey])) {
39-
acc[propertyKey].push(token.value);
57+
acc[propertyKey].push(convertedValue as string);
4058
} else {
41-
acc[propertyKey] = [token.value];
59+
acc[propertyKey] = [convertedValue as string];
4260
}
4361

4462
return acc;
4563
}
4664

47-
acc[propertyKey] = token.value;
65+
acc[propertyKey] = convertedValue;
4866

4967
return acc;
5068
},
51-
{} as Record<RequestParamsKeys, string>,
69+
{} as Record<RequestParamsKeys, RequestParam>,
5270
);
5371
};
5472

frontend/src/locale/en.json

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@
7878
"billing": "Billing",
7979
"resources": "Resources",
8080
"volumes": "Volumes",
81-
"instances": "Instances"
81+
"instances": "Instances",
82+
"offers": "Offers"
8283
},
8384

8485
"backend": {
@@ -446,6 +447,25 @@
446447
"size": "Size"
447448
}
448449
},
450+
"offer": {
451+
"title": "Offers",
452+
"filter_property_placeholder": "Filter offers by properties",
453+
"backend": "Backend",
454+
"backend_plural": "Backends",
455+
"availability": "Availability",
456+
"groupBy": "Group by",
457+
"region": "Region",
458+
"count": "Count",
459+
"price": "$/GPU",
460+
"memory_mib": "Memory",
461+
"spot": "Spot policy",
462+
"empty_message_title_select_project": "Select a project",
463+
"empty_message_text_select_project": "Use the filter above to select a project",
464+
"empty_message_title": "No offers",
465+
"empty_message_text": "No offers to display.",
466+
"nomatch_message_title": "No matches",
467+
"nomatch_message_text": "We can't find a match."
468+
},
449469

450470
"models": {
451471
"model_name": "Name",

frontend/src/pages/Instances/List/hooks/useFilters.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ export const useFilters = (localStorePrefix = 'instances-list-page') => {
2020
const { projectOptions } = useProjectFilter({ localStorePrefix });
2121

2222
const [propertyFilterQuery, setPropertyFilterQuery] = useState<PropertyFilterProps.Query>(() => {
23-
console.log(requestParamsToTokens<RequestParamsKeys>({ searchParams, filterKeys }));
24-
2523
return requestParamsToTokens<RequestParamsKeys>({ searchParams, filterKeys });
2624
});
2725

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { RequestParam } from '../../../libs/filters';
2+
3+
const rangeSeparator = '..';
4+
5+
export function convertMiBToGB(mib: number) {
6+
return mib / 1024;
7+
}
8+
9+
export const getPropertyFilterOptions = (gpus: IGpu[]) => {
10+
const names = new Set<string>();
11+
const backends = new Set<string>();
12+
const counts = new Set<string>();
13+
14+
gpus.forEach((gp) => {
15+
names.add(gp.name);
16+
17+
if (gp.backend) {
18+
backends.add(gp.backend);
19+
}
20+
21+
if (gp.backends?.length) {
22+
gp.backends.forEach((i) => backends.add(i));
23+
}
24+
25+
const countRange = renderRange(gp.count);
26+
27+
if (gp.count && countRange) {
28+
counts.add(countRange);
29+
}
30+
});
31+
32+
return {
33+
names,
34+
backends,
35+
counts,
36+
};
37+
};
38+
39+
export const round = (number: number) => Math.round(number * 100) / 100;
40+
41+
export const renderRange = (range: { min?: number; max?: number }) => {
42+
if (typeof range.min === 'number' && typeof range.max === 'number' && range.max != range.min) {
43+
return `${round(range.min)}${rangeSeparator}${round(range.max)}`;
44+
}
45+
46+
return range.min?.toString() ?? range.max?.toString();
47+
};
48+
49+
export const rangeToObject = (range: RequestParam): { min?: number; max?: number } | undefined => {
50+
if (!range) return;
51+
52+
if (typeof range === 'string') {
53+
const [minString, maxString] = range.split(rangeSeparator);
54+
55+
const min = Number(minString);
56+
const max = Number(maxString);
57+
58+
if (!isNaN(min) && !isNaN(max)) {
59+
return { min, max };
60+
}
61+
62+
if (!isNaN(min)) {
63+
return { min, max: min };
64+
}
65+
66+
if (!isNaN(max)) {
67+
return { min: max, max };
68+
}
69+
}
70+
71+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
72+
// @ts-expect-error
73+
return range;
74+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import React, { useCallback } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
4+
import { Button, ListEmptyMessage } from 'components';
5+
6+
export const useEmptyMessages = ({
7+
clearFilter,
8+
isDisabledClearFilter,
9+
projectNameSelected,
10+
}: {
11+
clearFilter?: () => void;
12+
isDisabledClearFilter?: boolean;
13+
projectNameSelected?: boolean;
14+
}) => {
15+
const { t } = useTranslation();
16+
17+
const renderEmptyMessage = useCallback<() => React.ReactNode>(() => {
18+
if (!projectNameSelected) {
19+
return (
20+
<ListEmptyMessage
21+
title={t('offer.empty_message_title_select_project')}
22+
message={t('offer.empty_message_text_select_project')}
23+
></ListEmptyMessage>
24+
);
25+
}
26+
27+
return (
28+
<ListEmptyMessage title={t('offer.empty_message_title')} message={t('offer.empty_message_text')}>
29+
<Button disabled={isDisabledClearFilter} onClick={clearFilter}>
30+
{t('common.clearFilter')}
31+
</Button>
32+
</ListEmptyMessage>
33+
);
34+
}, [clearFilter, isDisabledClearFilter]);
35+
36+
const renderNoMatchMessage = useCallback<() => React.ReactNode>(() => {
37+
return (
38+
<ListEmptyMessage title={t('offer.nomatch_message_title')} message={t('offer.nomatch_message_text')}>
39+
<Button disabled={isDisabledClearFilter} onClick={clearFilter}>
40+
{t('common.clearFilter')}
41+
</Button>
42+
</ListEmptyMessage>
43+
);
44+
}, [clearFilter, isDisabledClearFilter]);
45+
46+
return { renderEmptyMessage, renderNoMatchMessage } as const;
47+
};

0 commit comments

Comments
 (0)