From 87a9e1cd322a7940b2a38c28bb941f452e271ba5 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Fri, 16 Oct 2026 16:41:24 +0200 Subject: [PATCH] Add fleet offers and CPU offer cards --- .gitignore | 2 + frontend/product.config.cjs | 1 + frontend/src/locale/en.json | 7 +- .../Fleets/Details/FleetDetails/index.tsx | 107 +++-- .../src/pages/Fleets/Details/Offers/index.tsx | 38 ++ frontend/src/pages/Fleets/Details/index.tsx | 6 + .../Instances/List/hooks/useEmptyMessage.tsx | 8 +- .../Instances/List/hooks/useFilters.test.tsx | 60 +++ .../pages/Instances/List/hooks/useFilters.ts | 13 +- frontend/src/pages/Instances/List/index.tsx | 73 ++-- .../src/pages/Offers/List/helpers.test.ts | 119 +++++- frontend/src/pages/Offers/List/helpers.tsx | 126 +++++- .../Offers/List/hooks/useEmptyMessages.tsx | 15 +- .../Offers/List/hooks/useFilters.test.tsx | 382 +++++++++++++++++ .../src/pages/Offers/List/hooks/useFilters.ts | 318 ++++++++++---- frontend/src/pages/Offers/List/index.tsx | 397 +++++++++++------- .../src/pages/Offers/List/styles.module.scss | 12 + frontend/src/pages/Offers/ListPage.tsx | 32 +- .../pages/Project/Details/Settings/index.tsx | 33 +- frontend/src/pages/Runs/Launch/constants.tsx | 1 - .../getRunSpecConfigurationResources.ts | 19 +- .../Launch/helpers/templateResources.test.ts | 7 + .../Runs/Launch/helpers/templateResources.ts | 58 +-- .../Launch/hooks/useGenerateYaml.test.tsx | 145 +++++++ .../Runs/Launch/hooks/useGenerateYaml.ts | 54 ++- .../Launch/hooks/useValidationResolver.ts | 3 +- frontend/src/pages/Runs/Launch/index.tsx | 80 ++-- frontend/src/pages/Runs/Launch/types.ts | 5 +- .../pages/Runs/LaunchOptions/helpers.test.ts | 31 ++ .../src/pages/Runs/LaunchOptions/helpers.ts | 21 + .../src/pages/Runs/LaunchOptions/index.tsx | 34 +- frontend/src/router.tsx | 5 + frontend/src/routes.ts | 5 + frontend/src/services/gpu.ts | 15 +- frontend/src/services/project.ts | 1 + frontend/src/types/gpu.d.ts | 23 +- src/dstack/_internal/cli/utils/gpu.py | 6 +- .../_internal/core/compatibility/gpus.py | 2 +- .../_internal/core/compatibility/runs.py | 4 +- src/dstack/_internal/core/models/gpus.py | 18 + src/dstack/_internal/server/routers/gpus.py | 1 + src/dstack/_internal/server/routers/runs.py | 1 + src/dstack/_internal/server/schemas/gpus.py | 13 +- src/dstack/_internal/server/schemas/runs.py | 7 + src/dstack/_internal/server/services/gpus.py | 94 ++++- .../server/services/runs/__init__.py | 2 + .../_internal/server/services/runs/plan.py | 11 +- .../_internal/server/routers/test_gpus.py | 156 +++++++ .../_internal/server/routers/test_runs.py | 57 +++ src/tests/api/test_offers.py | 27 ++ 50 files changed, 2130 insertions(+), 525 deletions(-) create mode 100644 frontend/src/pages/Fleets/Details/Offers/index.tsx create mode 100644 frontend/src/pages/Instances/List/hooks/useFilters.test.tsx create mode 100644 frontend/src/pages/Offers/List/hooks/useFilters.test.tsx create mode 100644 frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.test.tsx create mode 100644 frontend/src/pages/Runs/LaunchOptions/helpers.test.ts create mode 100644 frontend/src/pages/Runs/LaunchOptions/helpers.ts create mode 100644 src/tests/api/test_offers.py diff --git a/.gitignore b/.gitignore index 927093831a..1e449f11b0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ venv/ .envrc uv.lock +.playwright-mcp + /runner/cmd/shim/shim /runner/cmd/runner/runner diff --git a/frontend/product.config.cjs b/frontend/product.config.cjs index cf8e02fb39..4d9e48a975 100644 --- a/frontend/product.config.cjs +++ b/frontend/product.config.cjs @@ -5,6 +5,7 @@ const oss = { hasEvents: false, hasBilling: false, hasPresets: false, + /** Whether this product allows templates, even when none are configured. */ hasTemplates: false, isSky: false, }; diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 10ae36a5ed..9e00c1030a 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -576,6 +576,10 @@ "availability_not_available": "Not available", "availability_no_quota": "No quota", "availability_no_balance": "No balance", + "availability_busy": "Busy", + "availability_idle": "Idle", + "truncated": "Showing {{shown}} of {{total}} offers. Use filters to narrow the results.", + "terminated_fleet": "Offers are unavailable for a terminated fleet.", "groupBy": "Group by properties", "region": "Region", "count": "Count", @@ -587,7 +591,8 @@ "empty_message_title_select_groupBy": "Select a group by", "empty_message_text_select_groupBy": "Use the field above to select a group by", "empty_message_title": "No offers", - "empty_message_text": "A backend may not be configured for this project.", + "empty_message_text": "No offers matching backends or SSH fleets. Check configured backends.", + "empty_message_text_fleet": "No offers matching fleet requirements. Check configured backends.", "nomatch_message_title": "No matches", "nomatch_message_text": "We can't find a match. Try to clear filter." }, diff --git a/frontend/src/pages/Fleets/Details/FleetDetails/index.tsx b/frontend/src/pages/Fleets/Details/FleetDetails/index.tsx index 97a09e2f38..3d71360e2f 100644 --- a/frontend/src/pages/Fleets/Details/FleetDetails/index.tsx +++ b/frontend/src/pages/Fleets/Details/FleetDetails/index.tsx @@ -3,19 +3,15 @@ import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { format } from 'date-fns'; -import { Box, ColumnLayout, Container, Header, Loader, NavigateLink, StatusIndicator } from 'components'; +import { Box, ColumnLayout, Container, Header, Loader, NavigateLink, SpaceBetween, StatusIndicator } from 'components'; import { DATE_TIME_FORMAT } from 'consts'; -import { - formatFleetBackend, - formatFleetResources, - getFleetInstancesLinkText, - getFleetPrice, - getFleetStatusIconType, -} from 'libs/fleet'; +import { formatFleetBackend, formatFleetResources, getFleetPrice, getFleetStatusIconType } from 'libs/fleet'; import { ROUTES } from 'routes'; import { useGetFleetDetailsQuery } from 'services/fleet'; +import { InstanceList } from 'pages/Instances/List'; + export const FleetDetails = () => { const { t } = useTranslation(); const params = useParams(); @@ -41,73 +37,66 @@ export const FleetDetails = () => { )} {data && ( - {t('common.general')}}> - -
- {t('fleets.fleet')} -
{data.name}
-
- -
- {t('fleets.instances.status')} - + + {t('common.general')}}> +
- - {t(`fleets.statuses.${data.status}`)} - + {t('fleets.fleet')} +
{data.name}
-
- -
- {t('fleets.instances.project')}
- - {data.project_name} - -
-
+ {t('fleets.instances.status')} -
- {t('fleets.instances.backend')} -
{formatFleetBackend(data.spec.configuration)}
-
+
+ + {t(`fleets.statuses.${data.status}`)} + +
+ -
- {t('fleets.instances.resources')}
- {data.spec.configuration.ssh_config - ? '-' - : formatFleetResources(data.spec.configuration.resources)} + {t('fleets.instances.project')} + +
+ + {data.project_name} + +
-
-
- {t('fleets.instances.title')} +
+ {t('fleets.instances.backend')} +
{formatFleetBackend(data.spec.configuration)}
+
- - {getFleetInstancesLinkText(data)} - + {t('fleets.instances.resources')} +
+ {data.spec.configuration.ssh_config + ? '-' + : formatFleetResources(data.spec.configuration.resources)} +
-
-
- {t('fleets.instances.created')} -
{format(new Date(data.created_at), DATE_TIME_FORMAT)}
-
+
+ {t('fleets.instances.created')} +
{format(new Date(data.created_at), DATE_TIME_FORMAT)}
+
-
- {t('fleets.instances.price')}
- {(() => { - const p = getFleetPrice(data); - return typeof p === 'number' ? `$${p}` : '-'; - })()} + {t('fleets.instances.price')} +
+ {(() => { + const p = getFleetPrice(data); + return typeof p === 'number' ? `$${p}` : '-'; + })()} +
-
-
-
+ + + + )} ); diff --git a/frontend/src/pages/Fleets/Details/Offers/index.tsx b/frontend/src/pages/Fleets/Details/Offers/index.tsx new file mode 100644 index 0000000000..2369653438 --- /dev/null +++ b/frontend/src/pages/Fleets/Details/Offers/index.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; + +import { Alert, Header, Loader } from 'components'; + +import { getServerError } from 'libs'; +import { useGetFleetDetailsQuery } from 'services/fleet'; + +import { OfferList } from 'pages/Offers/List'; + +export const FleetOffers = () => { + const { t } = useTranslation(); + const { projectName = '', fleetId = '' } = useParams(); + const { currentData: fleet, error } = useGetFleetDetailsQuery({ projectName, fleetId }); + + if (error) { + return {t('common.server_error', { error: getServerError(error) })}; + } + + if (!fleet) { + return ; + } + + // Offer APIs identify active fleets by name; a terminated fleet's name may have been reused. + if (fleet.status === 'terminated') { + return {t('offer.terminated_fleet')}; + } + + return ( + {t('offer.title')}} + /> + ); +}; diff --git a/frontend/src/pages/Fleets/Details/index.tsx b/frontend/src/pages/Fleets/Details/index.tsx index 6e5d9e6d7a..742ba40e76 100644 --- a/frontend/src/pages/Fleets/Details/index.tsx +++ b/frontend/src/pages/Fleets/Details/index.tsx @@ -6,6 +6,7 @@ import { Button, ContentLayout, DetailsHeader, Tabs } from 'components'; enum CodeTab { Details = 'details', + Offers = 'offers', Events = 'events', Inspect = 'inspect', } @@ -92,6 +93,11 @@ export const FleetDetails: React.FC = () => { id: CodeTab.Details, href: ROUTES.FLEETS.DETAILS.FORMAT(paramProjectName, paramFleetId), }, + { + label: t('offer.title'), + id: CodeTab.Offers, + href: ROUTES.FLEETS.DETAILS.OFFERS.FORMAT(paramProjectName, paramFleetId), + }, { label: 'Events', id: CodeTab.Events, diff --git a/frontend/src/pages/Instances/List/hooks/useEmptyMessage.tsx b/frontend/src/pages/Instances/List/hooks/useEmptyMessage.tsx index a2909c7637..401103b57d 100644 --- a/frontend/src/pages/Instances/List/hooks/useEmptyMessage.tsx +++ b/frontend/src/pages/Instances/List/hooks/useEmptyMessage.tsx @@ -18,9 +18,7 @@ export const useEmptyMessages = ({ title={t('fleets.instances.empty_message_title')} message={t('fleets.instances.empty_message_text')} > - + {!isDisabledClearFilter && } ); }, [clearFilter, isDisabledClearFilter]); @@ -31,9 +29,7 @@ export const useEmptyMessages = ({ title={t('fleets.instances.nomatch_message_title')} message={t('fleets.instances.nomatch_message_text')} > - + {!isDisabledClearFilter && } ); }, [clearFilter, isDisabledClearFilter]); diff --git a/frontend/src/pages/Instances/List/hooks/useFilters.test.tsx b/frontend/src/pages/Instances/List/hooks/useFilters.test.tsx new file mode 100644 index 0000000000..f804368042 --- /dev/null +++ b/frontend/src/pages/Instances/List/hooks/useFilters.test.tsx @@ -0,0 +1,60 @@ +/** @jest-environment node */ +import React from 'react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; + +import { useFilters } from './useFilters'; + +jest.mock('hooks', () => ({ + useLocalStorageState: (_key: string, value: boolean) => jest.requireActual('react').useState(value), +})); +jest.mock('services/project', () => ({ useLazyGetProjectsQuery: () => [jest.fn()] })); + +let filters: ReturnType; +let search: string; +let renderer: ReactTestRenderer; + +const Harness = ({ fleetId }: { fleetId?: string }) => { + filters = useFilters(fleetId); + search = useLocation().search; + return null; +}; + +const renderFilters = (url: string, fleetId?: string) => { + act(() => { + renderer = create( + + + , + ); + }); +}; + +afterEach(() => act(() => renderer.unmount())); + +test('embedded instances stay scoped to the fleet without changing the fleet page URL', () => { + const query = '?project_names=other&fleet_ids=other&gpu_count=0'; + renderFilters(`/${query}`, 'fixed-fleet'); + expect(filters.filteringRequestParams).toEqual({ + fleet_ids: ['fixed-fleet'], + only_active: true, + include_imported: true, + }); + + act(() => filters.onChangeOnlyActive!({ detail: { checked: false } } as never)); + act(() => filters.clearFilter()); + expect(filters.filteringRequestParams).toEqual({ + fleet_ids: ['fixed-fleet'], + only_active: false, + include_imported: true, + }); + expect(search).toBe(query); +}); + +test('global instances still read and clear URL filters', () => { + renderFilters('/?project_names=main&fleet_ids=my-fleet'); + expect(filters.filteringRequestParams).toMatchObject({ project_names: ['main'], fleet_ids: ['my-fleet'] }); + act(() => filters.clearFilter()); + expect(filters.filteringRequestParams).toEqual({ only_active: true, include_imported: true }); + expect(search).toBe(''); +}); diff --git a/frontend/src/pages/Instances/List/hooks/useFilters.ts b/frontend/src/pages/Instances/List/hooks/useFilters.ts index 4239ef50c3..9dff289c30 100644 --- a/frontend/src/pages/Instances/List/hooks/useFilters.ts +++ b/frontend/src/pages/Instances/List/hooks/useFilters.ts @@ -23,7 +23,7 @@ const filterKeys: Record = { const limit = 100; -export const useFilters = () => { +export const useFilters = (fleetId?: string) => { const [searchParams, setSearchParams] = useSearchParams(); const [onlyActive, setOnlyActive] = useLocalStorageState('instance-list-filter-only-active', true); const [dynamicFilteringOptions, setDynamicFilteringOptions] = useState([]); @@ -31,11 +31,11 @@ export const useFilters = () => { const [getProjects] = useLazyGetProjectsQuery(); const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => { - return requestParamsToTokens({ searchParams, filterKeys }); + return fleetId ? EMPTY_QUERY : requestParamsToTokens({ searchParams, filterKeys }); }); const clearFilter = () => { - setSearchParams({}); + if (!fleetId) setSearchParams({}); setPropertyFilterQuery(EMPTY_QUERY); }; @@ -61,7 +61,7 @@ export const useFilters = () => { const onChangePropertyFilter: PropertyFilterProps['onChange'] = ({ detail }) => { const { tokens, operation } = detail; - setSearchParams(tokensToSearchParams(tokens, onlyActive)); + if (!fleetId) setSearchParams(tokensToSearchParams(tokens, onlyActive)); setPropertyFilterQuery({ operation, @@ -81,12 +81,13 @@ export const useFilters = () => { return { ...params, + ...(fleetId ? { fleet_ids: [fleetId] } : {}), only_active: onlyActive, include_imported: true, } as Partial; - }, [propertyFilterQuery, onlyActive]); + }, [propertyFilterQuery, onlyActive, fleetId]); - const isDisabledClearFilter = !propertyFilterQuery.tokens.length && !onlyActive; + const isDisabledClearFilter = !propertyFilterQuery.tokens.length; const handleLoadItems: PropertyFilterProps['onLoadItems'] = async ({ detail: { filteringProperty, filteringText } }) => { setDynamicFilteringOptions([]); diff --git a/frontend/src/pages/Instances/List/index.tsx b/frontend/src/pages/Instances/List/index.tsx index a0cd2be951..c4551548e0 100644 --- a/frontend/src/pages/Instances/List/index.tsx +++ b/frontend/src/pages/Instances/List/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } from 'components'; +import { Box, Button, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } from 'components'; import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useInfiniteScroll } from 'hooks'; @@ -26,6 +26,11 @@ export const List: React.FC = () => { }, ]); + return ; +}; + +export const InstanceList: React.FC<{ fleetId?: string }> = ({ fleetId }) => { + const { t } = useTranslation(); const { columns } = useColumnsDefinitions(); const { @@ -40,7 +45,7 @@ export const List: React.FC = () => { isDisabledClearFilter, filteringStatusType, handleLoadItems, - } = useFilters(); + } = useFilters(fleetId); const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetInstancesQuery, @@ -74,10 +79,16 @@ export const List: React.FC = () => { deleteFleets([...selectedItems]).catch(console.log); }; + const activeInstancesToggle = ( + + {t('fleets.instances.active_only')} + + ); + return ( { selectionType="multi" header={
+ + {fleetId && {activeInstancesToggle}} @@ -106,35 +118,32 @@ export const List: React.FC = () => {
} filter={ -
-
- `Use: ${value}`, - }} - filteringOptions={filteringOptions} - filteringProperties={filteringProperties} - filteringStatusType={filteringStatusType} - onLoadItems={handleLoadItems} - /> -
- -
- - {t('fleets.instances.active_only')} - + !fleetId && ( +
+
+ `Use: ${value}`, + }} + filteringOptions={filteringOptions} + filteringProperties={filteringProperties} + filteringStatusType={filteringStatusType} + onLoadItems={handleLoadItems} + /> +
+
{activeInstancesToggle}
-
+ ) } - footer={} + footer={isLoadingMore && } /> ); }; diff --git a/frontend/src/pages/Offers/List/helpers.test.ts b/frontend/src/pages/Offers/List/helpers.test.ts index 01f8a4677a..4f8b5a2d9b 100644 --- a/frontend/src/pages/Offers/List/helpers.test.ts +++ b/frontend/src/pages/Offers/List/helpers.test.ts @@ -1,4 +1,12 @@ -import { rangeToObject, sortGpusByPriceDesc } from './helpers'; +import { + formatGpuVendor, + getOfferAvailability, + getOfferResources, + getOfferSearchParams, + rangeToObject, + toCpuCard, + toOfferCard, +} from './helpers'; const makeGpu = (name: string, price: { min: number; max: number }, availability: TAvailability[] = ['available']): IGpu => ({ name, @@ -11,25 +19,116 @@ const makeGpu = (name: string, price: { min: number; max: number }, availability }); describe('Offers helpers', () => { - test('sortGpusByPriceDesc puts available GPUs first, most expensive first', () => { - const gpus = [ - makeGpu('A10', { min: 0.5, max: 4 }), - makeGpu('B200', { min: 6, max: 48 }, ['not_available']), - makeGpu('H100', { min: 2, max: 16 }), - makeGpu('H200', { min: 3, max: 24 }, ['no_quota', 'idle']), - makeGpu('L4', { min: 0.5, max: 2 }), - ]; + test('GPU selection preserves precise memory and explicit fleet scope', () => { + const gpu = { ...makeGpu('H100', { min: 1, max: 8 }), memory_mib: 275032 }; + const params = getOfferSearchParams(new URLSearchParams('project_name=main&fleet=chosen&group_by=gpu'), gpu); + expect(params.get('gpu_memory')).toBe('268.5859375GB'); + expect(params.get('gpu_count')).toBe('1..8'); + expect(params.get('fleet')).toBe('chosen'); + expect(params.has('group_by')).toBe(false); + expect(getOfferResources(gpu)).toEqual({ gpu: 'nvidia:H100:1..8:268.5859375GB' }); + expect(getOfferSearchParams(new URLSearchParams(), gpu).has('fleet')).toBe(false); + }); + + test('CPU selection replaces GPU constraints and keeps the offer location', () => { + const cpu = { + ...makeGpu('CPU', { min: 0, max: 0 }), + count: { min: 2, max: 2 }, + memory_mib: 1901, + backend: 'kubernetes' as const, + region: 'cluster', + cpu: { cpus: 2, cpu_arch: 'arm', memory_mib: 1901, gpus: [], spot: false }, + }; + const params = getOfferSearchParams(new URLSearchParams('gpu_name=H100&gpu_vendor=nvidia&gpu_memory=80GB'), cpu); + expect(Object.fromEntries(params)).toEqual({ + gpu_count: '0', + cpu_count: '2', + cpu_arch: 'arm', + memory: '1.8564453125GB', + backend: 'kubernetes', + region: 'cluster', + spot_policy: 'on-demand', + }); + expect(getOfferResources(cpu)).toEqual({ gpu: 0, cpu: 'arm:2', memory: '1.8564453125GB' }); + }); + + test('grouped CPU selection preserves ranges without restricting a mixed architecture group', () => { + const cpu = toCpuCard({ + cpu_arch: ['arm', 'x86'], + count: { min: 2, max: 32 }, + memory_mib: { min: 8192, max: 65536 }, + price: { min: 0.1, max: 1 }, + backends: ['aws', 'kubernetes'], + availability: ['available'], + spot: ['on-demand', 'spot'], + }); + expect(cpu.cpu?.architectures).toEqual(['arm', 'x86']); + expect(getOfferResources(cpu)).toEqual({ gpu: 0, cpu: '2..32', memory: '8GB..64GB' }); + expect(Object.fromEntries(getOfferSearchParams(new URLSearchParams('cpu_arch=arm'), cpu))).toEqual({ + gpu_count: '0', + cpu_count: '2..32', + memory: '8GB..64GB', + spot_policy: 'auto', + }); + }); - expect(sortGpusByPriceDesc(gpus).map(({ name }) => name)).toEqual(['H200', 'H100', 'A10', 'L4', 'B200']); + test('Google accelerators are displayed as TPU without changing resource values', () => { + const gpu = { ...makeGpu('v5p', { min: 1, max: 1 }), vendor: 'google' }; + expect(formatGpuVendor(gpu.vendor)).toBe('TPU'); + expect(formatGpuVendor('amd')).toBe('AMD'); + expect(getOfferResources(gpu)).toEqual({ gpu: 'google:v5p:1..8:80GB' }); + expect(getOfferSearchParams(new URLSearchParams(), gpu).get('gpu_vendor')).toBe('google'); }); + test('individual cards use system RAM for CPUs and per-GPU VRAM for GPUs, keeping the whole-offer price', () => { + const resources: IResources = { cpus: 8, cpu_arch: 'arm', memory_mib: 32768, gpus: [], spot: false }; + const offer: IInstanceOffer = { + backend: 'aws', + region: 'eu-west-1', + instance: { name: 'test', resources }, + price: 2, + availability: 'available', + }; + const cpu = toOfferCard(offer); + expect(cpu.cpu?.cpu_arch).toBe('arm'); + expect(cpu.count).toEqual({ min: 8, max: 8 }); + expect(cpu.memory_mib).toBe(32768); + + resources.gpus = [makeGpu('H100', { min: 1, max: 1 }), makeGpu('H100', { min: 1, max: 1 })]; + const gpu = toOfferCard(offer); + expect(gpu.cpu).toBeUndefined(); + expect(gpu.name).toBe('H100'); + expect(gpu.count).toEqual({ min: 2, max: 2 }); + expect(gpu.memory_mib).toBe(81920); + expect(gpu.price).toEqual({ min: 2, max: 2 }); + }); + + test.each([ + [['no_quota', 'no_balance', 'not_available'], 'not_available'], + [['no_quota', 'no_balance'], 'no_balance'], + [['no_quota'], 'no_quota'], + [['idle'], 'idle'], + [['busy'], 'busy'], + [['no_quota', 'available'], undefined], + [['not_available', 'idle'], 'idle'], + [['busy', 'idle'], 'idle'], + [['idle', 'available'], undefined], + [['no_balance', 'unknown'], undefined], + [[], undefined], + ] as [TAvailability[], TAvailability | undefined][])('availability %j shows %s', (statuses, expected) => + expect(getOfferAvailability(statuses)).toBe(expected), + ); + test('rangeToObject parses open and closed ranges', () => { + expect(rangeToObject('0')).toEqual({ min: 0, max: 0 }); + expect(rangeToObject('2')).toEqual({ min: 2, max: 2 }); 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('1.8603515625GB', { requireUnit: true })).toEqual({ min: 1.8603515625, max: 1.8603515625 }); 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 }); diff --git a/frontend/src/pages/Offers/List/helpers.tsx b/frontend/src/pages/Offers/List/helpers.tsx index efbe23aac7..47f177968d 100644 --- a/frontend/src/pages/Offers/List/helpers.tsx +++ b/frontend/src/pages/Offers/List/helpers.tsx @@ -10,13 +10,15 @@ export function convertMiBToGB(mib: number) { return mib / 1024; } -export const getPropertyFilterOptions = (gpus: IGpu[]) => { +export const getPropertyFilterOptions = (gpus: (IGpu | ICpuGroup)[]) => { const names = new Set(); + const vendors = new Set(); const backends = new Set(); const counts = new Set(); gpus.forEach((gp) => { - names.add(gp.name); + if ('name' in gp) names.add(gp.name); + if ('vendor' in gp && gp.vendor) vendors.add(gp.vendor); if (gp.backend) { backends.add(gp.backend); @@ -35,21 +37,108 @@ export const getPropertyFilterOptions = (gpus: IGpu[]) => { return { names, + vendors, backends, counts, }; }; -const AVAILABLE_STATUSES = new Set(['unknown', 'available', 'idle']); +const AVAILABLE_STATUSES = new Set(['unknown', 'available']); -const isGpuAvailable = (gpu: IGpu) => gpu.availability.some((availability) => AVAILABLE_STATUSES.has(availability)); +export const formatGpuVendor = (vendor: string) => (vendor === 'google' ? 'TPU' : vendor.toUpperCase()); -// Available first, as the server sorts, but the most expensive (best) GPUs before cheaper ones. -export const sortGpusByPriceDesc = (gpus: IGpu[]): IGpu[] => - [...gpus].sort( - (a, b) => - Number(isGpuAvailable(b)) - Number(isGpuAvailable(a)) || b.price.min - a.price.min || b.price.max - a.price.max, - ); +export type OfferCard = IGpu & { + cpu?: IResources & { + architectures?: ICpuGroup['cpu_arch']; + memory_range?: ICpuGroup['memory_mib']; + }; +}; + +export const toCpuCard = ({ cpu_arch, memory_mib, ...group }: ICpuGroup): OfferCard => ({ + ...group, + name: 'CPU', + vendor: '', + memory_mib: memory_mib.min, + cpu: { + cpus: group.count.min, + cpu_arch: cpu_arch.length === 1 ? cpu_arch[0] : null, + memory_mib: memory_mib.min, + architectures: cpu_arch, + memory_range: memory_mib, + gpus: [], + spot: group.spot.length === 1 && group.spot[0] === 'spot', + }, +}); + +export const getCpuMemory = (offer: OfferCard) => { + const range = offer.cpu?.memory_range ?? { min: offer.memory_mib, max: offer.memory_mib }; + const min = `${convertMiBToGB(range.min)}GB`; + return range.min === range.max ? min : `${min}..${convertMiBToGB(range.max)}GB`; +}; + +export const toOfferCard = ({ backend, region, instance: { resources }, price, availability }: IInstanceOffer): OfferCard => { + const gpu = resources.gpus[0]; + const count = gpu ? resources.gpus.length : resources.cpus; + return { + name: gpu?.name ?? 'CPU', + vendor: gpu?.vendor ?? '', + memory_mib: gpu?.memory_mib ?? resources.memory_mib, + count: { min: count, max: count }, + price: { min: price, max: price }, + availability: [availability], + spot: [resources.spot ? 'spot' : 'on-demand'], + backend, + region, + cpu: gpu ? undefined : resources, + }; +}; + +export const getOfferResources = (offer: OfferCard): TResourceRequest => + offer.cpu + ? { + gpu: 0, + cpu: [offer.cpu.cpu_arch, renderRange(offer.count)].filter(Boolean).join(':'), + memory: getCpuMemory(offer), + } + : { + gpu: [offer.vendor, offer.name, renderRange(offer.count), `${convertMiBToGB(offer.memory_mib)}GB`] + .filter(Boolean) + .join(':'), + }; + +export const getOfferSearchParams = (searchParams: URLSearchParams, offer?: OfferCard) => { + const params = new URLSearchParams(searchParams); + params.delete('group_by'); + + if (!offer) return params; + + ['gpu_vendor', 'gpu_name', 'gpu_count', 'gpu_memory'].forEach((key) => params.delete(key)); + + if (offer.cpu) { + params.set('gpu_count', '0'); + params.set('cpu_count', renderRange(offer.count) ?? '1'); + params.delete('cpu_arch'); + if (offer.cpu.cpu_arch) params.set('cpu_arch', offer.cpu.cpu_arch); + params.set('memory', getCpuMemory(offer)); + } else { + params.set('gpu_vendor', offer.vendor); + params.set('gpu_name', offer.name); + params.set('gpu_count', renderRange(offer.count) ?? '1'); + params.set('gpu_memory', `${convertMiBToGB(offer.memory_mib)}GB`); + } + + if (offer.backend) params.set('backend', offer.backend); + if (offer.region) params.set('region', offer.region); + params.set('spot_policy', offer.spot.length === 1 ? offer.spot[0] : 'auto'); + return params; +}; + +export const getOfferAvailability = (availability: TAvailability[]) => + availability.some((status) => AVAILABLE_STATUSES.has(status)) + ? undefined + : (['idle', 'not_available', 'no_balance', 'no_quota', 'busy'] as const).find((status) => + availability.includes(status), + ); export const getFleetFilterValue = (fleet: IFleet, selectedProjectName?: string) => { if (selectedProjectName && fleet.project_name === selectedProjectName) { @@ -73,18 +162,19 @@ export const renderRangeJSX = (range: { min?: number; max?: number }) => { if (typeof range.min === 'number' && typeof range.max === 'number' && range.max != range.min) { return ( <> - {round(range.min)} + {Number(range.min.toFixed(4))} {rangeSeparator} - {round(range.max)} + {Number(range.max.toFixed(4))} ); } - return range.min?.toString() ?? range.max?.toString(); + const value = range.min ?? range.max; + return value === undefined ? undefined : Number(value.toFixed(4)); }; export const rangeToObject = ( - range: RequestParam, + range: RequestParam | number, { requireUnit = false, }: { @@ -113,6 +203,10 @@ export const rangeToObject = ( const min = parseBound(minString); const max = parseBound(maxString); + if (!range.includes(rangeSeparator) && typeof min === 'number') { + return { min, max: min }; + } + if (typeof min === 'number' && typeof max === 'number') { return { min, max }; } @@ -124,13 +218,13 @@ export const rangeToObject = ( if (typeof max === 'number') { return { max }; } + + return; } 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; }; diff --git a/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx b/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx index 452cc78b41..f3fb300f69 100644 --- a/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx +++ b/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx @@ -10,12 +10,16 @@ export const useEmptyMessages = ({ clearFilter, projectName, groupBySelected, + ungrouped, hasCustomFilters, + isFleet, }: { clearFilter?: () => void; projectName?: string; groupBySelected?: boolean; + ungrouped?: boolean; hasCustomFilters?: boolean; + isFleet?: boolean; }) => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -38,7 +42,7 @@ export const useEmptyMessages = ({ ); } - if (!groupBySelected) { + if (!groupBySelected && !ungrouped) { return ( - ); - }, [projectName, groupBySelected, hasCustomFilters, renderNoMatchMessage]); + }, [projectName, groupBySelected, ungrouped, hasCustomFilters, renderNoMatchMessage, isFleet]); return { renderEmptyMessage, renderNoMatchMessage } as const; }; diff --git a/frontend/src/pages/Offers/List/hooks/useFilters.test.tsx b/frontend/src/pages/Offers/List/hooks/useFilters.test.tsx new file mode 100644 index 0000000000..a011a2a9f6 --- /dev/null +++ b/frontend/src/pages/Offers/List/hooks/useFilters.test.tsx @@ -0,0 +1,382 @@ +/** @jest-environment node */ +import React from 'react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; + +import { getOfferFilters, useFilters, UseFiltersArgs } from './useFilters'; + +let mockRememberedProject: string | null; +const mockSetRememberedProject = jest.fn(); +let mockProjectsData: { data: { project_name: string }[] } | undefined; +const mockGetProject = jest.fn(); + +jest.mock('hooks/useLocalStorageState', () => ({ + useLocalStorageState: () => [mockRememberedProject, mockSetRememberedProject], +})); +jest.mock('services/fleet', () => ({ useLazyGetProjectFleetsQuery: () => [jest.fn()] })); +jest.mock('services/project', () => ({ + useLazyGetProjectsQuery: () => [jest.fn()], + useGetProjectsQuery: () => ({ data: mockProjectsData }), + useLazyGetProjectQuery: () => [mockGetProject], +})); +let filters: ReturnType; +let search: string; +let renderer: ReactTestRenderer; + +const Harness = (props: Partial) => { + filters = useFilters({ gpus: [], ...props }); + search = useLocation().search; + return null; +}; + +const renderFilters = (url: string, props: Partial = {}) => { + act(() => { + renderer = create( + + + , + ); + }); +}; + +beforeEach(() => { + mockRememberedProject = null; + mockProjectsData = undefined; + mockGetProject.mockReset(); +}); + +afterEach(() => act(() => renderer.unmount())); + +describe('Offer filters', () => { + test('remembers the selected project', () => { + renderFilters('/?project_name=main'); + expect(mockSetRememberedProject).toHaveBeenCalledWith('main'); + }); + + test('restores a valid remembered project even outside the first page of projects', async () => { + mockRememberedProject = 'saved'; + mockProjectsData = { data: [{ project_name: 'first' }, { project_name: 'second' }] }; + mockGetProject.mockReturnValue({ unwrap: () => Promise.resolve({ project_name: 'saved' }) }); + renderFilters('/'); + await act(async () => {}); + expect(mockGetProject).toHaveBeenCalledWith({ name: 'saved' }, false); + expect(filters.filteringRequestParams.project_name).toBe('saved'); + expect(new URLSearchParams(search).get('project_name')).toBe('saved'); + }); + + test('waits for the remembered project to be revalidated before restoring it', () => { + mockRememberedProject = 'saved'; + mockProjectsData = { data: [{ project_name: 'other' }] }; + mockGetProject.mockReturnValue({ unwrap: () => new Promise(() => {}) }); + renderFilters('/'); + expect(filters.filteringRequestParams.project_name).toBeUndefined(); + }); + + test('does not overwrite a project selected while the remembered project is being checked', async () => { + mockRememberedProject = 'saved'; + mockProjectsData = { data: [{ project_name: 'first' }, { project_name: 'second' }] }; + let resolveProject!: (project: { project_name: string }) => void; + mockGetProject.mockReturnValue({ + unwrap: () => + new Promise((resolve) => { + resolveProject = resolve; + }), + }); + renderFilters('/'); + act(() => + filters.onChangePropertyFilter!({ + detail: { operation: 'and', tokens: [{ propertyKey: 'project_name', operator: '=', value: 'chosen' }] }, + } as never), + ); + await act(async () => { + resolveProject({ project_name: 'saved' }); + }); + expect(filters.filteringRequestParams.project_name).toBe('chosen'); + }); + + test.each([403, 404])('discards a remembered project returning %s and keeps the project prompt', async (status) => { + mockRememberedProject = 'removed'; + mockProjectsData = { data: [{ project_name: 'first' }, { project_name: 'second' }] }; + mockGetProject.mockReturnValue({ unwrap: () => Promise.reject({ status }) }); + renderFilters('/'); + await act(async () => {}); + expect(filters.filteringRequestParams.project_name).toBeUndefined(); + expect(mockSetRememberedProject).toHaveBeenCalledWith(null); + }); + + test('an explicit project takes priority over the remembered project', () => { + mockRememberedProject = 'saved'; + renderFilters('/?project_name=main'); + expect(filters.filteringRequestParams.project_name).toBe('main'); + expect(mockGetProject).not.toHaveBeenCalled(); + }); + + test('a fleet project does not replace the remembered selection', () => { + mockRememberedProject = 'saved'; + renderFilters('/', { permanentFilters: { project_name: 'fixed', fleet: 'my-fleet' } }); + expect(filters.filteringRequestParams.project_name).toBe('fixed'); + expect(mockSetRememberedProject).not.toHaveBeenCalled(); + expect(mockGetProject).not.toHaveBeenCalled(); + }); + + test('GPU toggle selects CPUs, clears GPU constraints, and restores grouping', () => { + renderFilters( + '/?project_name=main&gpu_vendor=nvidia&gpu_name=H100&gpu_count=8&memory=4GB&group_by=gpu&group_by=backend', + { + allowUngrouped: true, + }, + ); + expect(filters.gpuEnabled).toBe(true); + expect(filters.canAutoSelectCpu).toBe(false); + + act(() => filters.onChangeGpu(false)); + expect(filters.gpuEnabled).toBe(false); + expect(filters.canAutoSelectCpu).toBe(false); + expect(filters.filteringRequestParams).toEqual({ project_name: 'main', gpu_count: '0', memory: '4GB' }); + expect(filters.propertyFilterQuery.tokens).toContainEqual({ propertyKey: 'gpu_count', operator: '=', value: '0' }); + expect(filters.filteringProperties.filter(({ key }) => key.startsWith('gpu_')).map(({ key }) => key)).toEqual([ + 'gpu_count', + ]); + expect(new URLSearchParams(search).get('gpu_count')).toBe('0'); + expect(new URLSearchParams(search).getAll('group_by')).toEqual(['cpu', 'backend']); + expect(filters.groupBy.map(({ value }) => value)).toEqual(['cpu', 'backend']); + + act(() => filters.onChangeGpu(true)); + expect(filters.gpuEnabled).toBe(true); + expect(filters.filteringRequestParams).toEqual({ project_name: 'main', memory: '4GB' }); + expect(filters.groupBy.map(({ value }) => value)).toEqual(['gpu', 'backend']); + }); + + test('CPU mode survives filter edits and clearing, and a CPU deep link can enable GPU grouping', () => { + renderFilters('/?project_name=main&gpu_count=0', { allowUngrouped: true }); + expect(filters.gpuEnabled).toBe(false); + expect(filters.hasCustomFilters).toBe(false); + act(() => + filters.onChangePropertyFilter!({ + detail: { + operation: 'and', + tokens: [ + { propertyKey: 'project_name', operator: '=', value: 'main' }, + { propertyKey: 'gpu_count', operator: '=', value: '0' }, + { propertyKey: 'memory', operator: '>=', value: '4GB' }, + ], + }, + } as never), + ); + expect(filters.filteringRequestParams).toMatchObject({ gpu_count: '0', memory: '4GB..' }); + act(() => filters.clearFilter()); + expect(filters.filteringRequestParams).toEqual({ project_name: 'main', gpu_count: '0' }); + expect(filters.hasCustomFilters).toBe(false); + act(() => filters.onChangeGpu(true)); + expect(filters.gpuEnabled).toBe(true); + expect(filters.groupBy.map(({ value }) => value)).toEqual(['gpu']); + }); + + test('resource bounds keep their units and range semantics in requests and URLs', () => { + renderFilters('/', { allowUngrouped: true }); + act(() => + filters.onChangePropertyFilter!({ + detail: { + operation: 'and', + tokens: [ + { propertyKey: 'memory', operator: '>=', value: '4GB' }, + { propertyKey: 'gpu_count', operator: '<=', value: '8' }, + ], + }, + } as never), + ); + expect(filters.filteringRequestParams).toMatchObject({ memory: '4GB..', gpu_count: '..8' }); + expect(new URLSearchParams(search).get('memory')).toBe('4GB..'); + expect(new URLSearchParams(search).get('gpu_count')).toBe('..8'); + }); + + test('reports edits, and restores wizard filters and grouping without changing the URL', () => { + const onChange = jest.fn(); + renderFilters('/?template=test', { withSearchParams: false, allowUngrouped: true, onChange }); + expect(onChange).not.toHaveBeenCalled(); + act(() => filters.onChangeGroupBy!({ detail: { selectedOptions: [] } } as never)); + expect(onChange).toHaveBeenLastCalledWith({ group_by: [] }); + expect(search).toBe('?template=test'); + + act(() => renderer.unmount()); + renderFilters('/?template=test', { + withSearchParams: false, + allowUngrouped: true, + defaultFilters: { gpu_count: '0', cpu_count: '2', group_by: [] }, + }); + expect(filters.groupBy).toEqual([]); + expect(filters.filteringRequestParams).toEqual({ gpu_count: '0', cpu_count: '2' }); + expect(filters.propertyFilterQuery.tokens.map(({ propertyKey }) => propertyKey)).not.toContain('group_by'); + }); + + test('a CPU selection can open individual offers and survives Launch filters', () => { + renderFilters('/?gpu_count=0&cpu_count=2&cpu_arch=arm&memory=1.8564453125GB®ion=cluster&group_by=', { + allowUngrouped: true, + }); + expect(filters.groupBy).toEqual([]); + expect( + getOfferFilters(new URLSearchParams('?gpu_count=0&cpu_count=2&cpu_arch=arm&memory=1.8564453125GB®ion=cluster')), + ).toEqual({ + gpu_count: '0', + cpu_count: '2', + cpu_arch: 'arm', + memory: '1.8564453125GB', + region: 'cluster', + }); + }); + + test('GPU vendor suggestions come from offers and preserve API values in URLs and launch filters', async () => { + const gpu: IGpu = { + name: 'H100', + vendor: 'nvidia', + memory_mib: 81920, + count: { min: 1, max: 8 }, + price: { min: 1, max: 8 }, + availability: ['available'], + spot: ['on-demand'], + }; + const loadGpus = jest.fn(); + renderFilters('/?project_name=main&gpu_vendor=amd', { + gpus: [gpu, { ...gpu, name: 'A100' }, { ...gpu, name: 'v5p', vendor: 'google' }], + loadGpus, + }); + expect(filters.filteringRequestParams.gpu_vendor).toBe('amd'); + expect(filters.filteringProperties.find(({ key }) => key === 'gpu_vendor')?.propertyLabel).toBe('GPU vendor'); + expect( + filters.filteringOptions.filter(({ propertyKey }) => propertyKey === 'gpu_vendor').map(({ value }) => value), + ).toEqual(['nvidia', 'google']); + expect(filters.filteringOptions.find(({ value }) => value === 'google')?.label).toBe('TPU'); + + await act(async () => { + await filters.handleLoadItems!({ + detail: { filteringProperty: { key: 'gpu_vendor' }, filteringText: '' }, + } as never); + }); + expect(loadGpus).toHaveBeenCalledWith('main'); + + act(() => + filters.onChangePropertyFilter!({ + detail: { + operation: 'and', + tokens: [ + { propertyKey: 'project_name', operator: '=', value: 'main' }, + { propertyKey: 'gpu_vendor', operator: '=', value: 'nvidia' }, + ], + }, + } as never), + ); + expect(new URLSearchParams(search).get('gpu_vendor')).toBe('nvidia'); + expect(getOfferFilters(new URLSearchParams(search))?.gpu_vendor).toBe('nvidia'); + }); + + test('defaults to GPU grouping and preserves explicitly ungrouped URLs', () => { + renderFilters('/', { allowUngrouped: true }); + expect(filters.gpuEnabled).toBe(true); + expect(filters.canAutoSelectCpu).toBe(true); + expect(filters.groupBy.map(({ value }) => value)).toEqual(['gpu']); + + act(() => filters.onChangeGroupBy!({ detail: { selectedOptions: [] } } as never)); + expect(filters.groupBy).toEqual([]); + expect(new URLSearchParams(search).has('group_by')).toBe(true); + expect(new URLSearchParams(search).get('group_by')).toBe(''); + + act(() => renderer.unmount()); + renderFilters(`/${search}`, { allowUngrouped: true }); + expect(filters.groupBy).toEqual([]); + }); + + test.each(['gpu_count=0', 'gpu_count=0&group_by=gpu', 'group_by=cpu'])('%s selects CPU grouping', (query) => { + renderFilters(`/?project_name=main&${query}`, { allowUngrouped: true }); + expect(filters.gpuEnabled).toBe(false); + expect(filters.filteringRequestParams.gpu_count).toBe('0'); + expect(filters.groupBy).toEqual([{ label: 'CPU', value: 'cpu' }]); + expect(filters.groupByOptions.map(({ label }) => label)).toEqual(['CPU', 'Backend']); + }); + + test.each(['..0', '0..0'])('zero GPU range %s selects CPU grouping', (value) => { + renderFilters(`/?project_name=main&gpu_count=${value}`, { allowUngrouped: true }); + expect(filters.gpuEnabled).toBe(false); + expect(filters.groupBy).toEqual([{ label: 'CPU', value: 'cpu' }]); + }); + + test('an upper bound of zero selects CPU grouping', () => { + renderFilters('/?project_name=main', { allowUngrouped: true }); + act(() => + filters.onChangePropertyFilter!({ + detail: { operation: 'and', tokens: [{ propertyKey: 'gpu_count', operator: '<=', value: '0' }] }, + } as never), + ); + expect(filters.gpuEnabled).toBe(false); + expect(filters.groupBy).toEqual([{ label: 'CPU', value: 'cpu' }]); + expect(new URLSearchParams(search).get('gpu_count')).toBe('..0'); + }); + + test('removing the visible zero GPU count enables GPU and updates grouping', () => { + renderFilters('/?project_name=main&gpu_count=0&group_by=cpu', { allowUngrouped: true }); + act(() => + filters.onChangePropertyFilter!({ + detail: { operation: 'and', tokens: [{ propertyKey: 'project_name', operator: '=', value: 'main' }] }, + } as never), + ); + expect(filters.gpuEnabled).toBe(true); + expect(filters.groupBy).toEqual([{ label: 'GPU', value: 'gpu' }]); + expect(new URLSearchParams(search).has('gpu_count')).toBe(false); + expect(new URLSearchParams(search).get('group_by')).toBe('gpu'); + }); + + test('Clear filter keeps the project and CPU mode', () => { + renderFilters('/?project_name=main&gpu_count=0&memory=4GB&group_by=cpu', { allowUngrouped: true }); + act(() => filters.clearFilter()); + expect(filters.filteringRequestParams).toEqual({ project_name: 'main', gpu_count: '0' }); + expect(filters.groupBy).toEqual([{ label: 'CPU', value: 'cpu' }]); + expect(new URLSearchParams(search).get('project_name')).toBe('main'); + }); + + test('removing the only CPU mode token enables GPU in a fleet', () => { + renderFilters('/?gpu_count=0&group_by=cpu', { + allowUngrouped: true, + permanentFilters: { project_name: 'main', fleet: 'cpu-fleet' }, + }); + act(() => filters.onChangePropertyFilter!({ detail: { operation: 'and', tokens: [] } } as never)); + expect(filters.gpuEnabled).toBe(true); + expect(filters.groupBy).toEqual([{ label: 'GPU', value: 'gpu' }]); + }); + + test('fixed fleet scope overrides URL filters and is serialized as an array', () => { + renderFilters('/?project_name=other&fleet=other&backend=aws', { + permanentFilters: { project_name: 'main', fleet: 'my-fleet' }, + allowUngrouped: true, + }); + expect(filters.filteringRequestParams).toMatchObject({ + project_name: 'main', + fleet: ['my-fleet'], + backend: ['aws'], + }); + expect(filters.propertyFilterQuery.tokens.map(({ propertyKey }) => propertyKey)).toEqual(['backend']); + expect(filters.filteringProperties.map(({ key }) => key)).not.toContain('project_name'); + expect(filters.filteringProperties.map(({ key }) => key)).not.toContain('fleet'); + + act(() => filters.clearFilter()); + expect(filters.filteringRequestParams).toEqual({ project_name: 'main', fleet: ['my-fleet'] }); + expect(filters.hasCustomFilters).toBe(false); + }); + + test('global fleet deep links keep their filter until the project changes', () => { + renderFilters('/?project_name=main&fleet=my-fleet', { showFleetFilter: true }); + expect(filters.filteringRequestParams.fleet).toEqual(['my-fleet']); + + act(() => + filters.onChangePropertyFilter!({ + detail: { + operation: 'and', + tokens: [ + { propertyKey: 'project_name', operator: '=', value: 'other' }, + { propertyKey: 'fleet', operator: '=', value: 'my-fleet' }, + ], + }, + } as never), + ); + expect(filters.filteringRequestParams.project_name).toBe('other'); + expect(filters.filteringRequestParams.fleet).toBeUndefined(); + }); +}); diff --git a/frontend/src/pages/Offers/List/hooks/useFilters.ts b/frontend/src/pages/Offers/List/hooks/useFilters.ts index 872150df89..dd4342b5a8 100644 --- a/frontend/src/pages/Offers/List/hooks/useFilters.ts +++ b/frontend/src/pages/Offers/List/hooks/useFilters.ts @@ -3,6 +3,7 @@ import { useSearchParams } from 'react-router-dom'; import type { MultiselectProps, PropertyFilterProps } from 'components'; +import { useLocalStorageState } from 'hooks/useLocalStorageState'; import { EMPTY_QUERY, getTokenAwareNamePatternFilterRequestParams, @@ -12,36 +13,48 @@ import { tokensToSearchParams, } from 'libs/filters'; import { useLazyGetProjectFleetsQuery } from 'services/fleet'; -import { useGetProjectsQuery, useLazyGetProjectsQuery } from 'services/project'; +import { useGetProjectsQuery, useLazyGetProjectQuery, useLazyGetProjectsQuery } from 'services/project'; -import { getFleetFilterValue, getPropertyFilterOptions } from '../helpers'; +import { formatGpuVendor, getFleetFilterValue, getPropertyFilterOptions, rangeToObject } from '../helpers'; type RequestParamsKeys = | 'project_name' + | 'gpu_vendor' | 'gpu_name' | 'gpu_count' | 'gpu_memory' + | 'cpu_count' + | 'cpu_arch' + | 'memory' | 'backend' + | 'region' | 'fleet' | 'spot_policy' | 'group_by'; export type UseFiltersArgs = { - gpus: IGpu[]; + gpus: (IGpu | ICpuGroup)[]; withSearchParams?: boolean; showFleetFilter?: boolean; + allowUngrouped?: boolean; permanentFilters?: Partial>; defaultFilters?: Partial>; - /** Loads offers for GPU name and backend suggestions, for a page that doesn't list offers itself. */ + onChange?: (filters: NonNullable) => void; + /** Loads offers for GPU name, vendor and backend suggestions, for a page that doesn't list offers itself. */ loadGpus?: (projectName: string) => Promise; }; export const filterKeys: Record = { PROJECT_NAME: 'project_name', + GPU_VENDOR: 'gpu_vendor', GPU_NAME: 'gpu_name', GPU_COUNT: 'gpu_count', GPU_MEMORY: 'gpu_memory', + CPU_COUNT: 'cpu_count', + CPU_ARCH: 'cpu_arch', + MEMORY: 'memory', BACKEND: 'backend', + REGION: 'region', FLEET: 'fleet', SPOT_POLICY: 'spot_policy', }; @@ -87,6 +100,12 @@ const projectFilteringProperty = { const filteringProperties = [ projectFilteringProperty, + { + key: filterKeys.GPU_VENDOR, + operators: [{ operator: '=', format: formatGpuVendor }], + propertyLabel: 'GPU vendor', + groupValuesLabel: 'GPU vendor values', + }, { key: filterKeys.GPU_NAME, operators: ['='], @@ -95,22 +114,46 @@ const filteringProperties = [ }, { key: filterKeys.GPU_COUNT, - operators: ['<=', '>='], + operators: ['=', '<=', '>='], propertyLabel: 'GPU count', groupValuesLabel: 'GPU count values', }, { key: filterKeys.GPU_MEMORY, - operators: ['<=', '>='], + operators: ['=', '<=', '>='], propertyLabel: 'GPU memory', groupValuesLabel: 'GPU memory values', }, + { + key: filterKeys.CPU_COUNT, + operators: ['=', '<=', '>='], + propertyLabel: 'CPU count', + groupValuesLabel: 'CPU count values', + }, + { + key: filterKeys.CPU_ARCH, + operators: ['='], + propertyLabel: 'CPU architecture', + groupValuesLabel: 'CPU architecture values', + }, + { + key: filterKeys.MEMORY, + operators: ['=', '<=', '>='], + propertyLabel: 'RAM', + groupValuesLabel: 'RAM values', + }, { key: filterKeys.BACKEND, operators: ['='], propertyLabel: 'Backend', groupValuesLabel: 'Backend values', }, + { + key: filterKeys.REGION, + operators: ['='], + propertyLabel: 'Region', + groupValuesLabel: 'Region values', + }, { key: filterKeys.FLEET, operators: ['='], @@ -126,22 +169,39 @@ const filteringProperties = [ ]; const gpuFilterOption = { label: 'GPU', value: 'gpu' }; -const defaultGroupByOptions = [{ ...gpuFilterOption }, { label: 'Backend', value: 'backend' }]; +const cpuFilterOption = { label: 'CPU', value: 'cpu' }; +const defaultGroupByOptions = [gpuFilterOption, cpuFilterOption, { label: 'Backend', value: 'backend' }]; +const isResourceGroup = (value?: string) => value === 'gpu' || value === 'cpu'; +const getGroupBy = (options: MultiselectProps.Options, gpuEnabled: boolean): MultiselectProps.Options => + options.map((option) => (isResourceGroup(option.value) ? (gpuEnabled ? gpuFilterOption : cpuFilterOption) : option)); const groupByRequestParamName: RequestParamsKeys = 'group_by'; const limit = 100; +// Use dstack ranges so bounds and memory units survive URLs and wizard navigation. +export const toRangeTokens = (tokens: PropertyFilterProps.Query['tokens']) => + tokens.map((token) => { + if (!['gpu_count', 'gpu_memory', 'cpu_count', 'memory'].includes(token.propertyKey ?? '')) return token; + if (token.operator === '>=') return { ...token, operator: '=', value: `${token.value}..` }; + if (token.operator === '<=') return { ...token, operator: '=', value: `..${token.value}` }; + return token; + }); + export const useFilters = ({ gpus, withSearchParams = true, showFleetFilter = false, + allowUngrouped = false, permanentFilters = {}, defaultFilters, + onChange, loadGpus, }: UseFiltersArgs) => { const [searchParams, setSearchParams] = useSearchParams(); + const [rememberedProject, setRememberedProject] = useLocalStorageState('project-filter-project_name', null); const [dynamicFilteringOptions, setDynamicFilteringOptions] = useState([]); const [filteringStatusType, setFilteringStatusType] = useState(); const [getProjects] = useLazyGetProjectsQuery(); + const [getProject] = useLazyGetProjectQuery(); const [getProjectFleets] = useLazyGetProjectFleetsQuery(); const { data: projectsData } = useGetProjectsQuery({ limit: 2 }); const propertyFilterRef = useRef(null); @@ -150,17 +210,36 @@ export const useFilters = ({ // Without search params, the page URL belongs to another page, e.g. the Launch wizard. const initialSearchParams = withSearchParams ? searchParams : new URLSearchParams(); + const canAutoSelectCpu = useRef( + allowUngrouped && + withSearchParams && + !Array.from(initialSearchParams.keys()).some((key) => key.startsWith('gpu_')) && + !Object.keys(defaultFilters ?? {}).some((key) => key.startsWith('gpu_')), + ); const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => { const queryFromSearchParams = requestParamsToTokens({ searchParams: initialSearchParams, filterKeys, - defaultFilterValues: defaultFilters, + defaultFilterValues: + defaultFilters && + Object.fromEntries(Object.entries(defaultFilters).filter(([key]) => key !== groupByRequestParamName)), }); - const tokens = showFleetFilter - ? queryFromSearchParams.tokens - : queryFromSearchParams.tokens.filter((token) => token.propertyKey !== filterKeys.FLEET); + const tokens = queryFromSearchParams.tokens.filter( + (token) => + !Object.keys(permanentFilters).includes(token.propertyKey ?? '') && + (showFleetFilter || token.propertyKey !== filterKeys.FLEET), + ); + + if ( + allowUngrouped && + (initialSearchParams.getAll(groupByRequestParamName).includes('cpu') || + defaultFilters?.group_by?.includes('cpu')) && + !tokens.some(({ propertyKey }) => propertyKey === filterKeys.GPU_COUNT) + ) { + tokens.push({ propertyKey: filterKeys.GPU_COUNT, operator: '=', value: '0' }); + } const query = { ...queryFromSearchParams, @@ -174,33 +253,65 @@ export const useFilters = ({ return EMPTY_QUERY; }); + const isCpuModeToken = (token: PropertyFilterProps.Token) => + allowUngrouped && + token.propertyKey === filterKeys.GPU_COUNT && + rangeToObject(toRangeTokens([token])[0].value)?.max === 0; + const gpuEnabled = !propertyFilterQuery.tokens.some(isCpuModeToken); + const [groupBy, setGroupBy] = useState(() => { + if (!withSearchParams && defaultFilters?.group_by !== undefined) { + return getGroupBy( + defaultGroupByOptions.filter(({ value }) => defaultFilters.group_by?.includes(value)), + gpuEnabled, + ); + } + const selectedGroupBy = requestParamsToArray({ searchParams: initialSearchParams, paramName: groupByRequestParamName, }); - if (selectedGroupBy.length) { - return defaultGroupByOptions.filter(({ value }) => selectedGroupBy.includes(value)); + if (selectedGroupBy.length || (allowUngrouped && initialSearchParams.has(groupByRequestParamName))) { + return getGroupBy( + defaultGroupByOptions.filter(({ value }) => selectedGroupBy.includes(value)), + gpuEnabled, + ); } - return [gpuFilterOption]; + return [gpuEnabled ? gpuFilterOption : cpuFilterOption]; }); - const hasCustomFilters = propertyFilterQuery.tokens.some((token) => token.propertyKey !== filterKeys.PROJECT_NAME); + const hasCustomFilters = propertyFilterQuery.tokens.some( + (token) => token.propertyKey !== filterKeys.PROJECT_NAME && !isCpuModeToken(token), + ); // Keeps the project and the group by, so that clearing leads back to the project's offers. const clearFilter = () => { onChangePropertyFilterHandle({ - tokens: propertyFilterQuery.tokens.filter((token) => token.propertyKey === filterKeys.PROJECT_NAME), + tokens: propertyFilterQuery.tokens.filter( + (token) => token.propertyKey === filterKeys.PROJECT_NAME || isCpuModeToken(token), + ), operation: propertyFilterQuery.operation, }); }; const filteringOptions = useMemo(() => { - const options: PropertyFilterProps.FilteringOption[] = [...spotPolicyOptions, ...dynamicFilteringOptions]; + const options: PropertyFilterProps.FilteringOption[] = [ + ...['x86', 'arm'].map((value) => ({ propertyKey: filterKeys.CPU_ARCH, value })), + ...spotPolicyOptions, + ...dynamicFilteringOptions, + ]; - const { names, backends } = getPropertyFilterOptions(gpus); + const { names, vendors, backends } = getPropertyFilterOptions(gpus); + + Array.from(vendors).forEach((vendor) => { + options.push({ + propertyKey: filterKeys.GPU_VENDOR, + value: vendor, + label: formatGpuVendor(vendor), + }); + }); Array.from(names).forEach((name) => { options.push({ @@ -220,35 +331,41 @@ export const useFilters = ({ }, [gpus, dynamicFilteringOptions]); const groupByOptions: MultiselectProps.Options = useMemo(() => { - return defaultGroupByOptions.map((option) => { - if (option.value === 'gpu' && groupBy.some(({ value }) => value === 'backend')) { - return { - ...option, - disabled: true, - }; - } - - if (option.value === 'backend' && !groupBy.some(({ value }) => value === 'gpu')) { - return { - ...option, - disabled: true, - }; - } - - return option; - }); - }, [groupBy]); + return defaultGroupByOptions + .filter(({ value }) => !isResourceGroup(value) || value === (gpuEnabled ? 'gpu' : 'cpu')) + .map((option) => { + if (isResourceGroup(option.value) && groupBy.some(({ value }) => value === 'backend')) { + return { + ...option, + disabled: true, + }; + } + + if (option.value === 'backend' && !groupBy.some(({ value }) => isResourceGroup(value))) { + return { + ...option, + disabled: true, + }; + } + + return option; + }); + }, [groupBy, gpuEnabled]); const filteringPropertiesForShowing = useMemo(() => { const permanentFilterKeys = Object.keys(permanentFilters); return filteringProperties.filter(({ key }) => { + if (!gpuEnabled && key.startsWith('gpu_') && key !== filterKeys.GPU_COUNT) { + return false; + } + if (key === filterKeys.FLEET && !showFleetFilter) { return false; } return !permanentFilterKeys.includes(key); }); - }, [permanentFilters, showFleetFilter]); + }, [permanentFilters, showFleetFilter, gpuEnabled]); const setSearchParamsHandle = ({ tokens, @@ -257,18 +374,20 @@ export const useFilters = ({ tokens: PropertyFilterProps.Query['tokens']; groupBy: MultiselectProps.Options; }) => { - if (!withSearchParams) { - return; - } - - const searchParams = tokensToSearchParams(tokens); + const searchParams = tokensToSearchParams(toRangeTokens(tokens)); groupBy.forEach(({ value }) => searchParams.append(groupByRequestParamName, value as string)); - setSearchParams(searchParams); + if (allowUngrouped && !groupBy.length) { + searchParams.set(groupByRequestParamName, ''); + } + + onChange?.({ ...getOfferFilters(searchParams), group_by: groupBy.map(({ value }) => value as string) }); + + if (withSearchParams) setSearchParams(searchParams); }; - const onChangePropertyFilterHandle = ({ tokens, operation }: PropertyFilterProps.Query) => { + const onChangePropertyFilterHandle = ({ tokens, operation }: PropertyFilterProps.Query, nextGroupBy = groupBy) => { const filteredTokens = tokens.filter((token, tokenIndex) => { return ( multipleChoiceKeys.includes(token.propertyKey as RequestParamsKeys) || @@ -276,11 +395,13 @@ export const useFilters = ({ ); }); + nextGroupBy = getGroupBy(nextGroupBy, !filteredTokens.some(isCpuModeToken)); setSearchParamsHandle({ tokens: filteredTokens, - groupBy: [...groupBy], + groupBy: nextGroupBy, }); + setGroupBy(nextGroupBy); setPropertyFilterQuery({ operation, tokens: filteredTokens, @@ -291,12 +412,22 @@ export const useFilters = ({ onChangePropertyFilterHandle(detail); }; + const onChangeGpu = (enabled: boolean) => { + canAutoSelectCpu.current = false; + const tokens = propertyFilterQuery.tokens.filter((token) => !token.propertyKey?.startsWith('gpu_')); + if (!enabled) tokens.push({ propertyKey: filterKeys.GPU_COUNT, operator: '=', value: '0' }); + onChangePropertyFilterHandle( + { ...propertyFilterQuery, tokens }, + enabled && !groupBy.length ? [gpuFilterOption] : groupBy, + ); + }; + const onChangeGroupBy: MultiselectProps['onChange'] = ({ detail }) => { - const selectedGpu = detail.selectedOptions.some(({ value }) => value === 'gpu'); + const selectedResource = detail.selectedOptions.some(({ value }) => isResourceGroup(value)); let tempSelectedOptions: MultiselectProps.Options = detail.selectedOptions; - if (!selectedGpu) { + if (!selectedResource) { tempSelectedOptions = detail.selectedOptions.filter(({ value }) => value !== 'backend'); } @@ -310,14 +441,15 @@ export const useFilters = ({ const filteringRequestParams = useMemo(() => { const params = tokensToRequestParams({ - tokens: propertyFilterQuery.tokens, + tokens: toRangeTokens(propertyFilterQuery.tokens), arrayFieldKeys: multipleChoiceKeys, }); - return { - ...params, - ...permanentFilters, - }; + Object.entries(permanentFilters).forEach(([key, value]) => { + params[key as RequestParamsKeys] = multipleChoiceKeys.includes(key as RequestParamsKeys) ? [value] : value; + }); + + return params; }, [propertyFilterQuery, permanentFilters]); const selectedProjectName = useMemo(() => { @@ -326,6 +458,12 @@ export const useFilters = ({ return typeof projectName === 'string' ? projectName : undefined; }, [filteringRequestParams]); + useEffect(() => { + if (!permanentFilters.project_name && selectedProjectName && selectedProjectName !== rememberedProject) { + setRememberedProject(selectedProjectName); + } + }, [selectedProjectName]); + const handleLoadItems: PropertyFilterProps['onLoadItems'] = async ({ detail: { filteringProperty, filteringText } }) => { setDynamicFilteringOptions([]); @@ -350,7 +488,9 @@ export const useFilters = ({ .then(setDynamicFilteringOptions); } - const isGpuProperty = filteringProperty?.key === filterKeys.GPU_NAME || filteringProperty?.key === filterKeys.BACKEND; + const isGpuProperty = [filterKeys.GPU_NAME, filterKeys.GPU_VENDOR, filterKeys.BACKEND].includes( + filteringProperty?.key as RequestParamsKeys, + ); if (loadGpus && isGpuProperty && selectedProjectName) { await loadGpus(selectedProjectName); @@ -379,7 +519,8 @@ export const useFilters = ({ // Cloudscape can only focus the filter, so typing "Project = " into it lists the projects. const openProjectValues = () => { - propertyFilterRef.current?.focus(); + if (!propertyFilterRef.current) return; + propertyFilterRef.current.focus(); const input = document.activeElement; const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; @@ -393,40 +534,62 @@ export const useFilters = ({ }; useEffect(() => { - if (projectNameIsChecked.current || !projectsData?.data?.length) { + if (projectNameIsChecked.current || !projectsData) { return; } - projectNameIsChecked.current = true; - - if (filteringRequestParams['project_name']) { + if (selectedProjectName) { + projectNameIsChecked.current = true; return; } - // With several projects, guessing is usually wrong, so the user picks one. - if (projectsData.data.length > 1) { - openProjectValues(); - return; - } + let cancelled = false; + const restoreProject = async () => { + let projectName = projectsData.data.length === 1 ? projectsData.data[0].project_name : undefined; + if (rememberedProject) { + try { + const project = await getProject({ name: rememberedProject }, false).unwrap(); + projectName = project.project_name; + } catch (error) { + if ( + !cancelled && + error && + typeof error === 'object' && + 'status' in error && + [403, 404].includes(Number(error.status)) + ) { + setRememberedProject(null); + } + } + } + if (cancelled) return; + projectNameIsChecked.current = true; - onChangePropertyFilterHandle({ - tokens: [ - ...propertyFilterQuery.tokens, - { - operator: '=', - propertyKey: filterKeys.PROJECT_NAME, - value: projectsData.data[0].project_name, - }, - ], - operation: 'and', - }); - }, [projectsData]); + // Otherwise, with several projects, the user picks one. + if (!projectName) { + openProjectValues(); + return; + } + + onChangePropertyFilterHandle({ + tokens: [ + ...propertyFilterQuery.tokens, + { operator: '=', propertyKey: filterKeys.PROJECT_NAME, value: projectName }, + ], + operation: 'and', + }); + }; + void restoreProject(); + return () => { + cancelled = true; + }; + }, [projectsData, selectedProjectName, rememberedProject, propertyFilterQuery]); useEffect(() => { const prevProjectName = prevSelectedProjectName.current; prevSelectedProjectName.current = selectedProjectName; - if (!showFleetFilter || prevProjectName === selectedProjectName) { + if (!showFleetFilter || prevProjectName === undefined || prevProjectName === selectedProjectName) { return; } @@ -452,6 +615,9 @@ export const useFilters = ({ groupBy, groupByOptions, onChangeGroupBy, + gpuEnabled, + onChangeGpu, + canAutoSelectCpu: canAutoSelectCpu.current, filteringStatusType, handleLoadItems, } as const; diff --git a/frontend/src/pages/Offers/List/index.tsx b/frontend/src/pages/Offers/List/index.tsx index 39216af9b8..51a682be2a 100644 --- a/frontend/src/pages/Offers/List/index.tsx +++ b/frontend/src/pages/Offers/List/index.tsx @@ -1,33 +1,59 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import Badge from '@cloudscape-design/components/badge'; +import { skipToken } from '@reduxjs/toolkit/query'; -import { Alert, Cards, CardsProps, MultiselectCSD, Popover, PropertyFilter } from 'components'; +import { Alert, Button, Cards, CardsProps, MultiselectCSD, Popover, PropertyFilter, SpaceBetween, Toggle } from 'components'; import { useCollection } from 'hooks'; -import { useGetGpusListQuery } from 'services/gpu'; +import { getServerError } from 'libs'; +import { formatBackend } from 'libs/fleet'; +import { useGetGpusListQuery, useGetOffersQuery } from 'services/gpu'; import { useEmptyMessages } from './hooks/useEmptyMessages'; import { useFilters, UseFiltersArgs } from './hooks/useFilters'; -import { convertMiBToGB, rangeToObject, renderRange, renderRangeJSX, round, sortGpusByPriceDesc } from './helpers'; +import { + convertMiBToGB, + formatGpuVendor, + getCpuMemory, + getOfferAvailability, + OfferCard, + rangeToObject, + renderRange, + renderRangeJSX, + round, + toCpuCard, + toOfferCard, +} from './helpers'; import styles from './styles.module.scss'; export const getRequestParams = ({ project_name, + gpu_vendor, gpu_name, backend, fleet, gpu_count, gpu_memory, + cpu_count, + cpu_arch, + memory, + region, spot_policy, group_by, }: { project_name: string; + gpu_vendor?: string; gpu_name?: string[]; backend?: string[]; fleet?: string[]; gpu_count?: string; gpu_memory?: string; + cpu_count?: string; + cpu_arch?: string; + memory?: string; + region?: string; spot_policy?: TSpot; group_by?: TGpuGroupBy[]; }): TGpusListQueryParams => { @@ -43,17 +69,22 @@ export const getRequestParams = ({ ports: [], commands: [':'], type: 'task', + image: 'scratch', privileged: false, home_dir: '/root', env: {}, resources: { - // cpu/memory/disk should match ResourcesSpec.unconstrained() used by `dstack offer` CLI command - cpu: { count: { min: 1 } }, - memory: { min: 0.0 }, + // Default to the same minimal CPU, memory and disk constraints as `dstack offer`. + cpu: { + count: (rangeToObject(cpu_count ?? '') as TRange | undefined) ?? { min: 1 }, + ...(cpu_arch ? { arch: cpu_arch as 'x86' | 'arm' } : {}), + }, + memory: (rangeToObject(memory ?? '', { requireUnit: true }) as TRange | undefined) ?? { min: 0.0 }, disk: null, gpu: { + ...(gpu_vendor ? { vendor: gpu_vendor } : {}), ...(gpu_name?.length ? { name: gpu_name } : {}), - ...(gpuCountMinMax ? { count: gpuCountMinMax as unknown as TRange } : {}), + count: gpuCountMinMax ? (gpuCountMinMax as unknown as TRange) : { min: 1 }, ...(gpuMemoryMinMax ? { memory: gpuMemoryMinMax as unknown as TRange } : {}), }, }, @@ -63,26 +94,39 @@ export const getRequestParams = ({ setup: [], ...(backend?.length ? { backends: backend as TBackendType[] } : {}), ...(fleet?.length ? { fleets: fleet } : {}), + ...(region ? { regions: [region] } : {}), }, profile: { name: 'default', default: false }, ssh_key_pub: '(dummy)', }, + // TODO: Add UI controls for full_offers and unallocated_resources for both grouped and individual offers. full_offers: true, }; }; -type OfferListProps = Pick & { +type OfferListProps = Pick< + CardsProps, + 'variant' | 'header' | 'onSelectionChange' | 'selectedItems' | 'selectionType' +> & { permanentFilters?: UseFiltersArgs['permanentFilters']; defaultFilters?: UseFiltersArgs['defaultFilters']; withSearchParams?: boolean; showFleetFilter?: boolean; - disabled?: boolean; - onChangeProjectName?: (value: string) => void; + allowUngrouped?: boolean; onChangeBackendFilter?: (backends: string[]) => void; onChangeFleetFilter?: (fleets: string[]) => void; - /** A GPU to select once offers load; `onPreselect` receives the matching offer. */ - preselectedGpu?: { name: string; memoryGb: number }; - onPreselect?: (gpu: IGpu) => void; + /** An offer to select once matching resources load. */ + preselectedOffer?: { + name?: string; + memoryGb?: number; + cpuCount?: string; + cpuMemory?: string; + cpuArch?: string | null; + backend?: TBackendType; + region?: string; + }; + onPreselect?: (gpu: OfferCard) => void; + onChangeFilters?: UseFiltersArgs['onChange']; /** Called with whether the project has no offers without custom filters. */ onChangeNoOffers?: (noOffers: boolean) => void; }; @@ -90,28 +134,29 @@ type OfferListProps = Pick = ({ withSearchParams, showFleetFilter, - disabled, - onChangeProjectName, + allowUngrouped = false, onChangeBackendFilter, onChangeFleetFilter, permanentFilters, defaultFilters, - preselectedGpu, + preselectedOffer, onPreselect, + onChangeFilters, onChangeNoOffers, ...props }) => { const { t } = useTranslation(); const [requestParams, setRequestParams] = useState(); - const { data, error, isError, isLoading, isFetching } = useGetGpusListQuery( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - requestParams, - { - skip: disabled || !requestParams || !requestParams['project_name'] || !requestParams['group_by']?.length, - }, - ); + const groupedQuery = useGetGpusListQuery(requestParams ?? skipToken, { + skip: !requestParams || !requestParams['project_name'] || !requestParams['group_by']?.length, + refetchOnMountOrArgChange: true, + }); + const offersQuery = useGetOffersQuery(requestParams ?? skipToken, { + skip: !allowUngrouped || !requestParams?.project_name || !!requestParams.group_by?.length, + refetchOnMountOrArgChange: true, + }); + const individualCards = useMemo(() => (offersQuery.currentData?.offers ?? []).map(toOfferCard), [offersQuery.currentData]); const { filteringRequestParams, @@ -125,9 +170,25 @@ export const OfferList: React.FC = ({ groupBy, groupByOptions, onChangeGroupBy, + gpuEnabled, + onChangeGpu, + canAutoSelectCpu, filteringStatusType, handleLoadItems, - } = useFilters({ gpus: data?.gpus ?? [], withSearchParams, showFleetFilter, permanentFilters, defaultFilters }); + } = useFilters({ + gpus: requestParams?.group_by?.length + ? (groupedQuery.currentData?.cpus ?? groupedQuery.currentData?.gpus ?? []) + : individualCards, + withSearchParams, + showFleetFilter, + allowUngrouped, + permanentFilters, + defaultFilters, + onChange: onChangeFilters, + }); + + const ungrouped = allowUngrouped && !groupBy.length; + const { error, isError, isLoading, isFetching } = ungrouped ? offersQuery : groupedQuery; useEffect(() => { setRequestParams( @@ -140,11 +201,6 @@ export const OfferList: React.FC = ({ ); }, [JSON.stringify(filteringRequestParams), groupBy]); - useEffect(() => { - const projectName = typeof filteringRequestParams.project_name === 'string' ? filteringRequestParams.project_name : ''; - onChangeProjectName?.(projectName); - }, [filteringRequestParams.project_name]); - useEffect(() => { const backend = filteringRequestParams.backend; const backendValues = backend @@ -164,45 +220,65 @@ export const OfferList: React.FC = ({ const { renderEmptyMessage, renderNoMatchMessage } = useEmptyMessages({ clearFilter, projectName: requestParams?.['project_name'], - groupBySelected: Boolean(requestParams?.['group_by']?.length), + groupBySelected: !ungrouped && Boolean(requestParams?.['group_by']?.length), + ungrouped, hasCustomFilters, + isFleet: Boolean(permanentFilters?.fleet), }); - const gpus = useMemo(() => sortGpusByPriceDesc(data?.gpus ?? []), [data]); + const gpus = useMemo( + () => + gpuEnabled + ? (groupedQuery.currentData?.gpus ?? []).map((gpu) => ({ ...gpu, price: gpu.offer_price ?? gpu.price })) + : (groupedQuery.currentData?.cpus ?? []).map(toCpuCard), + [groupedQuery.currentData, gpuEnabled], + ); const noOffers = !!requestParams?.project_name && - !!requestParams?.group_by?.length && + (ungrouped || !!requestParams?.group_by?.length) && !isLoading && !isFetching && !isError && !hasCustomFilters && - gpus.length === 0; + (ungrouped ? offersQuery.currentData?.offers.length === 0 : gpus.length === 0); useEffect(() => { onChangeNoOffers?.(noOffers); + if (noOffers && gpuEnabled && canAutoSelectCpu) { + onChangeGpu(false); + } }, [noOffers]); + const cards = useMemo(() => (ungrouped ? individualCards : gpus), [ungrouped, individualCards, gpus]); + const isPreselected = useRef(false); useEffect(() => { - if (!preselectedGpu || isPreselected.current) { + if (!preselectedOffer || isPreselected.current) { return; } - const gpu = gpus.find( - ({ name, memory_mib }) => - name === preselectedGpu.name && round(convertMiBToGB(memory_mib)) === preselectedGpu.memoryGb, + const gpu = cards.find( + (offer) => + (preselectedOffer.cpuCount + ? !!offer.cpu && renderRange(offer.count) === preselectedOffer.cpuCount + : offer.name === preselectedOffer.name) && + (!preselectedOffer.cpuMemory || getCpuMemory(offer) === preselectedOffer.cpuMemory) && + (!preselectedOffer.cpuArch || offer.cpu?.cpu_arch === preselectedOffer.cpuArch) && + (!preselectedOffer.backend || offer.backend === preselectedOffer.backend) && + (!preselectedOffer.region || offer.region === preselectedOffer.region) && + (preselectedOffer.memoryGb === undefined || convertMiBToGB(offer.memory_mib) === preselectedOffer.memoryGb), ); if (gpu) { isPreselected.current = true; onPreselect?.(gpu); } - }, [gpus]); + }, [cards]); const { items, collectionProps } = useCollection( - requestParams?.['project_name'] && requestParams?.['group_by']?.length ? gpus : [], + requestParams?.project_name && (ungrouped || requestParams.group_by?.length) ? cards : [], { filtering: { empty: renderEmptyMessage(), @@ -212,149 +288,158 @@ export const OfferList: React.FC = ({ }, ); - const groupByBackend = groupBy.some(({ value }) => value === 'backend'); - - const sections = [ - { - id: 'memory_mib', - // header: t('offer.memory_mib'), - content: (gpu: IGpu) => ( -
- {round(convertMiBToGB(gpu.memory_mib))}GB - : - {renderRange(gpu.count)} -
- ), - width: 50, - }, - { - id: 'price', - // header: t('offer.price'), - content: (gpu: IGpu) => ${renderRangeJSX(gpu.price) ?? '-'}, - width: 50, - }, - // { - // id: 'count', - // header: t('offer.count'), - // content: (gpu: IGpu) => renderRange(gpu.count) ?? '-', - // width: 50, - // }, - !groupByBackend && { - id: 'backends', - // header: t('offer.backend_plural'), - content: (gpu: IGpu) => gpu.backends?.join(', ') ?? '-', - width: 50, - }, - groupByBackend && { - id: 'backend', - // header: t('offer.backend'), - content: (gpu: IGpu) => gpu.backend ?? '-', - width: 50, - }, - // { - // id: 'region', - // header: t('offer.region'), - // content: (gpu) => gpu.region ?? gpu.regions?.join(', ') ?? '-', - // width: 50, - // }, - { - id: 'spot', - // header: t('offer.spot'), - content: (gpu: IGpu) => gpu.spot.join(', ') ?? '-', - width: 50, - }, + const sections: CardsProps.CardDefinition['sections'] = [ { - id: 'availability', - content: (gpu: IGpu) => { - const availabilityIssues = - gpu.availability.length > 0 && - gpu.availability.every((a) => a === 'not_available' || a === 'no_quota' || a === 'no_balance'); - - if (!availabilityIssues) { - return null; - } - - if (gpu.availability.length === 1) { - return {t(`offer.availability_${gpu.availability[0]}`)}; - } - + id: 'resources', + content: (gpu) => { + const availability = getOfferAvailability(gpu.availability); + const label = availability && ( + {t(`offer.availability_${availability}`)} + ); return ( - t(`offer.availability_${a}`)).join(', ')} - > - {t('offer.availability_not_available')} - +
+
+ {gpu.cpu?.memory_range + ? renderRange({ + min: round(convertMiBToGB(gpu.cpu.memory_range.min)), + max: round(convertMiBToGB(gpu.cpu.memory_range.max)), + }) + : round(convertMiBToGB(gpu.memory_mib))} + GB {gpu.cpu ? 'RAM' : 'VRAM'} +
+ ${renderRangeJSX(gpu.price) ?? '-'} +
+ {gpu.backend ? formatBackend(gpu.backend) : (gpu.backends?.map(formatBackend).join(', ') ?? '-')} +
+
{gpu.spot.join(', ') || '-'}
+ {ungrouped && gpu.region &&
{gpu.region}
} + {label && + (gpu.availability.length === 1 ? ( + label + ) : ( + t(`offer.availability_${a}`)).join(', ')} + > + {label} + + ))} +
); }, - width: 50, }, - ].filter(Boolean) as CardsProps.CardDefinition['sections']; + ]; return ( <> - {!disabled && isError && ( + {isError && ( - {'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' })} + {t('common.server_error', { error: getServerError(error) })} )} - {...collectionProps} {...props} entireCardClickable - items={disabled ? [] : items} - empty={disabled ? ' ' : collectionProps.empty} + cardsPerRow={[ + { cards: 1 }, + { minWidth: 580, cards: 2 }, + { minWidth: 900, cards: 3 }, + { minWidth: 1200, cards: 4 }, + ]} + items={items} cardDefinition={{ - header: (gpu) => gpu.name, + header: (gpu) => { + const architectures = gpu.cpu?.architectures ?? (gpu.cpu?.cpu_arch ? [gpu.cpu.cpu_arch] : []); + const badge = gpu.cpu + ? architectures + .map((arch) => (arch === 'x86' ? 'x86_64' : arch === 'arm' ? 'arm64' : arch)) + .join(', ') + : formatGpuVendor(gpu.vendor); + return ( + + {`${gpu.cpu ? 'CPU' : gpu.name}:${renderRange(gpu.count)}`} + {badge && {badge}} + + ); + }, sections, }} - loading={!disabled && (isLoading || isFetching)} + loading={isLoading || isFetching} loadingText={t('common.loading')} stickyHeader={true} filter={ - disabled ? undefined : ( -
-
- `Use: ${value}`, - }} - filteringOptions={filteringOptions} - filteringProperties={filteringProperties} - filteringStatusType={filteringStatusType} - onLoadItems={handleLoadItems} - /> -
+
+
+ + {t('common.clearFilter')} + + } + 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} + /> +
-
- + onChangeGpu(detail.checked)} disabled={isLoading || isFetching} - /> + > + GPU +
+ )} + +
+
- ) +
} /> + {ungrouped && + offersQuery.currentData && + offersQuery.currentData.total_offers > offersQuery.currentData.offers.length && ( + + {t('offer.truncated', { + shown: offersQuery.currentData.offers.length, + total: offersQuery.currentData.total_offers, + })} + + )} ); }; diff --git a/frontend/src/pages/Offers/List/styles.module.scss b/frontend/src/pages/Offers/List/styles.module.scss index dd0686150b..9d08c453a4 100644 --- a/frontend/src/pages/Offers/List/styles.module.scss +++ b/frontend/src/pages/Offers/List/styles.module.scss @@ -10,6 +10,11 @@ width: 240px; } + .gpuToggle { + flex-shrink: 0; + padding-top: 7px; + } + .propertyFilter { max-width: 640px; flex-grow: 1; @@ -24,3 +29,10 @@ .greenText { color: awsui.$color-text-status-success; } + +.cardContent { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: awsui.$space-scaled-m awsui.$space-scaled-xs; + overflow-wrap: anywhere; +} diff --git a/frontend/src/pages/Offers/ListPage.tsx b/frontend/src/pages/Offers/ListPage.tsx index 5fb2179595..0d9572ba45 100644 --- a/frontend/src/pages/Offers/ListPage.tsx +++ b/frontend/src/pages/Offers/ListPage.tsx @@ -6,7 +6,7 @@ import { product } from 'product'; import { useBreadcrumbs } from 'hooks'; import { ROUTES } from 'routes'; -import { convertMiBToGB, round } from './List/helpers'; +import { getOfferSearchParams, OfferCard } from './List/helpers'; import { Button, Header } from '../../components'; import { OfferList } from './List'; @@ -14,7 +14,7 @@ export const ListPage: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const [selectedGpus, setSelectedGpus] = useState([]); + const [selectedOffers, setSelectedOffers] = useState([]); const [noOffers, setNoOffers] = useState(false); useBreadcrumbs([ @@ -24,37 +24,21 @@ export const ListPage: React.FC = () => { }, ]); - // Carries the filters over; a picked GPU narrows them to its name and memory. const launch = () => { - const params = new URLSearchParams(searchParams); - params.delete('group_by'); - - const [gpu] = selectedGpus; - - if (gpu) { - params.set('gpu_name', gpu.name); - params.set('gpu_memory', `${round(convertMiBToGB(gpu.memory_mib))}GB`); - } - + const params = getOfferSearchParams(searchParams, selectedOffers[0]); navigate(`${ROUTES.RUNS.LAUNCH}?${params}`); }; - // Selecting an offer only matters for templates. - const selectionProps = product.hasTemplates - ? { - selectionType: 'single' as const, - selectedItems: selectedGpus, - onSelectionChange: ({ detail }: { detail: { selectedItems: IGpu[] } }) => setSelectedGpus(detail.selectedItems), - } - : {}; - return ( setSelectedGpus([])} + allowUngrouped + onChangeFilters={() => setSelectedOffers([])} onChangeNoOffers={setNoOffers} - {...selectionProps} + selectionType={product.hasTemplates ? 'single' : undefined} + selectedItems={selectedOffers} + onSelectionChange={({ detail }) => setSelectedOffers(detail.selectedItems)} header={
{ const [isResetTemplatesRepoVisible, setIsResetTemplatesRepoVisible] = useState(false); const changeTemplatesRepoInputWrapperRef = React.useRef(null); const dangerZoneRef = React.useRef(null); + const backendsRef = React.useRef(null); useEffect(() => { setTemplatesRepoValue(data?.templates_repo ?? ''); @@ -157,11 +158,13 @@ export const ProjectSettings: React.FC = () => { const isLoadingPage = isLoading || !data || isLoadingGateways; useEffect(() => { - if (location.hash === '#danger-zone') { - setTimeout(() => { + if (location.hash === '#danger-zone' || location.hash === '#backends') { + const timer = setTimeout(() => { // Not smooth: sections that finish rendering meanwhile cancel a smooth scroll. - dangerZoneRef.current?.scrollIntoView({ block: 'start' }); + const sectionRef = location.hash === '#backends' ? backendsRef : dangerZoneRef; + sectionRef.current?.scrollIntoView({ block: 'start' }); }, 0); + return () => clearTimeout(timer); } }, [location.hash, isLoadingPage]); @@ -465,17 +468,19 @@ export const ProjectSettings: React.FC = () => { )} - +
+ +
diff --git a/frontend/src/pages/Runs/Launch/constants.tsx b/frontend/src/pages/Runs/Launch/constants.tsx index 84988d8f2a..5e978dff0a 100644 --- a/frontend/src/pages/Runs/Launch/constants.tsx +++ b/frontend/src/pages/Runs/Launch/constants.tsx @@ -48,7 +48,6 @@ export const PASSWORD_INFO = { export const FORM_FIELD_NAMES = { project: 'project', template: 'template', - gpu_enabled: 'gpu_enabled', offer: 'offer', name: 'name', ide: 'ide', diff --git a/frontend/src/pages/Runs/Launch/helpers/getRunSpecConfigurationResources.ts b/frontend/src/pages/Runs/Launch/helpers/getRunSpecConfigurationResources.ts index bcf66cdb5c..1c7639b07b 100644 --- a/frontend/src/pages/Runs/Launch/helpers/getRunSpecConfigurationResources.ts +++ b/frontend/src/pages/Runs/Launch/helpers/getRunSpecConfigurationResources.ts @@ -1,6 +1,6 @@ const isVendor = (value: string) => ['amd', 'nvidia', 'google', 'tpu', 'intel'].includes(value); -const isMemory = (value: string) => /^\d+GB/.test(value); -const isCount = (value: string) => /^\d+(?:\.\.)*(?:\d+)*$/.test(value); +const isMemory = (value: string) => /^(?:\d+(?:\.\d+)?GB|\.\.\d+(?:\.\d+)?GB)/i.test(value); +const isCount = (value: string) => /^(?:\d+(?:\.\.\d*)?|\.\.\d+)$/.test(value); const parseRange = (rangeString: string) => { const [min, max] = rangeString.split('..'); @@ -9,8 +9,8 @@ const parseRange = (rangeString: string) => { return rangeString; } - const numberMin = parseInt(min, 10); - const numberMax = parseInt(max, 10); + const numberMin = parseFloat(min); + const numberMax = rangeString.includes('..') ? parseFloat(max) : numberMin; return { ...(!isNaN(numberMin) ? { min: numberMin } : {}), @@ -75,15 +75,8 @@ export const getRunSpecConfigurationResources = (json: unknown): TDevEnvironment } } - if (cpu) { - const cpuStr = String(cpu); - const cpuNum = parseInt(cpuStr, 10); - - if (!isNaN(cpuNum)) { - result['cpu'] = cpuNum; - } else { - result['cpu'] = cpuStr; - } + if (cpu !== undefined) { + result['cpu'] = cpu as TResourceRequest['cpu']; } if (typeof disk === 'string' && isMemory(disk)) { diff --git a/frontend/src/pages/Runs/Launch/helpers/templateResources.test.ts b/frontend/src/pages/Runs/Launch/helpers/templateResources.test.ts index 7feb69b18c..08ef7d748c 100644 --- a/frontend/src/pages/Runs/Launch/helpers/templateResources.test.ts +++ b/frontend/src/pages/Runs/Launch/helpers/templateResources.test.ts @@ -10,6 +10,13 @@ const makeTemplate = (configuration: Record): ITemplate => }) as ITemplate; describe('templateResources', () => { + test('includes CPU, RAM and vendor constraints in the resource filters', () => { + expect( + getTemplateOfferDefaultFilters( + makeTemplate({ resources: { gpu: { vendor: 'amd' }, cpu: 'arm:2..8', memory: '1.5GB..4GB' } }), + ), + ).toMatchObject({ gpu_vendor: 'amd', cpu_arch: 'arm', cpu_count: '2..8', memory: '1.5GB..4GB' }); + }); test('returns full gpu name list from object gpu spec', () => { const template = makeTemplate({ type: 'task', diff --git a/frontend/src/pages/Runs/Launch/helpers/templateResources.ts b/frontend/src/pages/Runs/Launch/helpers/templateResources.ts index cc20d6d729..ec4cbcdf99 100644 --- a/frontend/src/pages/Runs/Launch/helpers/templateResources.ts +++ b/frontend/src/pages/Runs/Launch/helpers/templateResources.ts @@ -7,6 +7,10 @@ type TGpuFilterDefaults = { }; type TOfferFilterDefaults = TGpuFilterDefaults & { + gpu_vendor?: string; + cpu_count?: string; + cpu_arch?: string; + memory?: string; backend?: string | string[]; fleet?: string | string[]; spot_policy?: string; @@ -53,6 +57,9 @@ const formatRangeForFilter = ( const maxValue = normalizeToken(max); const hasMin = !!minValue; const hasMax = !!maxValue; + if (hasMin && minValue === maxValue) { + return minValue; + } if (hasMin && hasMax) { return `${minValue}..${maxValue}`; } @@ -76,25 +83,17 @@ const getTemplateGpuSpec = (template?: ITemplate): TResourceRequest['gpu'] | und return getRunSpecConfigurationResources(resources)?.gpu; }; -export const hasConfiguredGpu = (template?: ITemplate): boolean => { - const gpu = getTemplateGpuSpec(template); - if (typeof gpu === 'number') { - return gpu > 0; - } - if (typeof gpu === 'string') { - const normalizedGpu = gpu.trim(); - return normalizedGpu !== '' && normalizedGpu !== '0'; - } - if (gpu && typeof gpu === 'object') { - return Object.keys(gpu as Record).length > 0; - } - - return false; -}; - export const getTemplateOfferDefaultFilters = (template?: ITemplate): TOfferFilterDefaults => { const gpu = getTemplateGpuSpec(template); const configuration = template?.configuration; + const resources = configuration?.resources as TResourceRequest | undefined; + const cpu = resources?.cpu; + const cpuTokens = typeof cpu === 'string' ? cpu.split(':') : []; + const cpuArch = typeof cpu === 'object' ? cpu?.arch : cpuTokens.find((token) => ['arm', 'x86'].includes(token)); + const cpuCount = formatRangeForFilter( + typeof cpu === 'object' ? cpu?.count : typeof cpu === 'number' ? cpu : cpuTokens.find((token) => token !== cpuArch), + ); + const memory = formatRangeForFilter(resources?.memory, { appendGBToNumber: true }); const spotPolicy = configuration && typeof configuration === 'object' && typeof configuration.spot_policy === 'string' @@ -114,13 +113,19 @@ export const getTemplateOfferDefaultFilters = (template?: ITemplate): TOfferFilt configuration.fleets.every((fleet) => typeof fleet === 'string') ? (configuration.fleets as string[]) : undefined; + const commonFilters = { + ...(cpuArch ? { cpu_arch: cpuArch } : {}), + ...(cpuCount ? { cpu_count: cpuCount } : {}), + ...(memory ? { memory } : {}), + ...(spotPolicy ? { spot_policy: spotPolicy } : {}), + ...(backends?.length ? { backend: backends } : {}), + ...(fleets?.length ? { fleet: fleets } : {}), + }; if (typeof gpu === 'number') { return { - ...(gpu > 0 ? { gpu_count: String(gpu) } : {}), - ...(spotPolicy ? { spot_policy: spotPolicy } : {}), - ...(backends?.length ? { backend: backends } : {}), - ...(fleets?.length ? { fleet: fleets } : {}), + gpu_count: String(gpu), + ...commonFilters, }; } @@ -138,9 +143,7 @@ export const getTemplateOfferDefaultFilters = (template?: ITemplate): TOfferFilt return { ...(gpuNames?.length === 1 ? { gpu_name: gpuNames[0] } : {}), ...(gpuNames && gpuNames.length > 1 ? { gpu_name: gpuNames } : {}), - ...(spotPolicy ? { spot_policy: spotPolicy } : {}), - ...(backends?.length ? { backend: backends } : {}), - ...(fleets?.length ? { fleet: fleets } : {}), + ...commonFilters, }; } @@ -162,12 +165,13 @@ export const getTemplateOfferDefaultFilters = (template?: ITemplate): TOfferFilt : undefined; return { + ...commonFilters, + ...(gpu && typeof gpu === 'object' && gpu.vendor ? { gpu_vendor: gpu.vendor } : {}), ...(typeof gpuName === 'string' ? { gpu_name: gpuName } : {}), - ...(Array.isArray(gpuName) && gpuName.every((name) => typeof name === 'string') ? { gpu_name: gpuName } : {}), + ...(Array.isArray(gpuName) && gpuName.every((name) => typeof name === 'string') + ? { gpu_name: gpuName.length === 1 ? gpuName[0] : gpuName } + : {}), ...(gpuCount ? { gpu_count: gpuCount } : {}), ...(gpuMemory ? { gpu_memory: gpuMemory } : {}), - ...(spotPolicy ? { spot_policy: spotPolicy } : {}), - ...(backends?.length ? { backend: backends } : {}), - ...(fleets?.length ? { fleet: fleets } : {}), }; }; diff --git a/frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.test.tsx b/frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.test.tsx new file mode 100644 index 0000000000..b4a8cb1625 --- /dev/null +++ b/frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.test.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; +import jsYaml from 'js-yaml'; + +import { tokensToSearchParams } from 'libs/filters'; + +import { getOfferResources, OfferCard, toCpuCard } from 'pages/Offers/List/helpers'; +import { toRangeTokens } from 'pages/Offers/List/hooks/useFilters'; + +import { getRunSpecConfigurationResources } from '../helpers/getRunSpecConfigurationResources'; +import { useGenerateYaml, UseGenerateYamlArgs } from './useGenerateYaml'; + +const generate = (args: Partial) => { + let yaml = ''; + const Harness = () => { + yaml = useGenerateYaml({ formValues: {} as UseGenerateYamlArgs['formValues'], ...args }); + return null; + }; + let renderer: ReactTestRenderer; + act(() => { + renderer = create(); + }); + act(() => renderer.unmount()); + return jsYaml.load(yaml); +}; + +describe('useGenerateYaml', () => { + const configuration = { type: 'task', resources: { gpu: 'H100:1', disk: '100GB' } } as ITemplate['configuration']; + const offer: OfferCard = { + name: 'H100', + vendor: 'nvidia', + memory_mib: 275032, + count: { min: 1, max: 2 }, + price: { min: 1, max: 2 }, + availability: ['available'], + spot: ['on-demand'], + backend: 'aws', + region: 'eu-west-1', + }; + + test('CPU selection overrides template GPU and preserves unrelated resources', () => { + const cpu: OfferCard = { + ...offer, + count: { min: 2, max: 2 }, + memory_mib: 1901, + cpu: { cpus: 2, cpu_arch: 'arm', memory_mib: 1901, gpus: [], spot: false }, + }; + expect( + generate({ configuration, formValues: { offer: cpu } as UseGenerateYamlArgs['formValues'], fleets: ['chosen'] }), + ).toEqual({ + type: 'task', + resources: { gpu: 0, cpu: 'arm:2', memory: '1.8564453125GB', disk: '100GB' }, + backends: ['aws'], + regions: ['eu-west-1'], + fleets: ['chosen'], + spot_policy: 'on-demand', + }); + }); + + test('GPU selection keeps exact VRAM and count range without inferring a fleet', () => { + expect(generate({ configuration, formValues: { offer } as UseGenerateYamlArgs['formValues'] })).toEqual({ + type: 'task', + resources: { gpu: 'nvidia:H100:1..2:268.5859375GB', disk: '100GB' }, + backends: ['aws'], + regions: ['eu-west-1'], + spot_policy: 'on-demand', + }); + }); + + test('no selection keeps explicit template resources, otherwise defaults to GPU', () => { + expect(generate({ configuration, hasResourcesParam: true })).toEqual(configuration); + const cpuConfiguration = { type: 'task', resources: { cpu: 2, memory: '4GB' } } as ITemplate['configuration']; + expect(generate({ configuration: cpuConfiguration, hasResourcesParam: true })).toEqual(cpuConfiguration); + expect(generate({ configuration: { type: 'task' } as ITemplate['configuration'], hasResourcesParam: true })).toEqual({ + type: 'task', + resources: { gpu: '1..' }, + }); + }); + + test('CPU filters apply without a card selection and keep unrelated template resources', () => { + expect( + generate({ + configuration, + hasResourcesParam: true, + filters: { gpu_count: '0', cpu_count: '2..32', cpu_arch: 'arm', memory: '1.5GB..', fleet: ['chosen'] }, + }), + ).toEqual({ + type: 'task', + resources: { gpu: 0, cpu: 'arm:2..32', memory: '1.5GB..', disk: '100GB' }, + fleets: ['chosen'], + }); + }); + + test('cleared filters remove template constraints and re-enable GPUs', () => { + expect( + generate({ + configuration: { ...configuration, backends: ['aws'], resources: { gpu: 0, cpu: 8, memory: '16GB' } }, + hasResourcesParam: true, + filters: {}, + }), + ).toEqual({ type: 'task', resources: { gpu: '1..' } }); + }); + + test('CPU ranges and fractional memory survive RunSpec conversion', () => { + const cpu = toCpuCard({ + cpu_arch: ['arm', 'x86'], + count: { min: 2, max: 32 }, + memory_mib: { min: 1901, max: 65536 }, + price: { min: 0, max: 1 }, + availability: ['idle'], + spot: ['on-demand'], + }); + expect(getRunSpecConfigurationResources(getOfferResources(cpu))).toEqual({ + gpu: 0, + cpu: '2..32', + memory: { min: 1.8564453125, max: 64 }, + }); + expect(getRunSpecConfigurationResources({ memory: '..4.5GB' })).toEqual({ memory: { max: 4.5 } }); + }); + + test('GPU count and fractional VRAM survive RunSpec conversion', () => { + expect(getRunSpecConfigurationResources(getOfferResources(offer))).toEqual({ + gpu: { + vendor: 'nvidia', + name: ['H100'], + count: { min: 1, max: 2 }, + memory: { min: 268.5859375, max: 268.5859375 }, + }, + }); + expect(getRunSpecConfigurationResources({ gpu: 'H100:1' })).toMatchObject({ + gpu: { count: { min: 1, max: 1 } }, + }); + }); + + test('Launch URL serialization preserves comparison operators', () => { + const params = tokensToSearchParams( + toRangeTokens([ + { propertyKey: 'memory', operator: '>=', value: '4GB' }, + { propertyKey: 'gpu_count', operator: '<=', value: '8' }, + ]), + ); + expect(params.get('memory')).toBe('4GB..'); + expect(params.get('gpu_count')).toBe('..8'); + }); +}); diff --git a/frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.ts b/frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.ts index bff0dfc7e7..d881170180 100644 --- a/frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.ts +++ b/frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.ts @@ -1,7 +1,8 @@ import { useMemo } from 'react'; import jsYaml from 'js-yaml'; -import { convertMiBToGB, renderRange, round } from 'pages/Offers/List/helpers'; +import { getOfferResources } from 'pages/Offers/List/helpers'; +import type { UseFiltersArgs } from 'pages/Offers/List/hooks/useFilters'; import { IRunEnvironmentFormValues } from '../types'; @@ -12,6 +13,7 @@ export type UseGenerateYamlArgs = { hasResourcesParam?: boolean; backends?: string[]; fleets?: string[]; + filters?: UseFiltersArgs['defaultFilters']; }; export const useGenerateYaml = ({ @@ -21,10 +23,10 @@ export const useGenerateYaml = ({ hasResourcesParam, backends, fleets, + filters, }: UseGenerateYamlArgs) => { return useMemo(() => { - const { name, ide, image, python, offer, repo_url, repo_path, working_dir, password, gpu_enabled } = formValues; - const gpuEnabled = gpu_enabled === true; + const { name, ide, image, python, offer, repo_url, repo_path, working_dir, password } = formValues; const hasTemplateResources = configuration && 'resources' in configuration && @@ -34,7 +36,8 @@ export const useGenerateYaml = ({ : false; const baseResources = hasTemplateResources && configuration ? (configuration.resources as Record) : undefined; - const hasTemplateGpu = !!baseResources && 'gpu' in baseResources; + const filterConfiguration = + hasResourcesParam && filters ? getFilterConfiguration(filters, baseResources) : { resources: baseResources }; const envEntries: string[] = []; if (envParam?.name && password) { @@ -47,6 +50,7 @@ export const useGenerateYaml = ({ return jsYaml.dump( { ...configuration, + ...filterConfiguration, ...(name ? { name } : {}), ...(ide ? { ide } : {}), @@ -54,35 +58,28 @@ export const useGenerateYaml = ({ ...(python ? { python } : {}), ...(envEntries.length > 0 ? { env: envEntries } : {}), - ...(gpuEnabled && offer + ...(offer ? { resources: { ...baseResources, - gpu: `${offer.name}:${round(convertMiBToGB(offer.memory_mib))}GB:${renderRange(offer.count)}`, + ...filterConfiguration.resources, + ...getOfferResources(offer), }, - ...(backends && backends.length > 0 ? { backends } : {}), + ...(offer.backend ? { backends: [offer.backend] } : backends?.length ? { backends } : {}), + ...(offer.region ? { regions: [offer.region] } : {}), ...(fleets && fleets.length > 0 ? { fleets } : {}), ...(offer.spot.length === 1 ? { spot_policy: offer.spot[0] } : {}), ...(offer.spot.length > 1 ? { spot_policy: 'auto' } : {}), } : {}), - ...(gpuEnabled && !offer && hasResourcesParam && !hasTemplateGpu + ...(!offer && hasResourcesParam && !filters && !baseResources ? { resources: { - ...baseResources, gpu: '1..', }, } : {}), - ...(hasResourcesParam && !gpuEnabled - ? { - resources: { - ...baseResources, - gpu: 0, - }, - } - : {}), ...(repo_url || repo_path ? { @@ -94,5 +91,26 @@ export const useGenerateYaml = ({ }, { lineWidth: -1 }, ); - }, [formValues, configuration, envParam, hasResourcesParam, backends, fleets]); + }, [formValues, configuration, envParam, hasResourcesParam, backends, fleets, filters]); }; + +const getFilterConfiguration = ( + filters: NonNullable, + baseResources?: Record, +) => ({ + resources: { + ...baseResources, + gpu: + filters.gpu_count === '0' + ? 0 + : [filters.gpu_vendor, filters.gpu_name, filters.gpu_count ?? '1..', filters.gpu_memory] + .filter(Boolean) + .join(':'), + cpu: [filters.cpu_arch, filters.cpu_count].filter(Boolean).join(':') || undefined, + memory: filters.memory, + }, + backends: filters.backend ? [filters.backend].flat() : undefined, + fleets: filters.fleet ? [filters.fleet].flat() : undefined, + regions: filters.region ? [filters.region] : undefined, + spot_policy: filters.spot_policy, +}); diff --git a/frontend/src/pages/Runs/Launch/hooks/useValidationResolver.ts b/frontend/src/pages/Runs/Launch/hooks/useValidationResolver.ts index 45420773ec..c95b6a1eaf 100644 --- a/frontend/src/pages/Runs/Launch/hooks/useValidationResolver.ts +++ b/frontend/src/pages/Runs/Launch/hooks/useValidationResolver.ts @@ -29,8 +29,7 @@ export const useYupValidationResolver = (template?: ITemplate) => { break; case 'resources': - // Offer selection is optional when GPU is enabled. - // If no offer is selected, YAML generation applies template GPU or a default fallback. + // Without a selection, keep template resources or the default GPU request. schema['offer'] = yup.object().nullable(); break; diff --git a/frontend/src/pages/Runs/Launch/index.tsx b/frontend/src/pages/Runs/Launch/index.tsx index 663507df17..4f24aef4a8 100644 --- a/frontend/src/pages/Runs/Launch/index.tsx +++ b/frontend/src/pages/Runs/Launch/index.tsx @@ -14,7 +14,6 @@ import { FormField, FormSelect, FormSelectProps, - FormToggle, InfoLink, SpaceBetween, Wizard, @@ -30,11 +29,12 @@ import { useApplyRunMutation } from 'services/run'; import { useGetAllTemplatesQuery } from 'services/templates'; import { OfferList } from 'pages/Offers/List'; -import { getOfferFilters } from 'pages/Offers/List/hooks/useFilters'; +import { convertMiBToGB, getCpuMemory, OfferCard, renderRange } from 'pages/Offers/List/helpers'; +import { getOfferFilters, UseFiltersArgs } from 'pages/Offers/List/hooks/useFilters'; import { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAlert'; import { ParamsWizardStep } from './components/ParamsWizardStep'; -import { getTemplateOfferDefaultFilters, hasConfiguredGpu } from './helpers/templateResources'; +import { getTemplateOfferDefaultFilters } from './helpers/templateResources'; import { useGenerateYaml } from './hooks/useGenerateYaml'; import { useGetRunSpecFromYaml } from './hooks/useGetRunSpecFromYaml'; import { useYupValidationResolver } from './hooks/useValidationResolver'; @@ -66,7 +66,8 @@ export const Launch: React.FC = () => { undefined, ); const [activeStepIndex, setActiveStepIndex] = useState(0); - const [selectedOffers, setSelectedOffers] = useState([]); + const [selectedOffers, setSelectedOffers] = useState([]); + const [resourceFilters, setResourceFilters] = useState(); const [selectedTemplate, setSelectedTemplate] = useState(); const [selectedBackends, setSelectedBackends] = useState([]); const [selectedFleets, setSelectedFleets] = useState([]); @@ -166,19 +167,35 @@ export const Launch: React.FC = () => { return filters; }, [searchParams]); - // An offer picked on the Offers page: one GPU name with an exact memory. - const preselectedGpu = useMemo(() => { - const { gpu_name: names, gpu_memory: memory } = urlOfferFilters; + // A selected offer is carried in the same filters used by Launch. + const preselectedOffer = useMemo(() => { + const [offer] = selectedOffers; + if (offer) { + return { + name: offer.name, + memoryGb: convertMiBToGB(offer.memory_mib), + cpuCount: offer.cpu ? renderRange(offer.count) : undefined, + cpuMemory: offer.cpu ? getCpuMemory(offer) : undefined, + cpuArch: offer.cpu?.cpu_arch, + backend: offer.backend, + region: offer.region, + }; + } + if (resourceFilters) return; + + const { gpu_name: names, gpu_memory, gpu_count, cpu_count, memory: ram } = urlOfferFilters; + const memory = gpu_count === '0' ? ram : gpu_memory; const memoryGb = typeof memory === 'string' && !memory.includes('..') ? parseFloat(memory) : NaN; + if (gpu_count === '0' && typeof cpu_count === 'string' && typeof ram === 'string') { + return { cpuCount: cpu_count, cpuMemory: ram }; + } return Array.isArray(names) && names.length === 1 && memoryGb ? { name: names[0], memoryGb } : undefined; - }, [urlOfferFilters]); - - const hasGpuFilters = Object.keys(urlOfferFilters).some((key) => key.startsWith('gpu_')); + }, [urlOfferFilters, selectedOffers, resourceFilters]); useEffect(() => { setSelectedOffers([]); + setResourceFilters(undefined); setValue(FORM_FIELD_NAMES.offer, undefined); - setValue(FORM_FIELD_NAMES.gpu_enabled, hasConfiguredGpu(selectedTemplate) || hasGpuFilters); }, [selectedTemplate, setValue]); const validateProjectAndTemplate = async () => await trigger(templateStepFieldNames); @@ -227,12 +244,12 @@ export const Launch: React.FC = () => { unregister(FORM_FIELD_NAMES.ide); }; - const selectOffers = (offers: IGpu[]) => { + const selectOffers = (offers: OfferCard[]) => { setSelectedOffers(offers); setValue(FORM_FIELD_NAMES.offer, offers[0] ?? undefined); }; - const onChangeOffer: CardsProps['onSelectionChange'] = ({ detail }) => selectOffers(detail?.selectedItems ?? []); + const onChangeOffer: CardsProps['onSelectionChange'] = ({ detail }) => selectOffers(detail?.selectedItems ?? []); const onSubmitWizard = async () => { const isValid = await trigger(); @@ -294,18 +311,20 @@ export const Launch: React.FC = () => { const hasResourcesParam = selectedTemplate?.parameters?.some((p) => p.type === 'resources') ?? false; const configuredOfferFilters = getTemplateOfferDefaultFilters(selectedTemplate); const defaultOfferFilters = useMemo( - () => ({ - spot_policy: 'on-demand', - ...configuredOfferFilters, - ...urlOfferFilters, - }), - [configuredOfferFilters, urlOfferFilters], + () => + resourceFilters ?? { + spot_policy: 'on-demand', + ...configuredOfferFilters, + ...urlOfferFilters, + }, + [configuredOfferFilters, urlOfferFilters, resourceFilters], ); const yaml = useGenerateYaml({ formValues, configuration: selectedTemplate?.configuration, envParam, hasResourcesParam, + filters: defaultOfferFilters, backends: selectedBackends, fleets: selectedFleets, }); @@ -413,32 +432,23 @@ export const Launch: React.FC = () => { title: 'Resources', content: ( selectOffers([gpu])} + onChangeFilters={(filters) => { + setResourceFilters(filters); + selectOffers([]); + }} onChangeBackendFilter={setSelectedBackends} onChangeFleetFilter={setSelectedFleets} permanentFilters={{ project_name: formValues.project ?? '' }} defaultFilters={defaultOfferFilters} - header={ - - } /> ), }, diff --git a/frontend/src/pages/Runs/Launch/types.ts b/frontend/src/pages/Runs/Launch/types.ts index e0a1986946..2e370dbdbd 100644 --- a/frontend/src/pages/Runs/Launch/types.ts +++ b/frontend/src/pages/Runs/Launch/types.ts @@ -1,8 +1,9 @@ +import type { OfferCard } from 'pages/Offers/List/helpers'; + export interface IRunEnvironmentFormValues { project: IProject['project_name']; template: string[]; - gpu_enabled?: boolean; - offer?: IGpu; + offer?: OfferCard; name: string; ide?: 'cursor' | 'vscode' | 'windsurf' | 'zed'; config_yaml: string; diff --git a/frontend/src/pages/Runs/LaunchOptions/helpers.test.ts b/frontend/src/pages/Runs/LaunchOptions/helpers.test.ts new file mode 100644 index 0000000000..9333564962 --- /dev/null +++ b/frontend/src/pages/Runs/LaunchOptions/helpers.test.ts @@ -0,0 +1,31 @@ +import { getApplyCommand } from './helpers'; + +describe('getApplyCommand', () => { + test('keeps the basic command without resource filters', () => { + expect(getApplyCommand(new URLSearchParams('project_name=main'))).toBe('dstack apply -f .dstack.yml'); + }); + + test('includes GPU ranges and all explicit backend and fleet filters', () => { + const params = new URLSearchParams( + 'gpu_vendor=nvidia&gpu_name=H100&gpu_count=1..8&gpu_memory=80GB&backend=aws&backend=gcp&fleet=chosen&spot_policy=auto', + ); + expect(getApplyCommand(params)).toBe( + 'dstack apply -f .dstack.yml --gpu nvidia:H100:1..8:80GB --backend aws --backend gcp --fleet chosen --spot-policy auto', + ); + }); + + test('CPU selection explicitly disables GPUs and keeps exact RAM', () => { + const params = new URLSearchParams('gpu_count=0&cpu_count=2&cpu_arch=x86&memory=1.8564453125GB®ion=cluster'); + expect(getApplyCommand(params)).toBe( + 'dstack apply -f .dstack.yml --gpu 0 --cpu x86:2 --memory 1.8564453125GB --region cluster', + ); + }); + + test('quotes shell metacharacters and single quotes from URL input', () => { + const params = new URLSearchParams(); + params.set('fleet', "team's $(touch /tmp/unwanted); `echo bad`"); + expect(getApplyCommand(params)).toBe( + "dstack apply -f .dstack.yml --fleet 'team'\\''s $(touch /tmp/unwanted); `echo bad`'", + ); + }); +}); diff --git a/frontend/src/pages/Runs/LaunchOptions/helpers.ts b/frontend/src/pages/Runs/LaunchOptions/helpers.ts new file mode 100644 index 0000000000..4b0fd9338c --- /dev/null +++ b/frontend/src/pages/Runs/LaunchOptions/helpers.ts @@ -0,0 +1,21 @@ +const shellQuote = (value: string) => (/^[\w.,:/-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`); + +export const getApplyCommand = (params: URLSearchParams) => { + const args = ['dstack apply -f .dstack.yml']; + const add = (flag: string, value?: string | null) => { + if (value) args.push(flag, shellQuote(value)); + }; + const gpu = + params.get('gpu_count') === '0' + ? '0' + : [params.get('gpu_vendor'), params.getAll('gpu_name').join(','), params.get('gpu_count'), params.get('gpu_memory')] + .filter(Boolean) + .join(':'); + + add('--gpu', gpu); + add('--cpu', [params.get('cpu_arch'), params.get('cpu_count')].filter(Boolean).join(':')); + add('--memory', params.get('memory')); + ['backend', 'region', 'fleet'].forEach((key) => params.getAll(key).forEach((value) => add(`--${key}`, value))); + add('--spot-policy', params.get('spot_policy')); + return args.join(' '); +}; diff --git a/frontend/src/pages/Runs/LaunchOptions/index.tsx b/frontend/src/pages/Runs/LaunchOptions/index.tsx index a4687d6909..24af78f1ef 100644 --- a/frontend/src/pages/Runs/LaunchOptions/index.tsx +++ b/frontend/src/pages/Runs/LaunchOptions/index.tsx @@ -30,16 +30,16 @@ import { useGetProjectQuery } from 'services/project'; import { useGetAllTemplatesQuery } from 'services/templates'; import { getRequestParams } from 'pages/Offers/List'; -import { getOfferFilters, useFilters } from 'pages/Offers/List/hooks/useFilters'; +import { getOfferFilters, toRangeTokens, useFilters } from 'pages/Offers/List/hooks/useFilters'; import { useCheckAvailableProjectPermission } from 'pages/Project/hooks/useCheckAvailableProjectPermission'; import { useConfigProjectCliCommand } from 'pages/Project/hooks/useConfigProjectCliComand'; import { AGENTS_INFO, CLI_INFO, getTemplatesInfo } from './constants'; +import { getApplyCommand } from './helpers'; import styles from './styles.module.scss'; const INSTALL_COMMAND = 'uv tool install dstack -U'; -const APPLY_COMMAND = 'dstack apply -f .dstack.yml'; const SKILLS_COMMAND = 'npx skills add dstackai/dstack'; // The page layout's padding below the content. const PAGE_BOTTOM_PADDING = 40; @@ -63,8 +63,11 @@ export const LaunchOptions: FC = () => { }, ]); - // The same filter as on Offers; GPU names and backends load only when their list is opened. - const [initialFilters] = useState(() => getOfferFilters(searchParams)); + // The same filter as on Offers; GPU names, vendors and backends load only when their list is opened. + const [initialFilters] = useState(() => { + const filters = getOfferFilters(searchParams); + return product.hasTemplates ? filters : filters?.project_name ? { project_name: filters.project_name } : {}; + }); const loadGpus = async (projectName: string) => { const data = await getGpus(getRequestParams({ project_name: projectName, group_by: ['gpu'] })).unwrap(); @@ -73,6 +76,7 @@ export const LaunchOptions: FC = () => { const { filteringRequestParams, + clearFilter, propertyFilterRef, propertyFilterQuery, onChangePropertyFilter, @@ -86,7 +90,7 @@ export const LaunchOptions: FC = () => { useEffect(() => { setSearchParams( (params) => { - const next = tokensToSearchParams(propertyFilterQuery.tokens); + const next = tokensToSearchParams(toRangeTokens(propertyFilterQuery.tokens)); const template = params.get('template'); if (template) { @@ -133,6 +137,11 @@ export const LaunchOptions: FC = () => { ref={propertyFilterRef} query={propertyFilterQuery} onChange={onChangePropertyFilter} + customFilterActions={ + + } expandToViewport hideOperations i18nStrings={{ @@ -140,8 +149,16 @@ export const LaunchOptions: FC = () => { filteringAriaLabel: t('offer.filter_property_placeholder'), filteringPlaceholder: t('offer.filter_property_placeholder'), }} - filteringOptions={filteringOptions} - filteringProperties={filteringProperties} + filteringOptions={ + product.hasTemplates + ? filteringOptions + : filteringOptions.filter(({ propertyKey }) => propertyKey === 'project_name') + } + filteringProperties={ + product.hasTemplates + ? filteringProperties + : filteringProperties.filter(({ key }) => key === 'project_name') + } filteringStatusType={filteringStatusType} onLoadItems={handleLoadItems} /> @@ -183,6 +200,7 @@ const CliColumn: FC<{ projectName: string }> = ({ projectName }) => ( const CliPanel: FC<{ projectName: string }> = ({ projectName }) => { const { t } = useTranslation(); + const [searchParams] = useSearchParams(); const [openHelpPanel] = useHelpPanel(); const [configCliCommand] = useConfigProjectCliCommand({ projectName }); @@ -222,7 +240,7 @@ const CliPanel: FC<{ projectName: string }> = ({ projectName }) => { - + diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index fd9ed3bf27..428f80dbd7 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -18,6 +18,7 @@ import { FleetAdd, FleetDetails, FleetList } from 'pages/Fleets'; import { EventsList as FleetEventsList } from 'pages/Fleets/Details/Events'; import { FleetDetails as FleetDetailsGeneral } from 'pages/Fleets/Details/FleetDetails'; import { FleetInspect } from 'pages/Fleets/Details/Inspect'; +import { FleetOffers } from 'pages/Fleets/Details/Offers'; import { InstanceDetailsPage, InstanceList } from 'pages/Instances'; import { EventsList as InstanceEventsList } from 'pages/Instances/Details/Events'; import { InstanceInspect } from 'pages/Instances/Details/Inspect'; @@ -269,6 +270,10 @@ export const router = createBrowserRouter([ index: true, element: , }, + { + path: ROUTES.FLEETS.DETAILS.OFFERS.TEMPLATE, + element: , + }, { path: ROUTES.FLEETS.DETAILS.EVENTS.TEMPLATE, element: , diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 7e97253d1c..b07ef62589 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -161,6 +161,11 @@ export const ROUTES = { FORMAT: (projectName: string, fleetId: string) => buildRoute(ROUTES.FLEETS.DETAILS.EVENTS.TEMPLATE, { projectName, fleetId }), }, + OFFERS: { + TEMPLATE: `/projects/:projectName/fleets/:fleetId/offers`, + FORMAT: (projectName: string, fleetId: string) => + buildRoute(ROUTES.FLEETS.DETAILS.OFFERS.TEMPLATE, { projectName, fleetId }), + }, INSPECT: { TEMPLATE: `/projects/:projectName/fleets/:fleetId/inspect`, FORMAT: (projectName: string, fleetId: string) => diff --git a/frontend/src/services/gpu.ts b/frontend/src/services/gpu.ts index 92599ff95d..9ecc955b95 100644 --- a/frontend/src/services/gpu.ts +++ b/frontend/src/services/gpu.ts @@ -15,19 +15,28 @@ export const gpuApi = createApi({ getGpusList: builder.query({ query: ({ project_name, ...body }) => { if (body?.group_by?.length) { - body.group_by = body.group_by.filter((g) => g !== 'gpu'); + body.group_by = body.group_by.filter((g) => g !== 'gpu' && g !== 'cpu'); } return { url: API.PROJECTS.GPUS_LIST(project_name), method: 'POST', - body, + body: { ...body, reverse: true }, }; }, providesTags: ['Gpus'], }), + getOffers: builder.query({ + query: ({ project_name, run_spec }) => ({ + url: API.PROJECTS.RUN_GET_PLAN(project_name), + method: 'POST', + body: { run_spec, full_offers: true, for_offers_only: true, reverse_offers: true, max_offers: 100 }, + }), + transformResponse: (response: { job_plans: TOffersQueryResponse[] }) => response.job_plans[0], + providesTags: ['Gpus'], + }), }), }); -export const { useGetGpusListQuery, useLazyGetGpusListQuery } = gpuApi; +export const { useGetGpusListQuery, useLazyGetGpusListQuery, useGetOffersQuery } = gpuApi; diff --git a/frontend/src/services/project.ts b/frontend/src/services/project.ts index 96e16e4492..6b20073d25 100644 --- a/frontend/src/services/project.ts +++ b/frontend/src/services/project.ts @@ -230,6 +230,7 @@ export const { useLazyGetProjectsQuery, useGetOnlyNoFleetsProjectsQuery, useGetProjectQuery, + useLazyGetProjectQuery, useCreateProjectMutation, useCreateWizardProjectMutation, useUpdateProjectMembersMutation, diff --git a/frontend/src/types/gpu.d.ts b/frontend/src/types/gpu.d.ts index 3ca543e360..fa554d8dc2 100644 --- a/frontend/src/types/gpu.d.ts +++ b/frontend/src/types/gpu.d.ts @@ -1,7 +1,7 @@ declare type TAvailability = 'unknown' | 'available' | 'not_available' | 'no_quota' | 'no_balance' | 'idle' | 'busy'; declare type TSpot = 'spot' | 'on-demand' | 'auto'; -declare type TGpuGroupBy = 'gpu' | 'backend' | 'region' | 'count'; +declare type TGpuGroupBy = 'gpu' | 'cpu' | 'backend' | 'region' | 'count'; declare type TRange = { min: number; @@ -36,6 +36,7 @@ declare interface IResourcesSpecRequest { } declare interface ITaskConfigurationQueryParams { + image?: string; nodes?: number; ports?: Array; commands?: string[]; @@ -72,6 +73,7 @@ declare interface IGpu { min: number; max: number; }; + offer_price?: { min: number; max: number }; backends?: TBackendType[]; backend?: TBackendType; regions?: string[]; @@ -98,4 +100,23 @@ declare type TGpusListQueryParams = { declare type TGpusListQueryResponse = { gpus: IGpu[]; + cpus?: ICpuGroup[]; +}; + +declare interface ICpuGroup extends Omit { + cpu_arch: NonNullable[]; + memory_mib: { min: number; max: number }; +} + +declare interface IInstanceOffer { + backend: TBackendType; + region: string; + instance: NonNullable; + price: number; + availability: TAvailability; +} + +declare type TOffersQueryResponse = { + offers: IInstanceOffer[]; + total_offers: number; }; diff --git a/src/dstack/_internal/cli/utils/gpu.py b/src/dstack/_internal/cli/utils/gpu.py index 7eb0fc9bb5..bf85b46776 100644 --- a/src/dstack/_internal/cli/utils/gpu.py +++ b/src/dstack/_internal/cli/utils/gpu.py @@ -6,6 +6,7 @@ from dstack._internal.cli.models.offers import OfferCommandGroupByGpuOutput, OfferRequirements from dstack._internal.cli.utils.common import console, format_instance_availability from dstack._internal.core.models.gpus import GpuGroup +from dstack._internal.core.models.instances import InstanceAvailability from dstack._internal.core.models.profiles import SpotPolicy from dstack._internal.core.models.runs import Requirements, RunSpec, get_policy_map @@ -116,7 +117,10 @@ def print_gpu_table(gpus: List[GpuGroup], run_spec: RunSpec, group_by: List[str] price_display = f"{min_formatted}..{max_formatted}" availability = "" - has_available = any(av.is_available() for av in gpu_group.availability) + has_available = any( + av in (InstanceAvailability.UNKNOWN, InstanceAvailability.AVAILABLE) + for av in gpu_group.availability + ) if not has_available: availability = ", ".join( map(format_instance_availability, set(gpu_group.availability)) diff --git a/src/dstack/_internal/core/compatibility/gpus.py b/src/dstack/_internal/core/compatibility/gpus.py index 3885001273..998259e62a 100644 --- a/src/dstack/_internal/core/compatibility/gpus.py +++ b/src/dstack/_internal/core/compatibility/gpus.py @@ -6,7 +6,7 @@ def get_list_gpus_excludes(request: ListGpusRequest) -> Optional[IncludeExcludeDictType]: - list_gpus_excludes: IncludeExcludeDictType = {} + list_gpus_excludes: IncludeExcludeDictType = {} if request.reverse else {"reverse": True} run_spec_excludes = get_run_spec_excludes(request.run_spec) if run_spec_excludes is not None: list_gpus_excludes["run_spec"] = run_spec_excludes diff --git a/src/dstack/_internal/core/compatibility/runs.py b/src/dstack/_internal/core/compatibility/runs.py index 545c729cb5..66cca5c79c 100644 --- a/src/dstack/_internal/core/compatibility/runs.py +++ b/src/dstack/_internal/core/compatibility/runs.py @@ -67,7 +67,9 @@ def get_get_plan_excludes(request: GetRunPlanRequest) -> Optional[IncludeExclude Excludes new fields when they are not set to keep clients backward-compatibility with older servers. """ - get_plan_excludes: IncludeExcludeDictType = {} + get_plan_excludes: IncludeExcludeDictType = ( + {} if request.reverse_offers else {"reverse_offers": True} + ) run_spec_excludes = get_run_spec_excludes(request.run_spec) if run_spec_excludes is not None: get_plan_excludes["run_spec"] = run_spec_excludes diff --git a/src/dstack/_internal/core/models/gpus.py b/src/dstack/_internal/core/models/gpus.py index 7f5b56b813..bb55b62a79 100644 --- a/src/dstack/_internal/core/models/gpus.py +++ b/src/dstack/_internal/core/models/gpus.py @@ -39,6 +39,24 @@ class GpuGroup(CoreModel): spot: List[Literal["spot", "on-demand"]] count: Range[int] price: Range[float] + offer_price: Optional[Range[float]] = None + """Hourly price range for whole offers; `price` remains per GPU for compatibility.""" + backends: Optional[List[BackendType]] = None + backend: Optional[BackendType] = None + regions: Optional[List[str]] = None + region: Optional[str] = None + + +class CpuGroup(CoreModel): + """CPU-only offers grouped by resources and optional backend, region, or count.""" + + cpu_arch: List[gpuhunt.CPUArchitecture] + count: Range[int] + memory_mib: Range[int] + price: Range[float] + """Hourly price range for whole offers.""" + availability: List[InstanceAvailability] + spot: List[Literal["spot", "on-demand"]] backends: Optional[List[BackendType]] = None backend: Optional[BackendType] = None regions: Optional[List[str]] = None diff --git a/src/dstack/_internal/server/routers/gpus.py b/src/dstack/_internal/server/routers/gpus.py index 37853e3bb5..ce911480df 100644 --- a/src/dstack/_internal/server/routers/gpus.py +++ b/src/dstack/_internal/server/routers/gpus.py @@ -35,5 +35,6 @@ async def list_gpus( group_by=body.group_by, full_offers=body.full_offers, unallocated_resources=body.unallocated_resources, + reverse=body.reverse, ) return resp diff --git a/src/dstack/_internal/server/routers/runs.py b/src/dstack/_internal/server/routers/runs.py index 91d4a47943..7b9e2a7421 100644 --- a/src/dstack/_internal/server/routers/runs.py +++ b/src/dstack/_internal/server/routers/runs.py @@ -150,6 +150,7 @@ async def get_plan( unallocated_resources=body.unallocated_resources, legacy_repo_dir=legacy_repo_dir, for_offers_only=for_offers_only, + reverse_offers=body.reverse_offers, ) patch_run_plan(run_plan, client_version) return CustomJSONResponse(run_plan) diff --git a/src/dstack/_internal/server/schemas/gpus.py b/src/dstack/_internal/server/schemas/gpus.py index 28c0cf18a8..99e1fcfe36 100644 --- a/src/dstack/_internal/server/schemas/gpus.py +++ b/src/dstack/_internal/server/schemas/gpus.py @@ -3,7 +3,7 @@ from pydantic import Field from dstack._internal.core.models.common import CoreModel -from dstack._internal.core.models.gpus import GpuGroup +from dstack._internal.core.models.gpus import CpuGroup, GpuGroup from dstack._internal.core.models.runs import RunSpec @@ -16,6 +16,13 @@ class ListGpusRequest(CoreModel): description="List of fields to group by. Valid values: 'backend', 'region', 'count'. " "Note: 'region' can only be used together with 'backend'.", ) + reverse: Annotated[ + bool, + Field( + description="Sort groups by descending whole-offer price range, " + "keeping available groups first." + ), + ] = False full_offers: Annotated[ bool, Field(description="Don't adjust backend offers by requirements") ] = False @@ -31,3 +38,7 @@ class ListGpusResponse(CoreModel): gpus: List[GpuGroup] = Field( description="List of GPU specifications, grouped according to the group_by parameter" ) + cpus: Optional[List[CpuGroup]] = Field( + default=None, + description="CPU groups, returned only when the GPU count is constrained to zero", + ) diff --git a/src/dstack/_internal/server/schemas/runs.py b/src/dstack/_internal/server/schemas/runs.py index e21962b14e..ef17ce9a74 100644 --- a/src/dstack/_internal/server/schemas/runs.py +++ b/src/dstack/_internal/server/schemas/runs.py @@ -45,6 +45,13 @@ class GetRunPlanRequest(CoreModel): max_offers: Optional[int] = Field( default=None, description="The maximum number of offers to return", ge=1, le=10000 ) + reverse_offers: Annotated[ + bool, + Field( + description="Sort returned offers by descending price, keeping available offers first. " + "Applied before max_offers. Does not affect fleet selection or run scheduling." + ), + ] = False full_offers: Annotated[ bool, Field(description="Return full offers not adjusted by requirements") ] = False diff --git a/src/dstack/_internal/server/services/gpus.py b/src/dstack/_internal/server/services/gpus.py index f139423f69..a9bf530dfc 100644 --- a/src/dstack/_internal/server/services/gpus.py +++ b/src/dstack/_internal/server/services/gpus.py @@ -5,7 +5,7 @@ from dstack._internal.core.backends.base.backend import Backend from dstack._internal.core.errors import ServerClientError from dstack._internal.core.models.backends.base import BackendType -from dstack._internal.core.models.gpus import BackendGpu, BackendGpus, GpuGroup +from dstack._internal.core.models.gpus import BackendGpu, BackendGpus, CpuGroup, GpuGroup from dstack._internal.core.models.instances import InstanceOfferWithAvailability from dstack._internal.core.models.profiles import CreationPolicy from dstack._internal.core.models.resources import Range @@ -18,7 +18,7 @@ get_offers_in_run_candidate_fleets, get_targeted_instance_offers, ) -from dstack._internal.utils.common import get_or_error +from dstack._internal.utils.common import get_or_error, run_async async def list_gpus_grouped( @@ -28,6 +28,7 @@ async def list_gpus_grouped( group_by: Optional[List[Literal["backend", "region", "count"]]] = None, full_offers: bool = False, unallocated_resources: bool = False, + reverse: bool = False, ) -> ListGpusResponse: """Retrieves available GPU specifications based on a run spec, with optional grouping.""" offers = await _get_gpu_offers( @@ -37,11 +38,23 @@ async def list_gpus_grouped( full_offers=full_offers, unallocated_resources=unallocated_resources, ) - backend_gpus = _process_offers_into_backend_gpus(offers) - group_by_set = set(group_by) if group_by else set() + group_by_set: set[str] = set(group_by or []) if "region" in group_by_set and "backend" not in group_by_set: raise ServerClientError("Cannot group by 'region' without also grouping by 'backend'") + gpu = run_spec.configuration.resources.gpu + if gpu is not None and gpu.count.max == 0: + cpus = await run_async(_get_cpus_grouped, offers, group_by_set, reverse) + return ListGpusResponse(gpus=[], cpus=cpus) + + gpus = await run_async(_get_gpus_grouped, offers, group_by_set, reverse) + return ListGpusResponse(gpus=gpus) + + +def _get_gpus_grouped( + offers: list[InstanceOfferWithAvailability], group_by_set: set[str], reverse: bool +) -> list[GpuGroup]: + backend_gpus = _process_offers_into_backend_gpus(offers) # Determine grouping strategy based on combination has_backend = "backend" in group_by_set has_region = "region" in group_by_set @@ -59,7 +72,69 @@ async def list_gpus_grouped( else: gpus = _get_gpus_with_no_grouping(backend_gpus) - return ListGpusResponse(gpus=gpus) + if reverse: + gpus.sort( + key=lambda g: ( + not any(av.is_available() for av in g.availability), + -get_or_error((g.offer_price or g.price).min), + -get_or_error((g.offer_price or g.price).max), + ), + ) + return gpus + + +def _get_cpus_grouped( + offers: list[InstanceOfferWithAvailability], group_by: set[str], reverse: bool +) -> list[CpuGroup]: + groups: dict[tuple, list[InstanceOfferWithAvailability]] = {} + for offer in offers: + if offer.instance.resources.gpus: + continue + key = ( + offer.backend if "backend" in group_by else None, + offer.region if "region" in group_by else None, + offer.instance.resources.cpus if "count" in group_by else None, + ) + groups.setdefault(key, []).append(offer) + + rows = [] + for (backend, region, _), group in groups.items(): + resources = [offer.instance.resources for offer in group] + rows.append( + CpuGroup( + cpu_arch=sorted({r.cpu_arch for r in resources if r.cpu_arch is not None}), + count=Range[int]( + min=min(r.cpus for r in resources), max=max(r.cpus for r in resources) + ), + memory_mib=Range[int]( + min=min(r.memory_mib for r in resources), + max=max(r.memory_mib for r in resources), + ), + price=Range[float]( + min=min(offer.price for offer in group), + max=max(offer.price for offer in group), + ), + availability=list(dict.fromkeys(offer.availability for offer in group)), + spot=sorted({"spot" if r.spot else "on-demand" for r in resources}), + backend=backend, + backends=sorted({offer.backend for offer in group}) if backend is None else None, + region=region, + regions=sorted({offer.region for offer in group}) + if backend is not None and region is None + else None, + ) + ) + return sorted( + rows, + key=lambda row: ( + not any(av.is_available() for av in row.availability), + (-1 if reverse else 1) * get_or_error(row.price.min), + (-1 if reverse else 1) * get_or_error(row.price.max), + row.backend or "", + row.region or "", + get_or_error(row.count.min), + ), + ) async def _get_gpu_offers( @@ -203,6 +278,9 @@ def _update_gpu_group(row: GpuGroup, gpu: BackendGpu, backend_type: BackendType) per_gpu_price = gpu.price / gpu.count row.price.min = min(row.price.min, per_gpu_price) row.price.max = max(row.price.max, per_gpu_price) + if row.offer_price is not None: + row.offer_price.min = min(get_or_error(row.offer_price.min), gpu.price) + row.offer_price.max = max(get_or_error(row.offer_price.max), gpu.price) def _get_gpus_with_no_grouping(backend_gpus: List[BackendGpus]) -> List[GpuGroup]: @@ -223,6 +301,7 @@ def _get_gpus_with_no_grouping(backend_gpus: List[BackendGpus]) -> List[GpuGroup spot=["spot" if gpu.spot else "on-demand"], count=Range[int](min=gpu.count, max=gpu.count), price=price_range, + offer_price=Range[float](min=gpu.price, max=gpu.price), backends=[backend.backend_type], ) else: @@ -258,6 +337,7 @@ def _get_gpus_grouped_by_backend(backend_gpus: List[BackendGpus]) -> List[GpuGro spot=["spot" if gpu.spot else "on-demand"], count=Range[int](min=gpu.count, max=gpu.count), price=Range[float](min=per_gpu_price, max=per_gpu_price), + offer_price=Range[float](min=gpu.price, max=gpu.price), backend=backend.backend_type, regions=backend.regions.copy(), ) @@ -293,6 +373,7 @@ def _get_gpus_grouped_by_backend_and_region(backend_gpus: List[BackendGpus]) -> spot=["spot" if gpu.spot else "on-demand"], count=Range[int](min=gpu.count, max=gpu.count), price=Range[float](min=per_gpu_price, max=per_gpu_price), + offer_price=Range[float](min=gpu.price, max=gpu.price), backend=backend.backend_type, region=gpu.region, ) @@ -329,6 +410,7 @@ def _get_gpus_grouped_by_count(backend_gpus: List[BackendGpus]) -> List[GpuGroup spot=["spot" if gpu.spot else "on-demand"], count=Range[int](min=gpu.count, max=gpu.count), price=Range[float](min=per_gpu_price, max=per_gpu_price), + offer_price=Range[float](min=gpu.price, max=gpu.price), backends=[backend.backend_type], ) else: @@ -363,6 +445,7 @@ def _get_gpus_grouped_by_backend_and_count(backend_gpus: List[BackendGpus]) -> L spot=["spot" if gpu.spot else "on-demand"], count=Range[int](min=gpu.count, max=gpu.count), price=Range[float](min=per_gpu_price, max=per_gpu_price), + offer_price=Range[float](min=gpu.price, max=gpu.price), backend=backend.backend_type, regions=backend.regions.copy(), ) @@ -408,6 +491,7 @@ def _get_gpus_grouped_by_backend_region_and_count( spot=["spot" if gpu.spot else "on-demand"], count=Range[int](min=gpu.count, max=gpu.count), price=Range[float](min=per_gpu_price, max=per_gpu_price), + offer_price=Range[float](min=gpu.price, max=gpu.price), backend=backend.backend_type, region=gpu.region, ) diff --git a/src/dstack/_internal/server/services/runs/__init__.py b/src/dstack/_internal/server/services/runs/__init__.py index b6ff199641..6a6eac719c 100644 --- a/src/dstack/_internal/server/services/runs/__init__.py +++ b/src/dstack/_internal/server/services/runs/__init__.py @@ -558,6 +558,7 @@ async def get_plan( unallocated_resources: bool, for_offers_only: bool, legacy_repo_dir: bool = False, + reverse_offers: bool = False, ) -> RunPlan: effective_run_spec = RunSpec.model_validate(run_spec.model_dump()) effective_run_spec = await apply_plugin_policies( @@ -598,6 +599,7 @@ async def get_plan( full_offers=full_offers, unallocated_resources=unallocated_resources, for_offers_only=for_offers_only, + reverse_offers=reverse_offers, ) run_plan = RunPlan( project_name=project.name, diff --git a/src/dstack/_internal/server/services/runs/plan.py b/src/dstack/_internal/server/services/runs/plan.py index c5c175c57b..41869b9538 100644 --- a/src/dstack/_internal/server/services/runs/plan.py +++ b/src/dstack/_internal/server/services/runs/plan.py @@ -92,6 +92,7 @@ async def get_job_plans( full_offers: bool, unallocated_resources: bool, for_offers_only: bool, + reverse_offers: bool = False, ) -> list[JobPlan]: """ Returns job plans for the given run spec. @@ -195,11 +196,13 @@ async def get_job_plans( ) for job in jobs: - job_plan = _get_job_plan( + job_plan = await common_utils.run_async( + _get_job_plan, instance_offers=instance_offers, backend_offers=backend_offers, job=job, max_offers=max_offers, + reverse_offers=reverse_offers, ) job_plans.append(job_plan) @@ -1040,11 +1043,15 @@ def _get_job_plan( backend_offers: list[tuple[Backend, InstanceOfferWithAvailability]], job: Job, max_offers: Optional[int], + reverse_offers: bool = False, ) -> JobPlan: job_offers: list[InstanceOfferWithAvailability] = [] job_offers.extend(offer for _, offer in instance_offers) job_offers.extend(offer for _, offer in backend_offers) - job_offers.sort(key=lambda offer: not offer.availability.is_available()) + if reverse_offers: + job_offers.sort(key=lambda offer: (not offer.availability.is_available(), -offer.price)) + else: + job_offers.sort(key=lambda offer: not offer.availability.is_available()) remove_job_spec_sensitive_info(job.job_spec) return JobPlan( job_spec=job.job_spec, diff --git a/src/tests/_internal/server/routers/test_gpus.py b/src/tests/_internal/server/routers/test_gpus.py index 8ed36a637f..09fee1c0e7 100644 --- a/src/tests/_internal/server/routers/test_gpus.py +++ b/src/tests/_internal/server/routers/test_gpus.py @@ -16,9 +16,11 @@ Resources, ) from dstack._internal.core.models.profiles import CreationPolicy, Profile +from dstack._internal.core.models.resources import GPUSpec, Range, ResourcesSpec from dstack._internal.core.models.runs import RunSpec from dstack._internal.core.models.users import GlobalRole, ProjectRole from dstack._internal.server.models import FleetModel, ProjectModel +from dstack._internal.server.services import gpus as gpus_services from dstack._internal.server.services.projects import add_project_member from dstack._internal.server.testing.common import ( create_fleet, @@ -139,6 +141,7 @@ async def call_gpus_api( group_by: Optional[List[str]] = None, full_offers: Optional[bool] = None, unallocated_resources: Optional[bool] = None, + reverse: Optional[bool] = None, ): """Helper to call the GPUs API with standard parameters.""" json_data = {"run_spec": run_spec.model_dump()} @@ -148,6 +151,8 @@ async def call_gpus_api( json_data["full_offers"] = full_offers if unallocated_resources is not None: json_data["unallocated_resources"] = unallocated_resources + if reverse is not None: + json_data["reverse"] = reverse return await client.post( f"/api/project/{project_name}/gpus/list", @@ -157,6 +162,157 @@ async def call_gpus_api( class TestListGpus: + def test_reverse_sorts_by_whole_offer_price(self): + offers = [ + create_gpu_offer(BackendType.AWS, "H100", 81920, 8, gpu_count=8), + create_gpu_offer(BackendType.AWS, "A10", 24576, 2), + ] + groups = gpus_services._get_gpus_grouped(offers, set(), reverse=True) + assert [group.name for group in groups] == ["H100", "A10"] + assert groups[0].price.min == 1 + assert groups[0].offer_price is not None + assert groups[0].offer_price.min == 8 + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + @pytest.mark.parametrize("reverse", [None, False, True]) + @pytest.mark.parametrize( + "group_by", + [ + [], + ["backend"], + ["backend", "region"], + ["count"], + ["backend", "count"], + ["backend", "region", "count"], + ], + ) + async def test_price_order( + self, test_db, session: AsyncSession, client: AsyncClient, reverse, group_by + ): + user, project, _, run_spec = await gpu_test_setup(session) + offers = [ + create_gpu_offer(BackendType.AWS, "A10", 24576, 2), + create_gpu_offer(BackendType.AWS, "H100", 81920, 2), + create_gpu_offer(BackendType.AWS, "H200", 144384, 5), + create_gpu_offer(BackendType.AWS, "A10", 24576, 12, gpu_count=2), + create_gpu_offer(BackendType.AWS, "H100", 81920, 16, gpu_count=2), + create_gpu_offer( + BackendType.AWS, "L4", 24576, 0.5, availability=InstanceAvailability.UNKNOWN + ), + create_gpu_offer( + BackendType.AWS, "T4", 16384, 100, availability=InstanceAvailability.NO_QUOTA + ), + ] + with patch("dstack._internal.server.services.backends.get_project_backends") as mock: + mock.return_value = create_mock_backends_with_offers({BackendType.AWS: offers}) + response = await call_gpus_api( + client, project.name, user.token, run_spec, group_by=group_by, reverse=reverse + ) + + assert response.status_code == 200, response.json() + assert "cpus" not in response.json() + gpus = response.json()["gpus"] + if "count" in group_by: + expected = ( + ["H100", "A10", "H200", "A10", "H100", "L4", "T4"] + if reverse + else ["L4", "A10", "H100", "H200", "A10", "H100", "T4"] + ) + else: + expected = ( + ["H200", "H100", "A10", "L4", "T4"] + if reverse + else ["L4", "A10", "H100", "H200", "T4"] + ) + h100 = next(gpu for gpu in gpus if gpu["name"] == "H100") + assert h100["price"] == {"min": 2, "max": 8} + assert h100["offer_price"] == {"min": 2, "max": 16} + assert [gpu["name"] for gpu in gpus] == expected + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + @pytest.mark.parametrize("reverse", [False, True]) + @pytest.mark.parametrize( + ("group_by", "prices"), + [ + ([], [(1, 10)]), + (["backend"], [(1, 4), (10, 10)]), + (["backend", "region"], [(1, 4), (2, 2), (10, 10)]), + (["count"], [(1, 2), (4, 4), (10, 10)]), + (["backend", "count"], [(1, 2), (4, 4), (10, 10)]), + (["backend", "region", "count"], [(1, 1), (2, 2), (4, 4), (10, 10)]), + ], + ) + async def test_cpu_groups( + self, test_db, session: AsyncSession, client: AsyncClient, reverse, group_by, prices + ): + user, project, _, run_spec = await gpu_test_setup(session) + run_spec.configuration.resources = ResourcesSpec.unconstrained() + run_spec.configuration.resources.gpu = GPUSpec(count=Range[int](min=0, max=0)) + aws = [ + get_instance_offer_with_availability( + cpu_count=2, memory_gib=4, price=1, region="west" + ), + get_instance_offer_with_availability( + cpu_count=8, memory_gib=16, price=4, region="west", spot=True + ), + get_instance_offer_with_availability( + cpu_count=2, memory_gib=8, price=2, region="east" + ), + ] + for offer, arch in zip(aws, ["x86", "arm", "x86"]): + offer.instance.resources.cpu_arch = gpuhunt.CPUArchitecture(arch) + unavailable = get_instance_offer_with_availability( + backend=BackendType.AZURE, + cpu_count=4, + memory_gib=8, + price=10, + region="east", + availability=InstanceAvailability.NO_QUOTA, + ) + unavailable.instance.resources.cpu_arch = gpuhunt.CPUArchitecture.X86 + with patch("dstack._internal.server.services.backends.get_project_backends") as mock: + mock.return_value = create_mock_backends_with_offers( + {BackendType.AWS: aws, BackendType.AZURE: [unavailable]} + ) + response = await call_gpus_api( + client, + project.name, + user.token, + run_spec, + group_by=group_by, + reverse=reverse, + full_offers=True, + ) + + assert response.status_code == 200, response.json() + assert response.json()["gpus"] == [] + cpus = response.json()["cpus"] + # Availability takes priority over descending price, just as for GPU groups. + expected_prices = list(reversed(prices[:-1])) + prices[-1:] if reverse else prices + assert [group["price"] for group in cpus] == [ + {"min": low, "max": high} for low, high in expected_prices + ] + if not group_by: + assert cpus == [ + { + "cpu_arch": ["arm", "x86"], + "count": {"min": 2, "max": 8}, + "memory_mib": {"min": 4096, "max": 16384}, + "price": {"min": 1, "max": 10}, + "availability": ["available", "no_quota"], + "spot": ["on-demand", "spot"], + "backends": ["aws", "azure"], + } + ] + if "count" in group_by: + assert all(group["count"]["min"] == group["count"]["max"] for group in cpus) + if "backend" in group_by: + assert all("backend" in group and "backends" not in group for group in cpus) + if "region" in group_by: + assert all("region" in group and "regions" not in group for group in cpus) + @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) async def test_returns_403_if_not_project_member( diff --git a/src/tests/_internal/server/routers/test_runs.py b/src/tests/_internal/server/routers/test_runs.py index e16c81cea7..6bfdb4cd85 100644 --- a/src/tests/_internal/server/routers/test_runs.py +++ b/src/tests/_internal/server/routers/test_runs.py @@ -1531,6 +1531,63 @@ async def test_patches_service_gateway_for_old_clients( class TestGetRunPlan: + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + @pytest.mark.parametrize("reverse_offers", [None, False, True]) + @pytest.mark.parametrize("for_offers_only", [False, True]) + @pytest.mark.parametrize("gpu_count", [0, 2]) + async def test_sorts_offers_before_limit( + self, + test_db, + session: AsyncSession, + client: AsyncClient, + reverse_offers, + for_offers_only, + gpu_count, + ): + user = await create_user(session=session) + project = await create_project(session=session, owner=user) + repo = await create_repo(session=session, project_id=project.id) + await create_fleet(session=session, project=project, spec=get_fleet_spec()) + run_spec = get_run_spec(repo_id=repo.name) + offers = [ + get_instance_offer_with_availability( + price=price, gpu_count=gpu_count, instance_type=f"instance-{price}" + ) + for price in range(1, 61) + ] + offers.append( + get_instance_offer_with_availability( + price=100, gpu_count=gpu_count, availability=InstanceAvailability.NO_BALANCE + ) + ) + backend = Mock() + backend.TYPE = BackendType.AWS + backend.compute.return_value.get_offers.return_value = offers + body = { + "run_spec": run_spec.model_dump(), + "for_offers_only": for_offers_only, + "max_offers": 3, + } + if reverse_offers is not None: + body["reverse_offers"] = reverse_offers + with patch("dstack._internal.server.services.backends.get_project_backends") as mock: + mock.return_value = [backend] + response = await client.post( + f"/api/project/{project.name}/runs/get_plan", + headers=get_auth_headers(user.token), + json=body, + ) + + assert response.status_code == 200, response.json() + plan = response.json()["job_plans"][0] + assert [offer["price"] for offer in plan["offers"]] == ( + [60, 59, 58] if reverse_offers else [1, 2, 3] + ) + assert plan["total_offers"] == 61 + assert plan["max_price"] == 100 + assert [offer.price for offer in offers] == [*range(1, 61), 100] + @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) async def test_returns_403_if_not_project_member( diff --git a/src/tests/api/test_offers.py b/src/tests/api/test_offers.py new file mode 100644 index 0000000000..b7139c67d3 --- /dev/null +++ b/src/tests/api/test_offers.py @@ -0,0 +1,27 @@ +import pytest + +from dstack._internal.core.compatibility.gpus import get_list_gpus_excludes +from dstack._internal.core.compatibility.runs import get_get_plan_excludes +from dstack._internal.server.schemas.gpus import ListGpusRequest +from dstack._internal.server.schemas.runs import GetRunPlanRequest +from dstack._internal.server.testing.common import get_run_spec + + +class TestOfferRequestCompatibility: + @pytest.mark.parametrize( + ("request_type", "get_excludes", "field"), + [ + (GetRunPlanRequest, get_get_plan_excludes, "reverse_offers"), + (ListGpusRequest, get_list_gpus_excludes, "reverse"), + ], + ) + @pytest.mark.parametrize("reverse", [None, False, True]) + def test_omits_disabled_sort_flag(self, request_type, get_excludes, field, reverse): + kwargs = {} if reverse is None else {field: reverse} + request = request_type(run_spec=get_run_spec(repo_id="test-repo"), **kwargs) + body = request.model_dump(exclude=get_excludes(request)) + + if reverse: + assert body[field] is True + else: + assert field not in body