Skip to content

Commit c8275b1

Browse files
[UI] Make Launch to respect resources in templates (#3642)
1 parent 684ade3 commit c8275b1

11 files changed

Lines changed: 437 additions & 103 deletions

File tree

frontend/src/locale/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,8 @@
508508
"template_placeholder": "Select a project to select a template",
509509
"template_card_type": "Type",
510510
"gpu": "GPU",
511-
"gpu_description": "Enable to select a GPU offer. Disable to run without a GPU.",
511+
"gpu_description_enabled": "Choose a specific offer, or let dstack select it automatically.",
512+
"gpu_description_disabled": "Enable GPU for this run.",
512513
"offer": "Offer",
513514
"offer_description": "Select an offer for the run.",
514515
"name": "Name",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { rangeToObject } from './helpers';
2+
3+
describe('Offers helpers', () => {
4+
test('rangeToObject parses open and closed ranges', () => {
5+
expect(rangeToObject('1..')).toEqual({ min: 1 });
6+
expect(rangeToObject('..4')).toEqual({ max: 4 });
7+
expect(rangeToObject('1..4')).toEqual({ min: 1, max: 4 });
8+
});
9+
10+
test('rangeToObject parses GB ranges for memory', () => {
11+
expect(rangeToObject('24GB..', { requireUnit: true })).toEqual({ min: 24 });
12+
expect(rangeToObject('..80GB', { requireUnit: true })).toEqual({ max: 80 });
13+
expect(rangeToObject('40GB..80GB', { requireUnit: true })).toEqual({ min: 40, max: 80 });
14+
});
15+
16+
test('rangeToObject rejects unitless memory when unit is required', () => {
17+
expect(rangeToObject('24..80', { requireUnit: true })).toBeUndefined();
18+
expect(rangeToObject(24, { requireUnit: true })).toBeUndefined();
19+
});
20+
});

frontend/src/pages/Offers/List/helpers.tsx

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,28 +64,53 @@ export const renderRangeJSX = (range: { min?: number; max?: number }) => {
6464
return range.min?.toString() ?? range.max?.toString();
6565
};
6666

67-
export const rangeToObject = (range: RequestParam): { min?: number; max?: number } | undefined => {
67+
export const rangeToObject = (
68+
range: RequestParam,
69+
{
70+
requireUnit = false,
71+
}: {
72+
requireUnit?: boolean;
73+
} = {},
74+
): { min?: number; max?: number } | undefined => {
75+
const hasGbUnit = (value?: string) => /gb/i.test(value ?? '');
76+
6877
if (!range) return;
6978

7079
if (typeof range === 'string') {
7180
const [minString, maxString] = range.split(rangeSeparator);
72-
73-
const min = Number(minString);
74-
const max = Number(maxString);
75-
76-
if (!isNaN(min) && !isNaN(max)) {
81+
const normalizeNumericPart = (value?: string) => (value ?? '').replace(/[^\d.]/g, '');
82+
const parseBound = (value?: string): number | undefined => {
83+
if (requireUnit && value && !hasGbUnit(value)) {
84+
return undefined;
85+
}
86+
const normalized = normalizeNumericPart(value);
87+
if (!normalized) {
88+
return undefined;
89+
}
90+
const parsed = Number(normalized);
91+
return isNaN(parsed) ? undefined : parsed;
92+
};
93+
94+
const min = parseBound(minString);
95+
const max = parseBound(maxString);
96+
97+
if (typeof min === 'number' && typeof max === 'number') {
7798
return { min, max };
7899
}
79100

80-
if (!isNaN(min)) {
81-
return { min, max: min };
101+
if (typeof min === 'number') {
102+
return { min };
82103
}
83104

84-
if (!isNaN(max)) {
85-
return { min: max, max };
105+
if (typeof max === 'number') {
106+
return { max };
86107
}
87108
}
88109

110+
if (typeof range === 'number') {
111+
return requireUnit ? undefined : { min: range, max: range };
112+
}
113+
89114
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
90115
// @ts-expect-error
91116
return range;

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export type UseFiltersArgs = {
2020
gpus: IGpu[];
2121
withSearchParams?: boolean;
2222
permanentFilters?: Partial<Record<RequestParamsKeys, string>>;
23-
defaultFilters?: Partial<Record<RequestParamsKeys, string>>;
23+
defaultFilters?: Partial<Record<RequestParamsKeys, string | string[]>>;
2424
};
2525

2626
export const filterKeys: Record<string, RequestParamsKeys> = {
@@ -101,9 +101,18 @@ export const useFilters = ({ gpus, withSearchParams = true, permanentFilters = {
101101
const { data: projectsData } = useGetProjectsQuery({ limit: 1 });
102102
const projectNameIsChecked = useRef(false);
103103

104-
const [propertyFilterQuery, setPropertyFilterQuery] = useState<PropertyFilterProps.Query>(() =>
105-
requestParamsToTokens<RequestParamsKeys>({ searchParams, filterKeys, defaultFilterValues: defaultFilters }),
106-
);
104+
const [propertyFilterQuery, setPropertyFilterQuery] = useState<PropertyFilterProps.Query>(() => {
105+
const queryFromSearchParams = requestParamsToTokens<RequestParamsKeys>({
106+
searchParams,
107+
filterKeys,
108+
defaultFilterValues: defaultFilters,
109+
});
110+
if (queryFromSearchParams.tokens.length > 0) {
111+
return queryFromSearchParams;
112+
}
113+
114+
return EMPTY_QUERY;
115+
});
107116

108117
const [groupBy, setGroupBy] = useState<MultiselectProps.Options>(() => {
109118
const selectedGroupBy = requestParamsToArray<RequestParamsKeys>({

frontend/src/pages/Offers/List/index.tsx

Lines changed: 79 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useEffect, useState } from 'react';
22
import { useTranslation } from 'react-i18next';
33

4-
import { Cards, CardsProps, MultiselectCSD, Popover, PropertyFilter } from 'components';
4+
import { Alert, Cards, CardsProps, MultiselectCSD, Popover, PropertyFilter } from 'components';
55

66
import { useCollection } from 'hooks';
77
import { useGetGpusListQuery } from 'services/gpu';
@@ -30,7 +30,7 @@ const getRequestParams = ({
3030
group_by?: TGpuGroupBy[];
3131
}): TGpusListQueryParams => {
3232
const gpuCountMinMax = rangeToObject(gpu_count ?? '');
33-
const gpuMemoryMinMax = rangeToObject(gpu_memory ?? '');
33+
const gpuMemoryMinMax = rangeToObject(gpu_memory ?? '', { requireUnit: true });
3434

3535
return {
3636
project_name,
@@ -50,29 +50,30 @@ const getRequestParams = ({
5050
// disk: { size: { min: 100.0 } },
5151
gpu: {
5252
...(gpu_name?.length ? { name: gpu_name } : {}),
53-
...(gpuCountMinMax ? { count: gpuCountMinMax } : {}),
54-
...(gpuMemoryMinMax ? { memory: gpuMemoryMinMax } : {}),
53+
...(gpuCountMinMax ? { count: gpuCountMinMax as unknown as TRange } : {}),
54+
...(gpuMemoryMinMax ? { memory: gpuMemoryMinMax as unknown as TRange } : {}),
5555
},
5656
},
5757
spot_policy,
5858
volumes: [],
5959
files: [],
6060
setup: [],
61-
...(backend?.length ? { backends: backend } : {}),
61+
...(backend?.length ? { backends: backend as TBackendType[] } : {}),
6262
},
6363
profile: { name: 'default', default: false },
6464
ssh_key_pub: '(dummy)',
6565
},
6666
};
6767
};
6868

69-
type OfferListProps = Pick<CardsProps, 'variant' | 'header' | 'onSelectionChange' | 'selectedItems' | 'selectionType'> &
70-
Pick<UseFiltersArgs, 'permanentFilters' | 'defaultFilters'> & {
71-
withSearchParams?: boolean;
72-
disabled?: boolean;
73-
onChangeProjectName?: (value: string) => void;
74-
onChangeBackendFilter?: (backends: string[]) => void;
75-
};
69+
type OfferListProps = Pick<CardsProps, 'variant' | 'header' | 'onSelectionChange' | 'selectedItems' | 'selectionType'> & {
70+
permanentFilters?: UseFiltersArgs['permanentFilters'];
71+
defaultFilters?: UseFiltersArgs['defaultFilters'];
72+
withSearchParams?: boolean;
73+
disabled?: boolean;
74+
onChangeProjectName?: (value: string) => void;
75+
onChangeBackendFilter?: (backends: string[]) => void;
76+
};
7677

7778
export const OfferList: React.FC<OfferListProps> = ({
7879
withSearchParams,
@@ -86,7 +87,7 @@ export const OfferList: React.FC<OfferListProps> = ({
8687
const { t } = useTranslation();
8788
const [requestParams, setRequestParams] = useState<TGpusListQueryParams | undefined>();
8889

89-
const { data, isLoading, isFetching } = useGetGpusListQuery(
90+
const { data, error, isError, isLoading, isFetching } = useGetGpusListQuery(
9091
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
9192
// @ts-expect-error
9293
requestParams,
@@ -121,12 +122,16 @@ export const OfferList: React.FC<OfferListProps> = ({
121122
}, [JSON.stringify(filteringRequestParams), groupBy]);
122123

123124
useEffect(() => {
124-
onChangeProjectName?.(filteringRequestParams.project_name ?? '');
125+
const projectName = typeof filteringRequestParams.project_name === 'string' ? filteringRequestParams.project_name : '';
126+
onChangeProjectName?.(projectName);
125127
}, [filteringRequestParams.project_name]);
126128

127129
useEffect(() => {
128130
const backend = filteringRequestParams.backend;
129-
onChangeBackendFilter?.(backend ? (Array.isArray(backend) ? backend : [backend]) : []);
131+
const backendValues = backend
132+
? (Array.isArray(backend) ? backend : [backend]).filter((value): value is string => typeof value === 'string')
133+
: [];
134+
onChangeBackendFilter?.(backendValues);
130135
}, [filteringRequestParams.backend]);
131136

132137
const { renderEmptyMessage, renderNoMatchMessage } = useEmptyMessages({
@@ -228,56 +233,66 @@ export const OfferList: React.FC<OfferListProps> = ({
228233
].filter(Boolean) as CardsProps.CardDefinition<IGpu>['sections'];
229234

230235
return (
231-
<Cards
232-
{...collectionProps}
233-
{...props}
234-
entireCardClickable
235-
items={disabled ? [] : items}
236-
empty={disabled ? ' ' : undefined}
237-
cardDefinition={{
238-
header: (gpu) => gpu.name,
239-
sections,
240-
}}
241-
loading={!disabled && (isLoading || isFetching)}
242-
loadingText={t('common.loading')}
243-
stickyHeader={true}
244-
filter={
245-
disabled ? undefined : (
246-
<div className={styles.selectFilters}>
247-
<div className={styles.propertyFilter}>
248-
<PropertyFilter
249-
disabled={isLoading || isFetching}
250-
query={propertyFilterQuery}
251-
onChange={onChangePropertyFilter}
252-
expandToViewport
253-
hideOperations
254-
i18nStrings={{
255-
clearFiltersText: t('common.clearFilter'),
256-
filteringAriaLabel: t('offer.filter_property_placeholder'),
257-
filteringPlaceholder: t('offer.filter_property_placeholder'),
258-
operationAndText: 'and',
259-
enteredTextLabel: (value) => `Use: ${value}`,
260-
}}
261-
filteringOptions={filteringOptions}
262-
filteringProperties={filteringProperties}
263-
filteringStatusType={filteringStatusType}
264-
onLoadItems={handleLoadItems}
265-
/>
266-
</div>
236+
<>
237+
{!disabled && isError && (
238+
<Alert type="error" header="Error">
239+
{'data' in (error as object) && (error as { data?: { detail?: { msg?: string }[] } }).data?.detail?.[0]?.msg
240+
? (error as { data?: { detail?: { msg?: string }[] } }).data?.detail?.[0]?.msg
241+
: t('common.server_error', { error: 'Unknown error' })}
242+
</Alert>
243+
)}
267244

268-
<div className={styles.filterField}>
269-
<MultiselectCSD
270-
placeholder={t('offer.groupBy')}
271-
onChange={onChangeGroupBy}
272-
options={groupByOptions}
273-
selectedOptions={groupBy}
274-
expandToViewport={true}
275-
disabled={isLoading || isFetching}
276-
/>
245+
<Cards
246+
{...collectionProps}
247+
{...props}
248+
entireCardClickable
249+
items={disabled ? [] : items}
250+
empty={disabled ? ' ' : undefined}
251+
cardDefinition={{
252+
header: (gpu) => gpu.name,
253+
sections,
254+
}}
255+
loading={!disabled && (isLoading || isFetching)}
256+
loadingText={t('common.loading')}
257+
stickyHeader={true}
258+
filter={
259+
disabled ? undefined : (
260+
<div className={styles.selectFilters}>
261+
<div className={styles.propertyFilter}>
262+
<PropertyFilter
263+
disabled={isLoading || isFetching}
264+
query={propertyFilterQuery}
265+
onChange={onChangePropertyFilter}
266+
expandToViewport
267+
hideOperations
268+
i18nStrings={{
269+
clearFiltersText: t('common.clearFilter'),
270+
filteringAriaLabel: t('offer.filter_property_placeholder'),
271+
filteringPlaceholder: t('offer.filter_property_placeholder'),
272+
operationAndText: 'and',
273+
enteredTextLabel: (value) => `Use: ${value}`,
274+
}}
275+
filteringOptions={filteringOptions}
276+
filteringProperties={filteringProperties}
277+
filteringStatusType={filteringStatusType}
278+
onLoadItems={handleLoadItems}
279+
/>
280+
</div>
281+
282+
<div className={styles.filterField}>
283+
<MultiselectCSD
284+
placeholder={t('offer.groupBy')}
285+
onChange={onChangeGroupBy}
286+
options={groupByOptions}
287+
selectedOptions={groupBy}
288+
expandToViewport={true}
289+
disabled={isLoading || isFetching}
290+
/>
291+
</div>
277292
</div>
278-
</div>
279-
)
280-
}
281-
/>
293+
)
294+
}
295+
/>
296+
</>
282297
);
283298
};
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { getTemplateOfferDefaultFilters } from './templateResources';
2+
3+
const makeTemplate = (configuration: Record<string, unknown>): ITemplate =>
4+
({
5+
type: 'template',
6+
name: 'test',
7+
title: 'test',
8+
parameters: [{ type: 'resources' }],
9+
configuration,
10+
}) as ITemplate;
11+
12+
describe('templateResources', () => {
13+
test('returns full gpu name list from object gpu spec', () => {
14+
const template = makeTemplate({
15+
type: 'task',
16+
resources: {
17+
gpu: {
18+
name: ['H100', 'H200'],
19+
count: { min: 1, max: 2 },
20+
},
21+
},
22+
});
23+
24+
expect(getTemplateOfferDefaultFilters(template)).toMatchObject({
25+
gpu_name: ['H100', 'H200'],
26+
gpu_count: '1..2',
27+
});
28+
});
29+
30+
test('keeps GB units and open ranges for gpu memory', () => {
31+
const template = makeTemplate({
32+
type: 'task',
33+
resources: {
34+
gpu: {
35+
name: 'H100',
36+
memory: { min: '24GB' },
37+
},
38+
},
39+
});
40+
41+
expect(getTemplateOfferDefaultFilters(template)).toMatchObject({
42+
gpu_name: 'H100',
43+
gpu_memory: '24GB..',
44+
});
45+
});
46+
47+
test('adds backends and spot policy defaults', () => {
48+
const template = makeTemplate({
49+
type: 'task',
50+
resources: {
51+
gpu: 'H100:1',
52+
},
53+
backends: ['aws', 'vastai'],
54+
spot_policy: 'auto',
55+
});
56+
57+
expect(getTemplateOfferDefaultFilters(template)).toMatchObject({
58+
gpu_name: 'H100',
59+
backend: ['aws', 'vastai'],
60+
spot_policy: 'auto',
61+
});
62+
});
63+
});

0 commit comments

Comments
 (0)