Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,8 @@
"template_placeholder": "Select a project to select a template",
"template_card_type": "Type",
"gpu": "GPU",
"gpu_description": "Enable to select a GPU offer. Disable to run without a GPU.",
"gpu_description_enabled": "Choose a specific offer, or let dstack select it automatically.",
"gpu_description_disabled": "Enable GPU for this run.",
"offer": "Offer",
"offer_description": "Select an offer for the run.",
"name": "Name",
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/pages/Offers/List/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { rangeToObject } from './helpers';

describe('Offers helpers', () => {
test('rangeToObject parses open and closed ranges', () => {
expect(rangeToObject('1..')).toEqual({ min: 1 });
expect(rangeToObject('..4')).toEqual({ max: 4 });
expect(rangeToObject('1..4')).toEqual({ min: 1, max: 4 });
});

test('rangeToObject parses GB ranges for memory', () => {
expect(rangeToObject('24GB..', { requireUnit: true })).toEqual({ min: 24 });
expect(rangeToObject('..80GB', { requireUnit: true })).toEqual({ max: 80 });
expect(rangeToObject('40GB..80GB', { requireUnit: true })).toEqual({ min: 40, max: 80 });
});

test('rangeToObject rejects unitless memory when unit is required', () => {
expect(rangeToObject('24..80', { requireUnit: true })).toBeUndefined();
expect(rangeToObject(24, { requireUnit: true })).toBeUndefined();
});
});
45 changes: 35 additions & 10 deletions frontend/src/pages/Offers/List/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,28 +64,53 @@ export const renderRangeJSX = (range: { min?: number; max?: number }) => {
return range.min?.toString() ?? range.max?.toString();
};

