diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 605353071..2436f64f5 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -508,7 +508,8 @@ "template_placeholder": "Select a project to select a template", "template_card_type": "Type", "gpu": "GPU", - "gpu_description": "Enable to select a GPU offer. Disable to run without a GPU.", + "gpu_description_enabled": "Choose a specific offer, or let dstack select it automatically.", + "gpu_description_disabled": "Enable GPU for this run.", "offer": "Offer", "offer_description": "Select an offer for the run.", "name": "Name", diff --git a/frontend/src/pages/Offers/List/helpers.test.ts b/frontend/src/pages/Offers/List/helpers.test.ts new file mode 100644 index 000000000..6d7c77132 --- /dev/null +++ b/frontend/src/pages/Offers/List/helpers.test.ts @@ -0,0 +1,20 @@ +import { rangeToObject } from './helpers'; + +describe('Offers helpers', () => { + test('rangeToObject parses open and closed ranges', () => { + expect(rangeToObject('1..')).toEqual({ min: 1 }); + expect(rangeToObject('..4')).toEqual({ max: 4 }); + expect(rangeToObject('1..4')).toEqual({ min: 1, max: 4 }); + }); + + test('rangeToObject parses GB ranges for memory', () => { + expect(rangeToObject('24GB..', { requireUnit: true })).toEqual({ min: 24 }); + expect(rangeToObject('..80GB', { requireUnit: true })).toEqual({ max: 80 }); + expect(rangeToObject('40GB..80GB', { requireUnit: true })).toEqual({ min: 40, max: 80 }); + }); + + test('rangeToObject rejects unitless memory when unit is required', () => { + expect(rangeToObject('24..80', { requireUnit: true })).toBeUndefined(); + expect(rangeToObject(24, { requireUnit: true })).toBeUndefined(); + }); +}); diff --git a/frontend/src/pages/Offers/List/helpers.tsx b/frontend/src/pages/Offers/List/helpers.tsx index d1cbeb779..37d89782d 100644 --- a/frontend/src/pages/Offers/List/helpers.tsx +++ b/frontend/src/pages/Offers/List/helpers.tsx @@ -64,28 +64,53 @@ export const renderRangeJSX = (range: { min?: number; max?: number }) => { return range.min?.toString() ?? range.max?.toString(); }; -export const rangeToObject = (range: RequestParam): { min?: number; max?: number } | undefined => { +export const rangeToObject = ( + range: RequestParam, + { + requireUnit = false, + }: { + requireUnit?: boolean; + } = {}, +): { min?: number; max?: number } | undefined => { + const hasGbUnit = (value?: string) => /gb/i.test(value ?? ''); + if (!range) return; if (typeof range === 'string') { const [minString, maxString] = range.split(rangeSeparator); - - const min = Number(minString); - const max = Number(maxString); - - if (!isNaN(min) && !isNaN(max)) { + const normalizeNumericPart = (value?: string) => (value ?? '').replace(/[^\d.]/g, ''); + const parseBound = (value?: string): number | undefined => { + if (requireUnit && value && !hasGbUnit(value)) { + return undefined; + } + const normalized = normalizeNumericPart(value); + if (!normalized) { + return undefined; + } + const parsed = Number(normalized); + return isNaN(parsed) ? undefined : parsed; + }; + + const min = parseBound(minString); + const max = parseBound(maxString); + + if (typeof min === 'number' && typeof max === 'number') { return { min, max }; } - if (!isNaN(min)) { - return { min, max: min }; + if (typeof min === 'number') { + return { min }; } - if (!isNaN(max)) { - return { min: max, max }; + if (typeof max === 'number') { + return { max }; } } + if (typeof range === 'number') { + return requireUnit ? undefined : { min: range, max: range }; + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error return range; diff --git a/frontend/src/pages/Offers/List/hooks/useFilters.ts b/frontend/src/pages/Offers/List/hooks/useFilters.ts index b44cdfcee..ba5aa3129 100644 --- a/frontend/src/pages/Offers/List/hooks/useFilters.ts +++ b/frontend/src/pages/Offers/List/hooks/useFilters.ts @@ -20,7 +20,7 @@ export type UseFiltersArgs = { gpus: IGpu[]; withSearchParams?: boolean; permanentFilters?: Partial>; - defaultFilters?: Partial>; + defaultFilters?: Partial>; }; export const filterKeys: Record = { @@ -101,9 +101,18 @@ export const useFilters = ({ gpus, withSearchParams = true, permanentFilters = { const { data: projectsData } = useGetProjectsQuery({ limit: 1 }); const projectNameIsChecked = useRef(false); - const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => - requestParamsToTokens({ searchParams, filterKeys, defaultFilterValues: defaultFilters }), - ); + const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => { + const queryFromSearchParams = requestParamsToTokens({ + searchParams, + filterKeys, + defaultFilterValues: defaultFilters, + }); + if (queryFromSearchParams.tokens.length > 0) { + return queryFromSearchParams; + } + + return EMPTY_QUERY; + }); const [groupBy, setGroupBy] = useState(() => { const selectedGroupBy = requestParamsToArray({ diff --git a/frontend/src/pages/Offers/List/index.tsx b/frontend/src/pages/Offers/List/index.tsx index a44dbd48f..15658e422 100644 --- a/frontend/src/pages/Offers/List/index.tsx +++ b/frontend/src/pages/Offers/List/index.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Cards, CardsProps, MultiselectCSD, Popover, PropertyFilter } from 'components'; +import { Alert, Cards, CardsProps, MultiselectCSD, Popover, PropertyFilter } from 'components'; import { useCollection } from 'hooks'; import { useGetGpusListQuery } from 'services/gpu'; @@ -30,7 +30,7 @@ const getRequestParams = ({ group_by?: TGpuGroupBy[]; }): TGpusListQueryParams => { const gpuCountMinMax = rangeToObject(gpu_count ?? ''); - const gpuMemoryMinMax = rangeToObject(gpu_memory ?? ''); + const gpuMemoryMinMax = rangeToObject(gpu_memory ?? '', { requireUnit: true }); return { project_name, @@ -50,15 +50,15 @@ const getRequestParams = ({ // disk: { size: { min: 100.0 } }, gpu: { ...(gpu_name?.length ? { name: gpu_name } : {}), - ...(gpuCountMinMax ? { count: gpuCountMinMax } : {}), - ...(gpuMemoryMinMax ? { memory: gpuMemoryMinMax } : {}), + ...(gpuCountMinMax ? { count: gpuCountMinMax as unknown as TRange } : {}), + ...(gpuMemoryMinMax ? { memory: gpuMemoryMinMax as unknown as TRange } : {}), }, }, spot_policy, volumes: [], files: [], setup: [], - ...(backend?.length ? { backends: backend } : {}), + ...(backend?.length ? { backends: backend as TBackendType[] } : {}), }, profile: { name: 'default', default: false }, ssh_key_pub: '(dummy)', @@ -66,13 +66,14 @@ const getRequestParams = ({ }; }; -type OfferListProps = Pick & - Pick & { - withSearchParams?: boolean; - disabled?: boolean; - onChangeProjectName?: (value: string) => void; - onChangeBackendFilter?: (backends: string[]) => void; - }; +type OfferListProps = Pick & { + permanentFilters?: UseFiltersArgs['permanentFilters']; + defaultFilters?: UseFiltersArgs['defaultFilters']; + withSearchParams?: boolean; + disabled?: boolean; + onChangeProjectName?: (value: string) => void; + onChangeBackendFilter?: (backends: string[]) => void; +}; export const OfferList: React.FC = ({ withSearchParams, @@ -86,7 +87,7 @@ export const OfferList: React.FC = ({ const { t } = useTranslation(); const [requestParams, setRequestParams] = useState(); - const { data, isLoading, isFetching } = useGetGpusListQuery( + const { data, error, isError, isLoading, isFetching } = useGetGpusListQuery( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error requestParams, @@ -121,12 +122,16 @@ export const OfferList: React.FC = ({ }, [JSON.stringify(filteringRequestParams), groupBy]); useEffect(() => { - onChangeProjectName?.(filteringRequestParams.project_name ?? ''); + const projectName = typeof filteringRequestParams.project_name === 'string' ? filteringRequestParams.project_name : ''; + onChangeProjectName?.(projectName); }, [filteringRequestParams.project_name]); useEffect(() => { const backend = filteringRequestParams.backend; - onChangeBackendFilter?.(backend ? (Array.isArray(backend) ? backend : [backend]) : []); + const backendValues = backend + ? (Array.isArray(backend) ? backend : [backend]).filter((value): value is string => typeof value === 'string') + : []; + onChangeBackendFilter?.(backendValues); }, [filteringRequestParams.backend]); const { renderEmptyMessage, renderNoMatchMessage } = useEmptyMessages({ @@ -228,56 +233,66 @@ export const OfferList: React.FC = ({ ].filter(Boolean) as CardsProps.CardDefinition['sections']; return ( - gpu.name, - sections, - }} - loading={!disabled && (isLoading || isFetching)} - loadingText={t('common.loading')} - stickyHeader={true} - filter={ - disabled ? undefined : ( -
-
- `Use: ${value}`, - }} - filteringOptions={filteringOptions} - filteringProperties={filteringProperties} - filteringStatusType={filteringStatusType} - onLoadItems={handleLoadItems} - /> -
+ <> + {!disabled && 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' })} + + )} -
- + gpu.name, + sections, + }} + loading={!disabled && (isLoading || isFetching)} + loadingText={t('common.loading')} + stickyHeader={true} + filter={ + disabled ? undefined : ( +
+
+ `Use: ${value}`, + }} + filteringOptions={filteringOptions} + filteringProperties={filteringProperties} + filteringStatusType={filteringStatusType} + onLoadItems={handleLoadItems} + /> +
+ +
+ +
-
- ) - } - /> + ) + } + /> + ); }; diff --git a/frontend/src/pages/Runs/Launch/helpers/templateResources.test.ts b/frontend/src/pages/Runs/Launch/helpers/templateResources.test.ts new file mode 100644 index 000000000..7fa227036 --- /dev/null +++ b/frontend/src/pages/Runs/Launch/helpers/templateResources.test.ts @@ -0,0 +1,63 @@ +import { getTemplateOfferDefaultFilters } from './templateResources'; + +const makeTemplate = (configuration: Record): ITemplate => + ({ + type: 'template', + name: 'test', + title: 'test', + parameters: [{ type: 'resources' }], + configuration, + }) as ITemplate; + +describe('templateResources', () => { + test('returns full gpu name list from object gpu spec', () => { + const template = makeTemplate({ + type: 'task', + resources: { + gpu: { + name: ['H100', 'H200'], + count: { min: 1, max: 2 }, + }, + }, + }); + + expect(getTemplateOfferDefaultFilters(template)).toMatchObject({ + gpu_name: ['H100', 'H200'], + gpu_count: '1..2', + }); + }); + + test('keeps GB units and open ranges for gpu memory', () => { + const template = makeTemplate({ + type: 'task', + resources: { + gpu: { + name: 'H100', + memory: { min: '24GB' }, + }, + }, + }); + + expect(getTemplateOfferDefaultFilters(template)).toMatchObject({ + gpu_name: 'H100', + gpu_memory: '24GB..', + }); + }); + + test('adds backends and spot policy defaults', () => { + const template = makeTemplate({ + type: 'task', + resources: { + gpu: 'H100:1', + }, + backends: ['aws', 'vastai'], + spot_policy: 'auto', + }); + + expect(getTemplateOfferDefaultFilters(template)).toMatchObject({ + gpu_name: 'H100', + backend: ['aws', 'vastai'], + spot_policy: 'auto', + }); + }); +}); diff --git a/frontend/src/pages/Runs/Launch/helpers/templateResources.ts b/frontend/src/pages/Runs/Launch/helpers/templateResources.ts new file mode 100644 index 000000000..af1963f25 --- /dev/null +++ b/frontend/src/pages/Runs/Launch/helpers/templateResources.ts @@ -0,0 +1,162 @@ +import { getRunSpecConfigurationResources } from './getRunSpecConfigurationResources'; + +type TGpuFilterDefaults = { + gpu_name?: string | string[]; + gpu_count?: string; + gpu_memory?: string; +}; + +type TOfferFilterDefaults = TGpuFilterDefaults & { + backend?: string | string[]; + spot_policy?: string; +}; + +const formatRangeForFilter = ( + value: unknown, + { + stripGB = false, + appendGBToNumber = false, + requireUnitForString = false, + }: { + stripGB?: boolean; + appendGBToNumber?: boolean; + requireUnitForString?: boolean; + } = {}, +): string | undefined => { + const hasGbUnit = (token: string) => /gb/i.test(token); + + const normalizeToken = (token: unknown): string | undefined => { + if (typeof token === 'number') { + return appendGBToNumber ? `${token}GB` : String(token); + } + if (typeof token === 'string') { + if (requireUnitForString && !hasGbUnit(token)) { + return undefined; + } + const normalized = stripGB ? token.replace(/GB/gi, '').trim() : token.trim(); + return normalized || undefined; + } + return undefined; + }; + + if (typeof value === 'number') { + return normalizeToken(value); + } + if (typeof value === 'string') { + return normalizeToken(value); + } + if (value && typeof value === 'object') { + const min = (value as Record).min; + const max = (value as Record).max; + const minValue = normalizeToken(min); + const maxValue = normalizeToken(max); + const hasMin = !!minValue; + const hasMax = !!maxValue; + if (hasMin && hasMax) { + return `${minValue}..${maxValue}`; + } + if (hasMin) { + return `${minValue}..`; + } + if (hasMax) { + return `..${maxValue}`; + } + } + + return undefined; +}; + +const getTemplateGpuSpec = (template?: ITemplate): TResourceRequest['gpu'] | undefined => { + const resources = template?.configuration?.resources; + if (!resources || typeof resources !== 'object') { + return undefined; + } + + 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 spotPolicy = + configuration && typeof configuration === 'object' && typeof configuration.spot_policy === 'string' + ? configuration.spot_policy + : undefined; + const backends = + configuration && + typeof configuration === 'object' && + Array.isArray(configuration.backends) && + configuration.backends.every((backend) => typeof backend === 'string') + ? (configuration.backends as string[]) + : undefined; + + if (typeof gpu === 'number') { + return { + ...(gpu > 0 ? { gpu_count: String(gpu) } : {}), + ...(spotPolicy ? { spot_policy: spotPolicy } : {}), + ...(backends?.length ? { backend: backends } : {}), + }; + } + + if (typeof gpu === 'string') { + // Keep fallback for unknown string forms not normalized into object shape. + const tokens = gpu + .split(':') + .map((token) => token.trim()) + .filter(Boolean); + const gpuNames = tokens[0] + ?.split(',') + .map((name) => name.trim()) + .filter(Boolean); + + return { + ...(gpuNames?.length === 1 ? { gpu_name: gpuNames[0] } : {}), + ...(gpuNames && gpuNames.length > 1 ? { gpu_name: gpuNames } : {}), + ...(spotPolicy ? { spot_policy: spotPolicy } : {}), + ...(backends?.length ? { backend: backends } : {}), + }; + } + + const gpuName = gpu && typeof gpu === 'object' ? gpu.name : undefined; + const gpuCount = + gpu && typeof gpu === 'object' + ? formatRangeForFilter(gpu.count, { + stripGB: true, + appendGBToNumber: false, + }) + : undefined; + const gpuMemory = + gpu && typeof gpu === 'object' + ? formatRangeForFilter(gpu.memory, { + stripGB: false, + appendGBToNumber: true, + requireUnitForString: true, + }) + : undefined; + + return { + ...(typeof gpuName === 'string' ? { gpu_name: gpuName } : {}), + ...(Array.isArray(gpuName) && gpuName.every((name) => typeof name === 'string') ? { gpu_name: gpuName } : {}), + ...(gpuCount ? { gpu_count: gpuCount } : {}), + ...(gpuMemory ? { gpu_memory: gpuMemory } : {}), + ...(spotPolicy ? { spot_policy: spotPolicy } : {}), + ...(backends?.length ? { backend: backends } : {}), + }; +}; diff --git a/frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.ts b/frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.ts index 51b7b781f..8af880cf7 100644 --- a/frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.ts +++ b/frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.ts @@ -9,13 +9,24 @@ export type UseGenerateYamlArgs = { formValues: IRunEnvironmentFormValues; configuration?: ITemplate['configuration']; envParam?: TTemplateParam; + hasResourcesParam?: boolean; backends?: string[]; }; -export const useGenerateYaml = ({ formValues, configuration, envParam, backends }: UseGenerateYamlArgs) => { +export const useGenerateYaml = ({ formValues, configuration, envParam, hasResourcesParam, backends }: 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 hasTemplateResources = + configuration && + 'resources' in configuration && + configuration.resources && + typeof configuration.resources === 'object' + ? true + : false; + const baseResources = + hasTemplateResources && configuration ? (configuration.resources as Record) : undefined; + const hasTemplateGpu = !!baseResources && 'gpu' in baseResources; const envEntries: string[] = []; if (envParam?.name && password) { @@ -38,6 +49,7 @@ export const useGenerateYaml = ({ formValues, configuration, envParam, backends ...(gpuEnabled && offer ? { resources: { + ...baseResources, gpu: `${offer.name}:${round(convertMiBToGB(offer.memory_mib))}GB:${renderRange(offer.count)}`, }, @@ -46,7 +58,22 @@ export const useGenerateYaml = ({ formValues, configuration, envParam, backends ...(offer.spot.length > 1 ? { spot_policy: 'auto' } : {}), } : {}), - ...(!gpuEnabled ? { resources: { gpu: 0 } } : {}), + ...(gpuEnabled && !offer && hasResourcesParam && !hasTemplateGpu + ? { + resources: { + ...baseResources, + gpu: '1..', + }, + } + : {}), + ...(hasResourcesParam && !gpuEnabled + ? { + resources: { + ...baseResources, + gpu: 0, + }, + } + : {}), ...(repo_url || repo_path ? { @@ -58,5 +85,5 @@ export const useGenerateYaml = ({ formValues, configuration, envParam, backends }, { lineWidth: -1 }, ); - }, [formValues, configuration, envParam, backends]); + }, [formValues, configuration, envParam, hasResourcesParam, backends]); }; diff --git a/frontend/src/pages/Runs/Launch/hooks/useValidationResolver.ts b/frontend/src/pages/Runs/Launch/hooks/useValidationResolver.ts index 010521760..41ef5d04b 100644 --- a/frontend/src/pages/Runs/Launch/hooks/useValidationResolver.ts +++ b/frontend/src/pages/Runs/Launch/hooks/useValidationResolver.ts @@ -11,9 +11,7 @@ const passwordNotCopiedError = 'Copy the password before proceeding'; export const useYupValidationResolver = (template?: ITemplate) => { const validationSchema = useMemo(() => { - const schema: Partial< - Record | yup.BooleanSchema> - > = { + const schema: Partial> = { project: yup.string().required(requiredFieldError), template: yup.array().min(1, requiredFieldError).of(yup.string()).required(requiredFieldError), config_yaml: yup.string().required(requiredFieldError), @@ -31,12 +29,9 @@ export const useYupValidationResolver = (template?: ITemplate) => { break; case 'resources': - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - schema['offer'] = yup.object().when('gpu_enabled', { - is: true, - then: yup.object().required(requiredFieldError), - }); + // Offer selection is optional when GPU is enabled. + // If no offer is selected, YAML generation applies template GPU or a default fallback. + schema['offer'] = yup.object().nullable(); break; case 'python_or_docker': @@ -66,13 +61,9 @@ export const useYupValidationResolver = (template?: ITemplate) => { schema['password'] = yup .string() .required(requiredFieldError) - .test( - 'password-copied', - passwordNotCopiedError, - function () { - return this.parent.password_copied === true; - }, - ); + .test('password-copied', passwordNotCopiedError, function () { + return this.parent.password_copied === true; + }); } else { schema['password'] = yup.string().required(requiredFieldError); } diff --git a/frontend/src/pages/Runs/Launch/index.tsx b/frontend/src/pages/Runs/Launch/index.tsx index 48705a3ef..23b6acc29 100644 --- a/frontend/src/pages/Runs/Launch/index.tsx +++ b/frontend/src/pages/Runs/Launch/index.tsx @@ -33,6 +33,7 @@ import { OfferList } from 'pages/Offers/List'; import { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAlert'; import { ParamsWizardStep } from './components/ParamsWizardStep'; +import { getTemplateOfferDefaultFilters, hasConfiguredGpu } from './helpers/templateResources'; import { useGenerateYaml } from './hooks/useGenerateYaml'; import { useGetRunSpecFromYaml } from './hooks/useGetRunSpecFromYaml'; import { useYupValidationResolver } from './hooks/useValidationResolver'; @@ -132,6 +133,12 @@ export const Launch: React.FC = () => { setSelectedTemplate(templatesData?.find((t) => t.name === formValues.template?.[0])); }, [templatesData, formValues.template]); + useEffect(() => { + setSelectedOffers([]); + setValue(FORM_FIELD_NAMES.offer, undefined); + setValue(FORM_FIELD_NAMES.gpu_enabled, hasConfiguredGpu(selectedTemplate)); + }, [selectedTemplate, setValue]); + const validateProjectAndTemplate = async () => await trigger(templateStepFieldNames); const validateOffer = async () => await trigger(offerStepFieldNames); const validateConfigParams = async () => await trigger(paramsStepFieldNames); @@ -181,7 +188,7 @@ export const Launch: React.FC = () => { const onChangeOffer: CardsProps['onSelectionChange'] = ({ detail }) => { const newSelectedOffers = detail?.selectedItems ?? []; setSelectedOffers(newSelectedOffers); - setValue(FORM_FIELD_NAMES.offer, newSelectedOffers?.[0] ?? null); + setValue(FORM_FIELD_NAMES.offer, newSelectedOffers?.[0] ?? undefined); }; const onSubmitWizard = async () => { @@ -241,10 +248,20 @@ export const Launch: React.FC = () => { }; const envParam = selectedTemplate?.parameters?.find((p) => p.type === 'env'); + const hasResourcesParam = selectedTemplate?.parameters?.some((p) => p.type === 'resources') ?? false; + const configuredOfferFilters = getTemplateOfferDefaultFilters(selectedTemplate); + const defaultOfferFilters = useMemo( + () => ({ + spot_policy: 'on-demand', + ...configuredOfferFilters, + }), + [configuredOfferFilters], + ); const yaml = useGenerateYaml({ formValues, configuration: selectedTemplate?.configuration, envParam, + hasResourcesParam, backends: selectedBackends, }); @@ -358,13 +375,17 @@ export const Launch: React.FC = () => { onSelectionChange={onChangeOffer} onChangeBackendFilter={setSelectedBackends} permanentFilters={{ project_name: formValues.project ?? '' }} - defaultFilters={{ spot_policy: 'on-demand' }} + defaultFilters={defaultOfferFilters} header={ diff --git a/frontend/src/pages/Runs/Launch/types.ts b/frontend/src/pages/Runs/Launch/types.ts index cc232a4ef..ef3c164f0 100644 --- a/frontend/src/pages/Runs/Launch/types.ts +++ b/frontend/src/pages/Runs/Launch/types.ts @@ -2,9 +2,9 @@ export interface IRunEnvironmentFormValues { project: IProject['project_name']; template: string[]; gpu_enabled?: boolean; - offer: IGpu; + offer?: IGpu; name: string; - ide: 'cursor' | 'vscode' | 'windsurf' | 'coder'; + ide: 'cursor' | 'vscode' | 'windsurf'; config_yaml: string; image?: string; python?: string;