export const rangeToObject = (range: RequestParam): { min?: number; max?: number } | undefined => {
export const rangeToObject = (
range: RequestParam,
{
requireUnit = false,
}: {
requireUnit?: boolean;
} = {},
): { min?: number; max?: number } | undefined => {
const hasGbUnit = (value?: string) => /gb/i.test(value ?? '');

if (!range) return;

if (typeof range === 'string') {
const [minString, maxString] = range.split(rangeSeparator);

const min = Number(minString);
const max = Number(maxString);

if (!isNaN(min) && !isNaN(max)) {
const normalizeNumericPart = (value?: string) => (value ?? '').replace(/[^\d.]/g, '');
const parseBound = (value?: string): number | undefined => {
if (requireUnit && value && !hasGbUnit(value)) {
return undefined;
}
const normalized = normalizeNumericPart(value);
if (!normalized) {
return undefined;
}
const parsed = Number(normalized);
return isNaN(parsed) ? undefined : parsed;
};

const min = parseBound(minString);
const max = parseBound(maxString);

if (typeof min === 'number' && typeof max === 'number') {
return { min, max };
}

if (!isNaN(min)) {
return { min, max: min };
if (typeof min === 'number') {
return { min };
}

if (!isNaN(max)) {
return { min: max, max };
if (typeof max === 'number') {
return { max };
}
}

if (typeof range === 'number') {
return requireUnit ? undefined : { min: range, max: range };
}

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
return range;
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/pages/Offers/List/hooks/useFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type UseFiltersArgs = {
gpus: IGpu[];
withSearchParams?: boolean;
permanentFilters?: Partial<Record<RequestParamsKeys, string>>;
defaultFilters?: Partial<Record<RequestParamsKeys, string>>;
defaultFilters?: Partial<Record<RequestParamsKeys, string | string[]>>;
};

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

const [propertyFilterQuery, setPropertyFilterQuery] = useState<PropertyFilterProps.Query>(() =>
requestParamsToTokens<RequestParamsKeys>({ searchParams, filterKeys, defaultFilterValues: defaultFilters }),
);
const [propertyFilterQuery, setPropertyFilterQuery] = useState<PropertyFilterProps.Query>(() => {
const queryFromSearchParams = requestParamsToTokens<RequestParamsKeys>({
searchParams,
filterKeys,
defaultFilterValues: defaultFilters,
});
if (queryFromSearchParams.tokens.length > 0) {
return queryFromSearchParams;
}

return EMPTY_QUERY;
});

const [groupBy, setGroupBy] = useState<MultiselectProps.Options>(() => {
const selectedGroupBy = requestParamsToArray<RequestParamsKeys>({
Expand Down
143 changes: 79 additions & 64 deletions frontend/src/pages/Offers/List/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { Cards, CardsProps, MultiselectCSD, Popover, PropertyFilter } from 'components';
import { Alert, Cards, CardsProps, MultiselectCSD, Popover, PropertyFilter } from 'components';

import { useCollection } from 'hooks';
import { useGetGpusListQuery } from 'services/gpu';
Expand Down Expand Up @@ -30,7 +30,7 @@ const getRequestParams = ({
group_by?: TGpuGroupBy[];
}): TGpusListQueryParams => {
const gpuCountMinMax = rangeToObject(gpu_count ?? '');
const gpuMemoryMinMax = rangeToObject(gpu_memory ?? '');
const gpuMemoryMinMax = rangeToObject(gpu_memory ?? '', { requireUnit: true });

return {
project_name,
Expand All @@ -50,29 +50,30 @@ const getRequestParams = ({
// disk: { size: { min: 100.0 } },
gpu: {
...(gpu_name?.length ? { name: gpu_name } : {}),
...(gpuCountMinMax ? { count: gpuCountMinMax } : {}),
...(gpuMemoryMinMax ? { memory: gpuMemoryMinMax } : {}),
...(gpuCountMinMax ? { count: gpuCountMinMax as unknown as TRange } : {}),
...(gpuMemoryMinMax ? { memory: gpuMemoryMinMax as unknown as TRange } : {}),
},
},
spot_policy,
volumes: [],
files: [],
setup: [],
...(backend?.length ? { backends: backend } : {}),
...(backend?.length ? { backends: backend as TBackendType[] } : {}),
},
profile: { name: 'default', default: false },
ssh_key_pub: '(dummy)',
},
};
};

type OfferListProps = Pick<CardsProps, 'variant' | 'header' | 'onSelectionChange' | 'selectedItems' | 'selectionType'> &
Pick<UseFiltersArgs, 'permanentFilters' | 'defaultFilters'> & {
withSearchParams?: boolean;
disabled?: boolean;
onChangeProjectName?: (value: string) => void;
onChangeBackendFilter?: (backends: string[]) => void;
};
type OfferListProps = Pick<CardsProps, 'variant' | 'header' | 'onSelectionChange' | 'selectedItems' | 'selectionType'> & {
permanentFilters?: UseFiltersArgs['permanentFilters'];
defaultFilters?: UseFiltersArgs['defaultFilters'];
withSearchParams?: boolean;
disabled?: boolean;
onChangeProjectName?: (value: string) => void;
onChangeBackendFilter?: (backends: string[]) => void;
};

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

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

useEffect(() => {
onChangeProjectName?.(filteringRequestParams.project_name ?? '');
const projectName = typeof filteringRequestParams.project_name === 'string' ? filteringRequestParams.project_name : '';
onChangeProjectName?.(projectName);
}, [filteringRequestParams.project_name]);

useEffect(() => {
const backend = filteringRequestParams.backend;
onChangeBackendFilter?.(backend ? (Array.isArray(backend) ? backend : [backend]) : []);
const backendValues = backend
? (Array.isArray(backend) ? backend : [backend]).filter((value): value is string => typeof value === 'string')
: [];
onChangeBackendFilter?.(backendValues);
}, [filteringRequestParams.backend]);

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

return (
<Cards
{...collectionProps}
{...props}
entireCardClickable
items={disabled ? [] : items}
empty={disabled ? ' ' : undefined}
cardDefinition={{
header: (gpu) => gpu.name,
sections,
}}
loading={!disabled && (isLoading || isFetching)}
loadingText={t('common.loading')}
stickyHeader={true}
filter={
disabled ? undefined : (
<div className={styles.selectFilters}>
<div className={styles.propertyFilter}>
<PropertyFilter
disabled={isLoading || isFetching}
query={propertyFilterQuery}
onChange={onChangePropertyFilter}
expandToViewport
hideOperations
i18nStrings={{
clearFiltersText: t('common.clearFilter'),
filteringAriaLabel: t('offer.filter_property_placeholder'),
filteringPlaceholder: t('offer.filter_property_placeholder'),
operationAndText: 'and',
enteredTextLabel: (value) => `Use: ${value}`,
}}
filteringOptions={filteringOptions}
filteringProperties={filteringProperties}
filteringStatusType={filteringStatusType}
onLoadItems={handleLoadItems}
/>
</div>
<>
{!disabled && isError && (
<Alert type="error" header="Error">
{'data' in (error as object) && (error as { data?: { detail?: { msg?: string }[] } }).data?.detail?.[0]?.msg
? (error as { data?: { detail?: { msg?: string }[] } }).data?.detail?.[0]?.msg
: t('common.server_error', { error: 'Unknown error' })}
</Alert>
)}

<div className={styles.filterField}>
<MultiselectCSD
placeholder={t('offer.groupBy')}
onChange={onChangeGroupBy}
options={groupByOptions}
selectedOptions={groupBy}
expandToViewport={true}
disabled={isLoading || isFetching}
/>
<Cards
{...collectionProps}
{...props}
entireCardClickable
items={disabled ? [] : items}
empty={disabled ? ' ' : undefined}
cardDefinition={{
header: (gpu) => gpu.name,
sections,
}}
loading={!disabled && (isLoading || isFetching)}
loadingText={t('common.loading')}
stickyHeader={true}
filter={
disabled ? undefined : (
<div className={styles.selectFilters}>
<div className={styles.propertyFilter}>
<PropertyFilter
disabled={isLoading || isFetching}
query={propertyFilterQuery}
onChange={onChangePropertyFilter}
expandToViewport
hideOperations
i18nStrings={{
clearFiltersText: t('common.clearFilter'),
filteringAriaLabel: t('offer.filter_property_placeholder'),
filteringPlaceholder: t('offer.filter_property_placeholder'),
operationAndText: 'and',
enteredTextLabel: (value) => `Use: ${value}`,
}}
filteringOptions={filteringOptions}
filteringProperties={filteringProperties}
filteringStatusType={filteringStatusType}
onLoadItems={handleLoadItems}
/>
</div>

<div className={styles.filterField}>
<MultiselectCSD
placeholder={t('offer.groupBy')}
onChange={onChangeGroupBy}
options={groupByOptions}
selectedOptions={groupBy}
expandToViewport={true}
disabled={isLoading || isFetching}
/>
</div>
</div>
</div>
)
}
/>
)
}
/>
</>
);
};
63 changes: 63 additions & 0 deletions frontend/src/pages/Runs/Launch/helpers/templateResources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { getTemplateOfferDefaultFilters } from './templateResources';

const makeTemplate = (configuration: Record<string, unknown>): ITemplate =>
({
type: 'template',
name: 'test',
title: 'test',
parameters: [{ type: 'resources' }],
configuration,
}) as ITemplate;

describe('templateResources', () => {
test('returns full gpu name list from object gpu spec', () => {
const template = makeTemplate({
type: 'task',
resources: {
gpu: {
name: ['H100', 'H200'],
count: { min: 1, max: 2 },
},
},
});

expect(getTemplateOfferDefaultFilters(template)).toMatchObject({
gpu_name: ['H100', 'H200'],
gpu_count: '1..2',
});
});

test('keeps GB units and open ranges for gpu memory', () => {
const template = makeTemplate({
type: 'task',
resources: {
gpu: {
name: 'H100',
memory: { min: '24GB' },
},
},
});

expect(getTemplateOfferDefaultFilters(template)).toMatchObject({
gpu_name: 'H100',
gpu_memory: '24GB..',
});
});

test('adds backends and spot policy defaults', () => {
const template = makeTemplate({
type: 'task',
resources: {
gpu: 'H100:1',
},
backends: ['aws', 'vastai'],
spot_policy: 'auto',
});

expect(getTemplateOfferDefaultFilters(template)).toMatchObject({
gpu_name: 'H100',
backend: ['aws', 'vastai'],
spot_policy: 'auto',
});
});
});
Loading