diff --git a/frontend/product.config.cjs b/frontend/product.config.cjs index da846b9f15..cf8e02fb39 100644 --- a/frontend/product.config.cjs +++ b/frontend/product.config.cjs @@ -5,9 +5,10 @@ const oss = { hasEvents: false, hasBilling: false, hasPresets: false, + hasTemplates: false, isSky: false, }; -const enterprise = { ...oss, id: 'enterprise', name: 'dstack Enterprise', hasEvents: true }; +const enterprise = { ...oss, id: 'enterprise', name: 'dstack Enterprise', hasEvents: true, hasTemplates: true }; const factory = { ...enterprise, id: 'factory', name: 'dstack Factory', hasBilling: true, hasPresets: true }; const sky = { ...factory, id: 'sky', name: 'dstack Sky', isSky: true }; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 699aa0303f..812789bb71 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -3,6 +3,8 @@ import { Provider as BusProvider } from 'react-bus'; import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; import { RouterProvider } from 'react-router-dom'; +import { I18nProvider } from '@cloudscape-design/components/i18n'; +import enMessages from '@cloudscape-design/components/i18n/messages/all.en.json'; import { applyTheme, Theme } from '@cloudscape-design/components/theming'; import { initAnalytics } from './analytics'; @@ -39,11 +41,14 @@ if (container) { root.render( - - - - - + {/* Cloudscape's default labels, e.g. the property filter's Apply and Cancel, for any a page leaves out. */} + + + + + + + , ); } diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 2cccd3998d..10ae36a5ed 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -496,14 +496,28 @@ }, "runs": { "launch_button": "Launch", + "launch_page": { + "title": "Launch", + "select_project": "Select a project to see how to launch a run.", + "cli_title": "Run with the CLI", + "docs": "Docs", + "cli_install": "1. Install the CLI", + "cli_add_project": "2. Add this project", + "cli_define": "3. Define a configuration", + "cli_define_text": "Define a configuration in .dstack.yml.", + "cli_apply": "4. Apply the configuration", + "agents_title": "Run with agents", + "agents_description": "Let AI agents define configurations, run them, and manage dstack for you", + "template_title": "From a template", + "no_templates_text": "If you configure a repo with templates, you can run them from here." + }, "no_templates_alert": { - "title": "No templates configured", + "title": "No templates", "description": "The selected project has no templates available for Launch.", "action": "Settings" }, "launch": { "wizard": { - "title": "Launch", "submit": "Apply", "project": "Project", "project_description": "Select a project", @@ -573,9 +587,9 @@ "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": "No offers to display.", + "empty_message_text": "A backend may not be configured for this project.", "nomatch_message_title": "No matches", - "nomatch_message_text": "We can't find a match." + "nomatch_message_text": "We can't find a match. Try to clear filter." }, "models": { diff --git a/frontend/src/pages/Offers/List/helpers.test.ts b/frontend/src/pages/Offers/List/helpers.test.ts index 6d7c77132b..01f8a4677a 100644 --- a/frontend/src/pages/Offers/List/helpers.test.ts +++ b/frontend/src/pages/Offers/List/helpers.test.ts @@ -1,6 +1,28 @@ -import { rangeToObject } from './helpers'; +import { rangeToObject, sortGpusByPriceDesc } from './helpers'; + +const makeGpu = (name: string, price: { min: number; max: number }, availability: TAvailability[] = ['available']): IGpu => ({ + name, + memory_mib: 81920, + vendor: 'nvidia', + availability, + spot: ['on-demand'], + count: { min: 1, max: 8 }, + price, +}); 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 }), + ]; + + expect(sortGpusByPriceDesc(gpus).map(({ name }) => name)).toEqual(['H200', 'H100', 'A10', 'L4', 'B200']); + }); + test('rangeToObject parses open and closed ranges', () => { expect(rangeToObject('1..')).toEqual({ min: 1 }); expect(rangeToObject('..4')).toEqual({ max: 4 }); diff --git a/frontend/src/pages/Offers/List/helpers.tsx b/frontend/src/pages/Offers/List/helpers.tsx index 531eb0544f..efbe23aac7 100644 --- a/frontend/src/pages/Offers/List/helpers.tsx +++ b/frontend/src/pages/Offers/List/helpers.tsx @@ -40,6 +40,17 @@ export const getPropertyFilterOptions = (gpus: IGpu[]) => { }; }; +const AVAILABLE_STATUSES = new Set(['unknown', 'available', 'idle']); + +const isGpuAvailable = (gpu: IGpu) => gpu.availability.some((availability) => AVAILABLE_STATUSES.has(availability)); + +// 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 const getFleetFilterValue = (fleet: IFleet, selectedProjectName?: string) => { if (selectedProjectName && fleet.project_name === selectedProjectName) { return fleet.name; diff --git a/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx b/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx index 1d6ba07efb..452cc78b41 100644 --- a/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx +++ b/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx @@ -1,23 +1,35 @@ import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import { Button, ListEmptyMessage } from 'components'; +import { ROUTES } from 'routes'; + export const useEmptyMessages = ({ clearFilter, - isDisabledClearFilter, - projectNameSelected, + projectName, groupBySelected, + hasCustomFilters, }: { clearFilter?: () => void; - isDisabledClearFilter?: boolean; - projectNameSelected?: boolean; + projectName?: string; groupBySelected?: boolean; + hasCustomFilters?: boolean; }) => { const { t } = useTranslation(); + const navigate = useNavigate(); + + const renderNoMatchMessage = useCallback<() => React.ReactNode>(() => { + return ( + + + + ); + }, [clearFilter]); const renderEmptyMessage = useCallback<() => React.ReactNode>(() => { - if (!projectNameSelected) { + if (!projectName) { return ( - - - ); - }, [clearFilter, isDisabledClearFilter]); + if (hasCustomFilters) { + return renderNoMatchMessage(); + } - const renderNoMatchMessage = useCallback<() => React.ReactNode>(() => { return ( - - ); - }, [clearFilter, isDisabledClearFilter]); + }, [projectName, groupBySelected, hasCustomFilters, renderNoMatchMessage]); return { renderEmptyMessage, renderNoMatchMessage } as const; }; diff --git a/frontend/src/pages/Offers/List/hooks/useFilters.ts b/frontend/src/pages/Offers/List/hooks/useFilters.ts index 5d331aa6d6..872150df89 100644 --- a/frontend/src/pages/Offers/List/hooks/useFilters.ts +++ b/frontend/src/pages/Offers/List/hooks/useFilters.ts @@ -32,6 +32,8 @@ export type UseFiltersArgs = { showFleetFilter?: boolean; permanentFilters?: Partial>; defaultFilters?: Partial>; + /** Loads offers for GPU name and backend suggestions, for a page that doesn't list offers itself. */ + loadGpus?: (projectName: string) => Promise; }; export const filterKeys: Record = { @@ -46,6 +48,21 @@ export const filterKeys: Record = { const multipleChoiceKeys: RequestParamsKeys[] = ['gpu_name', 'backend', 'fleet']; +/** The offer filters in a URL, as `defaultFilters` takes them. */ +export const getOfferFilters = (searchParams: URLSearchParams): UseFiltersArgs['defaultFilters'] => { + const filters: UseFiltersArgs['defaultFilters'] = {}; + + Object.values(filterKeys).forEach((key) => { + const values = searchParams.getAll(key); + + if (values.length) { + filters[key] = multipleChoiceKeys.includes(key) ? values : values[0]; + } + }); + + return filters; +}; + const spotPolicyOptions = [ { propertyKey: filterKeys.SPOT_POLICY, @@ -61,13 +78,15 @@ const spotPolicyOptions = [ }, ]; +const projectFilteringProperty = { + key: filterKeys.PROJECT_NAME, + operators: ['='], + propertyLabel: 'Project', + groupValuesLabel: 'Project values', +}; + const filteringProperties = [ - { - key: filterKeys.PROJECT_NAME, - operators: ['='], - propertyLabel: 'Project', - groupValuesLabel: 'Project values', - }, + projectFilteringProperty, { key: filterKeys.GPU_NAME, operators: ['='], @@ -117,19 +136,24 @@ export const useFilters = ({ showFleetFilter = false, permanentFilters = {}, defaultFilters, + loadGpus, }: UseFiltersArgs) => { const [searchParams, setSearchParams] = useSearchParams(); const [dynamicFilteringOptions, setDynamicFilteringOptions] = useState([]); const [filteringStatusType, setFilteringStatusType] = useState(); const [getProjects] = useLazyGetProjectsQuery(); const [getProjectFleets] = useLazyGetProjectFleetsQuery(); - const { data: projectsData } = useGetProjectsQuery({ limit: 1 }); + const { data: projectsData } = useGetProjectsQuery({ limit: 2 }); + const propertyFilterRef = useRef(null); const projectNameIsChecked = useRef(false); const prevSelectedProjectName = useRef(); + // Without search params, the page URL belongs to another page, e.g. the Launch wizard. + const initialSearchParams = withSearchParams ? searchParams : new URLSearchParams(); + const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => { const queryFromSearchParams = requestParamsToTokens({ - searchParams, + searchParams: initialSearchParams, filterKeys, defaultFilterValues: defaultFilters, }); @@ -152,7 +176,7 @@ export const useFilters = ({ const [groupBy, setGroupBy] = useState(() => { const selectedGroupBy = requestParamsToArray({ - searchParams, + searchParams: initialSearchParams, paramName: groupByRequestParamName, }); @@ -163,12 +187,14 @@ export const useFilters = ({ return [gpuFilterOption]; }); + const hasCustomFilters = propertyFilterQuery.tokens.some((token) => token.propertyKey !== filterKeys.PROJECT_NAME); + + // Keeps the project and the group by, so that clearing leads back to the project's offers. const clearFilter = () => { - if (withSearchParams) { - setSearchParams({}); - } - setPropertyFilterQuery(EMPTY_QUERY); - setGroupBy([]); + onChangePropertyFilterHandle({ + tokens: propertyFilterQuery.tokens.filter((token) => token.propertyKey === filterKeys.PROJECT_NAME), + operation: propertyFilterQuery.operation, + }); }; const filteringOptions = useMemo(() => { @@ -324,6 +350,12 @@ export const useFilters = ({ .then(setDynamicFilteringOptions); } + const isGpuProperty = filteringProperty?.key === filterKeys.GPU_NAME || filteringProperty?.key === filterKeys.BACKEND; + + if (loadGpus && isGpuProperty && selectedProjectName) { + await loadGpus(selectedProjectName); + } + if (showFleetFilter && filteringProperty?.key === filterKeys.FLEET && selectedProjectName) { await getProjectFleets({ projectName: selectedProjectName, @@ -345,24 +377,49 @@ export const useFilters = ({ setFilteringStatusType(undefined); }; + // Cloudscape can only focus the filter, so typing "Project = " into it lists the projects. + const openProjectValues = () => { + propertyFilterRef.current?.focus(); + + const input = document.activeElement; + const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + + if (!(input instanceof HTMLInputElement) || !setInputValue) { + return; + } + + setInputValue.call(input, `${projectFilteringProperty.propertyLabel} = `); + input.dispatchEvent(new Event('input', { bubbles: true })); + }; + useEffect(() => { - if (!projectNameIsChecked.current && projectsData?.data?.length) { - projectNameIsChecked.current = true; - - if (!filteringRequestParams['project_name']) { - onChangePropertyFilterHandle({ - tokens: [ - ...propertyFilterQuery.tokens, - { - operator: '=', - propertyKey: filterKeys.PROJECT_NAME, - value: projectsData.data[0].project_name, - }, - ], - operation: 'and', - }); - } + if (projectNameIsChecked.current || !projectsData?.data?.length) { + return; + } + + projectNameIsChecked.current = true; + + if (filteringRequestParams['project_name']) { + return; + } + + // With several projects, guessing is usually wrong, so the user picks one. + if (projectsData.data.length > 1) { + openProjectValues(); + return; } + + onChangePropertyFilterHandle({ + tokens: [ + ...propertyFilterQuery.tokens, + { + operator: '=', + propertyKey: filterKeys.PROJECT_NAME, + value: projectsData.data[0].project_name, + }, + ], + operation: 'and', + }); }, [projectsData]); useEffect(() => { @@ -386,6 +443,8 @@ export const useFilters = ({ return { filteringRequestParams, clearFilter, + hasCustomFilters, + propertyFilterRef, propertyFilterQuery, onChangePropertyFilter, filteringOptions, diff --git a/frontend/src/pages/Offers/List/index.tsx b/frontend/src/pages/Offers/List/index.tsx index 559698b563..39216af9b8 100644 --- a/frontend/src/pages/Offers/List/index.tsx +++ b/frontend/src/pages/Offers/List/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Cards, CardsProps, MultiselectCSD, Popover, PropertyFilter } from 'components'; @@ -8,11 +8,11 @@ import { useGetGpusListQuery } from 'services/gpu'; import { useEmptyMessages } from './hooks/useEmptyMessages'; import { useFilters, UseFiltersArgs } from './hooks/useFilters'; -import { convertMiBToGB, rangeToObject, renderRange, renderRangeJSX, round } from './helpers'; +import { convertMiBToGB, rangeToObject, renderRange, renderRangeJSX, round, sortGpusByPriceDesc } from './helpers'; import styles from './styles.module.scss'; -const getRequestParams = ({ +export const getRequestParams = ({ project_name, gpu_name, backend, @@ -80,6 +80,11 @@ type OfferListProps = Pick void; 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; + /** Called with whether the project has no offers without custom filters. */ + onChangeNoOffers?: (noOffers: boolean) => void; }; export const OfferList: React.FC = ({ @@ -91,6 +96,9 @@ export const OfferList: React.FC = ({ onChangeFleetFilter, permanentFilters, defaultFilters, + preselectedGpu, + onPreselect, + onChangeNoOffers, ...props }) => { const { t } = useTranslation(); @@ -108,6 +116,8 @@ export const OfferList: React.FC = ({ const { filteringRequestParams, clearFilter, + hasCustomFilters, + propertyFilterRef, propertyFilterQuery, onChangePropertyFilter, filteringOptions, @@ -153,12 +163,46 @@ export const OfferList: React.FC = ({ const { renderEmptyMessage, renderNoMatchMessage } = useEmptyMessages({ clearFilter, - projectNameSelected: Boolean(requestParams?.['project_name']), + projectName: requestParams?.['project_name'], groupBySelected: Boolean(requestParams?.['group_by']?.length), + hasCustomFilters, }); + const gpus = useMemo(() => sortGpusByPriceDesc(data?.gpus ?? []), [data]); + + const noOffers = + !!requestParams?.project_name && + !!requestParams?.group_by?.length && + !isLoading && + !isFetching && + !isError && + !hasCustomFilters && + gpus.length === 0; + + useEffect(() => { + onChangeNoOffers?.(noOffers); + }, [noOffers]); + + const isPreselected = useRef(false); + + useEffect(() => { + if (!preselectedGpu || isPreselected.current) { + return; + } + + const gpu = gpus.find( + ({ name, memory_mib }) => + name === preselectedGpu.name && round(convertMiBToGB(memory_mib)) === preselectedGpu.memoryGb, + ); + + if (gpu) { + isPreselected.current = true; + onPreselect?.(gpu); + } + }, [gpus]); + const { items, collectionProps } = useCollection( - requestParams?.['project_name'] && requestParams?.['group_by']?.length ? (data?.gpus ?? []) : [], + requestParams?.['project_name'] && requestParams?.['group_by']?.length ? gpus : [], { filtering: { empty: renderEmptyMessage(), @@ -264,7 +308,7 @@ export const OfferList: React.FC = ({ {...props} entireCardClickable items={disabled ? [] : items} - empty={disabled ? ' ' : undefined} + empty={disabled ? ' ' : collectionProps.empty} cardDefinition={{ header: (gpu) => gpu.name, sections, @@ -277,6 +321,7 @@ export const OfferList: React.FC = ({
{ const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [selectedGpus, setSelectedGpus] = useState([]); + const [noOffers, setNoOffers] = useState(false); useBreadcrumbs([ { @@ -17,7 +24,50 @@ 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`); + } + + 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 ( - {t('offer.title')}} /> + setSelectedGpus([])} + onChangeNoOffers={setNoOffers} + {...selectionProps} + header={ +
+ {t('runs.launch_button')} + + } + > + {t('offer.title')} +
+ } + /> ); }; diff --git a/frontend/src/pages/Project/Details/Settings/constants.tsx b/frontend/src/pages/Project/Details/Settings/constants.tsx index 195c684889..1f3e6f47ae 100644 --- a/frontend/src/pages/Project/Details/Settings/constants.tsx +++ b/frontend/src/pages/Project/Details/Settings/constants.tsx @@ -29,7 +29,7 @@ export const TEMPLATES_REPO_INFO = { <>

Specify a project-level templates Git repository URL. Templates from this repo are shown on the Launch page in - Runs, and setting it enables the Launch button when templates are available. + Runs.

If set, project templates override global templates configured on the server.

diff --git a/frontend/src/pages/Project/Details/Settings/index.tsx b/frontend/src/pages/Project/Details/Settings/index.tsx index a2f0b5f4e8..158567d135 100644 --- a/frontend/src/pages/Project/Details/Settings/index.tsx +++ b/frontend/src/pages/Project/Details/Settings/index.tsx @@ -159,7 +159,8 @@ export const ProjectSettings: React.FC = () => { useEffect(() => { if (location.hash === '#danger-zone') { setTimeout(() => { - dangerZoneRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // Not smooth: sections that finish rendering meanwhile cancel a smooth scroll. + dangerZoneRef.current?.scrollIntoView({ block: 'start' }); }, 0); } }, [location.hash, isLoadingPage]); @@ -559,7 +560,7 @@ export const ProjectSettings: React.FC = () => {

- {isAvailableProjectManaging && ( + {product.hasTemplates && isAvailableProjectManaging && ( <>
diff --git a/frontend/src/pages/Runs/Launch/index.tsx b/frontend/src/pages/Runs/Launch/index.tsx index c70ee317a2..663507df17 100644 --- a/frontend/src/pages/Runs/Launch/index.tsx +++ b/frontend/src/pages/Runs/Launch/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; @@ -30,6 +30,7 @@ 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 { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAlert'; import { ParamsWizardStep } from './components/ParamsWizardStep'; @@ -81,7 +82,11 @@ export const Launch: React.FC = () => { href: ROUTES.RUNS.LIST, }, { - text: t('runs.launch.wizard.title'), + text: t('runs.launch_page.title'), + href: ROUTES.RUNS.LAUNCH, + }, + { + text: t('runs.launch_page.template_title'), href: ROUTES.RUNS.CREATE_DEV_ENV, }, ]); @@ -92,6 +97,7 @@ export const Launch: React.FC = () => { resolver, defaultValues: { project: searchParams.get('project_name') ?? undefined, + template: searchParams.getAll('template'), }, }); @@ -124,7 +130,8 @@ export const Launch: React.FC = () => { }, [templatesData]); useEffect(() => { - if (!defaultProject && projectOptions?.[0]?.value) { + // A project passed in the URL takes precedence over the first one. + if (!defaultProject && !getValues(FORM_FIELD_NAMES.project) && projectOptions?.[0]?.value) { setValue(FORM_FIELD_NAMES.project, projectOptions[0].value); setDefaultProject(projectOptions[0].value); } @@ -134,10 +141,44 @@ export const Launch: React.FC = () => { setSelectedTemplate(templatesData?.find((t) => t.name === formValues.template?.[0])); }, [templatesData, formValues.template]); + // `step=resources` (from Next on the Launch page) skips the template step once the template is loaded. + const templateStepSkipped = useRef(false); + + useEffect(() => { + if ( + templateStepSkipped.current || + searchParams.get('step') !== 'resources' || + !selectedTemplate || + selectedTemplate.name !== searchParams.get('template') + ) { + return; + } + + templateStepSkipped.current = true; + setActiveStepIndex(1); + }, [selectedTemplate]); + + // Offer filters from the Launch page, e.g. carried over from the Offers page. + const urlOfferFilters = useMemo(() => { + const filters = { ...getOfferFilters(searchParams) }; + // The project comes from the wizard's own field. + delete filters.project_name; + 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; + const memoryGb = typeof memory === 'string' && !memory.includes('..') ? parseFloat(memory) : NaN; + return Array.isArray(names) && names.length === 1 && memoryGb ? { name: names[0], memoryGb } : undefined; + }, [urlOfferFilters]); + + const hasGpuFilters = Object.keys(urlOfferFilters).some((key) => key.startsWith('gpu_')); + useEffect(() => { setSelectedOffers([]); setValue(FORM_FIELD_NAMES.offer, undefined); - setValue(FORM_FIELD_NAMES.gpu_enabled, hasConfiguredGpu(selectedTemplate)); + setValue(FORM_FIELD_NAMES.gpu_enabled, hasConfiguredGpu(selectedTemplate) || hasGpuFilters); }, [selectedTemplate, setValue]); const validateProjectAndTemplate = async () => await trigger(templateStepFieldNames); @@ -186,12 +227,13 @@ export const Launch: React.FC = () => { unregister(FORM_FIELD_NAMES.ide); }; - const onChangeOffer: CardsProps['onSelectionChange'] = ({ detail }) => { - const newSelectedOffers = detail?.selectedItems ?? []; - setSelectedOffers(newSelectedOffers); - setValue(FORM_FIELD_NAMES.offer, newSelectedOffers?.[0] ?? undefined); + const selectOffers = (offers: IGpu[]) => { + setSelectedOffers(offers); + setValue(FORM_FIELD_NAMES.offer, offers[0] ?? undefined); }; + const onChangeOffer: CardsProps['onSelectionChange'] = ({ detail }) => selectOffers(detail?.selectedItems ?? []); + const onSubmitWizard = async () => { const isValid = await trigger(); @@ -255,8 +297,9 @@ export const Launch: React.FC = () => { () => ({ spot_policy: 'on-demand', ...configuredOfferFilters, + ...urlOfferFilters, }), - [configuredOfferFilters], + [configuredOfferFilters, urlOfferFilters], ); const yaml = useGenerateYaml({ formValues, @@ -376,6 +419,8 @@ export const Launch: React.FC = () => { showFleetFilter selectedItems={selectedOffers} onSelectionChange={onChangeOffer} + preselectedGpu={preselectedGpu} + onPreselect={(gpu) => selectOffers([gpu])} onChangeBackendFilter={setSelectedBackends} onChangeFleetFilter={setSelectedFleets} permanentFilters={{ project_name: formValues.project ?? '' }} diff --git a/frontend/src/pages/Runs/LaunchOptions/constants.tsx b/frontend/src/pages/Runs/LaunchOptions/constants.tsx new file mode 100644 index 0000000000..49700d2d76 --- /dev/null +++ b/frontend/src/pages/Runs/LaunchOptions/constants.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import { Link, NavigateLink } from 'components'; + +import { DOCS_URL, QUICK_START_URL } from 'consts'; +import { ROUTES } from 'routes'; + +export const CLI_INFO = { + header:

Run with the CLI

, + body: ( + <> +

+ A run is described by a configuration in a .dstack.yml file and submitted with{' '} + dstack apply. +

+
    +
  • + + Dev environments + {' '} + let you provision an instance and access it with your desktop IDE or SSH. +
  • +
  • + + Tasks + {' '} + run arbitrary commands on one or more nodes, such as training or batch processing. +
  • +
  • + + Services + {' '} + deploy models or web apps as secure and scalable endpoints. +
  • +
+

+ For a step-by-step walkthrough, see the{' '} + + Quickstart + + . +

+ + ), +}; + +export const getTemplatesInfo = (projectName: string) => ({ + header:

From a template

, + body: ( + <> +

+ A template is a run configuration with a form. Pick a template, choose resources, and fill in its settings; the + last step shows the resulting configuration before you launch it. +

+

+ Templates come from a Git repository: each is a YAML file in its .dstack/templates folder. A + project admin sets the repository in the{' '} + + project settings + + ; without one, the server's default is used. +

+

+ See examples in{' '} + + dstackai/dstack-templates + + . +

+ + ), +}); + +export const AGENTS_INFO = { + header:

Run with agents

, + body: ( + <> +

+ With{' '} + + dstack skills + {' '} + installed, AI agents like Claude, Codex, and Cursor work with dstack on your behalf: they define configurations, + run them, and manage dstack through the CLI, from fleets and volumes to runs and their logs. +

+

+ See{' '} + + Installation + {' '} + for details. +

+ + ), +}; diff --git a/frontend/src/pages/Runs/LaunchOptions/index.tsx b/frontend/src/pages/Runs/LaunchOptions/index.tsx new file mode 100644 index 0000000000..a4687d6909 --- /dev/null +++ b/frontend/src/pages/Runs/LaunchOptions/index.tsx @@ -0,0 +1,391 @@ +import React, { FC, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { product } from 'product'; + +import { + Box, + Button, + Cards, + Code, + Container, + ContentLayout, + FormField, + Header, + InfoLink, + Popover, + PropertyFilter, + SpaceBetween, + Spinner, + StatusIndicator, +} from 'components'; + +import { QUICK_START_URL } from 'consts'; +import { useBreadcrumbs, useHelpPanel } from 'hooks'; +import { copyToClipboard } from 'libs'; +import { tokensToSearchParams } from 'libs/filters'; +import { ROUTES } from 'routes'; +import { useLazyGetGpusListQuery } from 'services/gpu'; +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 { useCheckAvailableProjectPermission } from 'pages/Project/hooks/useCheckAvailableProjectPermission'; +import { useConfigProjectCliCommand } from 'pages/Project/hooks/useConfigProjectCliComand'; + +import { AGENTS_INFO, CLI_INFO, getTemplatesInfo } from './constants'; + +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; +// Matches the breakpoint in styles.module.scss where the columns stack. +const STACKED_COLUMNS_QUERY = '(max-width: 960px)'; + +export const LaunchOptions: FC = () => { + const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + const [gpus, setGpus] = useState([]); + const [getGpus] = useLazyGetGpusListQuery(); + + useBreadcrumbs([ + { + text: t('projects.runs'), + href: ROUTES.RUNS.LIST, + }, + { + text: t('runs.launch_page.title'), + href: ROUTES.RUNS.LAUNCH, + }, + ]); + + // The same filter as on Offers; GPU names and backends load only when their list is opened. + const [initialFilters] = useState(() => getOfferFilters(searchParams)); + + const loadGpus = async (projectName: string) => { + const data = await getGpus(getRequestParams({ project_name: projectName, group_by: ['gpu'] })).unwrap(); + setGpus(data.gpus); + }; + + const { + filteringRequestParams, + propertyFilterRef, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, + filteringStatusType, + handleLoadItems, + } = useFilters({ gpus, withSearchParams: false, showFleetFilter: true, defaultFilters: initialFilters, loadGpus }); + + // The filter owns the URL, apart from the selected template. + useEffect(() => { + setSearchParams( + (params) => { + const next = tokensToSearchParams(propertyFilterQuery.tokens); + const template = params.get('template'); + + if (template) { + next.set('template', template); + } + + return next; + }, + { replace: true }, + ); + }, [propertyFilterQuery]); + + const projectName = + typeof filteringRequestParams.project_name === 'string' ? filteringRequestParams.project_name : undefined; + + const { currentData: templates, isFetching: isFetchingTemplates } = useGetAllTemplatesQuery( + { projectName: projectName ?? '' }, + { skip: !product.hasTemplates || !projectName }, + ); + + const renderWaysToLaunch = () => { + if (!projectName) { + return {t('runs.launch_page.select_project')}; + } + + // Templates are an Enterprise feature; without them, the CLI is the only way to launch. + if (!product.hasTemplates) { + return ; + } + + // Waiting for the templates keeps the layout from switching once they arrive. + if (isFetchingTemplates) { + return ; + } + + return ; + }; + + return ( + {t('runs.launch_page.title')}}> + +
+ +
+ + {renderWaysToLaunch()} +
+
+ ); +}; + +// Reaches the window's bottom; extra height goes to the CLI card and the template cards. +const Columns: FC<{ projectName: string; templates: ITemplate[] }> = ({ projectName, templates }) => { + const columnsRef = useRef(null); + const minHeight = useHeightToWindowBottom(columnsRef); + + return ( +
+ + + {/* Fills the row, so the template cards scroll inside it. */} +
+
+ +
+
+
+ ); +}; + +const CliColumn: FC<{ projectName: string }> = ({ projectName }) => ( +
+
+ +
+ +
+); + +const CliPanel: FC<{ projectName: string }> = ({ projectName }) => { + const { t } = useTranslation(); + const [openHelpPanel] = useHelpPanel(); + const [configCliCommand] = useConfigProjectCliCommand({ projectName }); + + return ( + openHelpPanel(CLI_INFO)} />} + actions={ + + } + > + {t('runs.launch_page.cli_title')} + + } + > + + + + + + + + + + openHelpPanel(CLI_INFO)} />} + > + + }} /> + + + + + + + + + ); +}; + +const AgentsPanel: FC = () => { + const { t } = useTranslation(); + const [openHelpPanel] = useHelpPanel(); + + return ( + }} /> + } + info={ openHelpPanel(AGENTS_INFO)} />} + > + {t('runs.launch_page.agents_title')} + + } + > + + + ); +}; + +const TemplatePanel: FC<{ projectName: string; templates: ITemplate[] }> = ({ projectName, templates }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [openHelpPanel] = useHelpPanel(); + const { isProjectAdmin } = useCheckAvailableProjectPermission(); + const { data: project } = useGetProjectQuery({ name: projectName }, { skip: templates.length > 0 }); + const isAdmin = !!project && isProjectAdmin(project); + + // The selected template lives in the URL, like the project, so a refresh or a shared link keeps it. + const [searchParams, setSearchParams] = useSearchParams(); + const selectedTemplate = templates.find(({ name }) => name === searchParams.get('template')); + + // Keeps the other params, such as a GPU picked on the Offers page. + const selectTemplate = (template?: ITemplate) => { + setSearchParams( + (params) => { + if (template) { + params.set('template', template.name); + } else { + params.delete('template'); + } + return params; + }, + { replace: true }, + ); + }; + + // The wizard starts at Resources, with the project, the template and any GPU picked on the Offers page. + const openWizard = () => { + const params = new URLSearchParams(searchParams); + params.set('project_name', projectName); + params.set('step', 'resources'); + navigate(`${ROUTES.RUNS.CREATE_DEV_ENV}?${params}`); + }; + + const renderNoTemplates = () => ( + + + {t('runs.no_templates_alert.title')} + {t('runs.launch_page.no_templates_text')} + + + {isAdmin && ( + + )} + + ); + + return ( + openHelpPanel(getTemplatesInfo(projectName))} />} + actions={ + templates.length > 0 && ( + // Hidden, not removed, until a template is selected, so the header keeps its height. + + + + ) + } + > + {t('runs.launch_page.template_title')} + + } + > + {templates.length ? ( + selectTemplate(detail.selectedItems[0])} + cardsPerRow={[{ cards: 1 }, { minWidth: 400, cards: 2 }, { minWidth: 700, cards: 3 }]} + cardDefinition={{ + header: (template) => template.title, + sections: [{ id: 'description', content: (template) => template.description ?? '' }], + }} + /> + ) : ( + renderNoTemplates() + )} + + ); +}; + +// From the element's top to the window's bottom; none when the columns stack. +const useHeightToWindowBottom = (ref: React.RefObject): number | undefined => { + const [height, setHeight] = useState(); + + useLayoutEffect(() => { + const update = () => { + if (!ref.current || window.matchMedia(STACKED_COLUMNS_QUERY).matches) { + setHeight(undefined); + return; + } + + const top = ref.current.getBoundingClientRect().top + window.scrollY; + setHeight(window.innerHeight - top - PAGE_BOTTOM_PADDING); + }; + + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, []); + + return height; +}; + +const CopyableCode: FC<{ command: string }> = ({ command }) => { + const { t } = useTranslation(); + + return ( +
+ {command} + +
+ {t('common.copied')}} + > +
+
+ ); +}; diff --git a/frontend/src/pages/Runs/LaunchOptions/styles.module.scss b/frontend/src/pages/Runs/LaunchOptions/styles.module.scss new file mode 100644 index 0000000000..ea4f608bf4 --- /dev/null +++ b/frontend/src/pages/Runs/LaunchOptions/styles.module.scss @@ -0,0 +1,62 @@ +.filter { + max-width: 640px; +} + +.columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +// Not visibility: Cloudscape buttons set visibility: visible on themselves. +.hidden { + opacity: 0; + pointer-events: none; +} + +.cliColumn { + display: flex; + flex-direction: column; + gap: 20px; +} + +.cliFill { + flex: 1 1 auto; +} + +.templatesCell { + position: relative; +} + +.templatesFill { + position: absolute; + inset: 0; +} + +// Stacked on narrow screens, where the templates take their full height and the page scrolls. +@media (max-width: 960px) { + .columns { + grid-template-columns: minmax(0, 1fr); + } + + .templatesFill { + position: static; + } +} + +.codeWrapper { + position: relative; + + // Commands scroll instead of wrapping. + .code { + padding: 12px 48px 12px 12px; + white-space: pre; + overflow-x: auto; + } + + .copy { + position: absolute; + top: 6px; + right: 6px; + } +} diff --git a/frontend/src/pages/Runs/List/hooks/useEmptyMessages.tsx b/frontend/src/pages/Runs/List/hooks/useEmptyMessages.tsx index d495f03976..2691d027f6 100644 --- a/frontend/src/pages/Runs/List/hooks/useEmptyMessages.tsx +++ b/frontend/src/pages/Runs/List/hooks/useEmptyMessages.tsx @@ -8,23 +8,23 @@ import { goToUrl } from 'libs'; export const useEmptyMessages = ({ clearFilter, - noData, + hasNoRuns, isDisabledClearFilter, }: { clearFilter?: () => void; - noData?: boolean; + hasNoRuns?: boolean; isDisabledClearFilter?: boolean; }) => { const { t } = useTranslation(); const renderEmptyMessage = useCallback<() => React.ReactNode>(() => { - if (noData && isDisabledClearFilter) { + if (hasNoRuns) { return ( - @@ -38,7 +38,7 @@ export const useEmptyMessages = ({ ); - }, [isDisabledClearFilter, clearFilter]); + }, [hasNoRuns, isDisabledClearFilter, clearFilter]); const renderNoMatchMessage = useCallback<() => React.ReactNode>(() => { return ( diff --git a/frontend/src/pages/Runs/List/index.tsx b/frontend/src/pages/Runs/List/index.tsx index 631eb07c82..0cea0c7afc 100644 --- a/frontend/src/pages/Runs/List/index.tsx +++ b/frontend/src/pages/Runs/List/index.tsx @@ -8,7 +8,7 @@ import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProjectsOfMember'; import { ROUTES } from 'routes'; -import { useLazyGetRunsQuery } from 'services/run'; +import { useGetRunsQuery, useLazyGetRunsQuery } from 'services/run'; import { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAlert'; @@ -67,10 +67,20 @@ export const RunList: React.FC = () => { const { columns } = useColumnsDefinitions(); + const hasNoOtherFilters = Object.keys(filteringRequestParams).length <= 1; + const isDisabledClearFilter = hasNoOtherFilters && !filteringRequestParams.only_active; + const isOnlyActiveFilter = hasNoOtherFilters && !!filteringRequestParams.only_active; + + // "Active runs" is on by default, so an empty list under it alone doesn't say whether the user has any runs. + const { data: anyRuns } = useGetRunsQuery( + { limit: 1, only_active: false, job_submissions_limit: 0 }, + { skip: !isOnlyActiveFilter || isLoading || data.length > 0 }, + ); + const { renderEmptyMessage, renderNoMatchMessage } = useEmptyMessages({ clearFilter, - noData: !data.length, - isDisabledClearFilter: Object.keys(filteringRequestParams).length <= 1 && !filteringRequestParams.only_active, + hasNoRuns: !data.length && (isDisabledClearFilter || (isOnlyActiveFilter && anyRuns?.length === 0)), + isDisabledClearFilter, }); const { items, actions, collectionProps } = useCollection(data ?? [], { @@ -113,12 +123,14 @@ export const RunList: React.FC = () => { // }; const launchHandle = () => { + const projectName = filteringRequestParams.project_name; navigate( - `${ROUTES.RUNS.CREATE_DEV_ENV}${ - filteringRequestParams.project_name ? `?project_name=${filteringRequestParams.project_name}` : '' - }`, + typeof projectName === 'string' + ? `${ROUTES.RUNS.LAUNCH}?${new URLSearchParams({ project_name: projectName })}` + : ROUTES.RUNS.LAUNCH, ); }; + const projectDontHasFleet = Object.keys(projectHavingFleetMap).find((project) => !projectHavingFleetMap[project]); return ( @@ -145,7 +157,9 @@ export const RunList: React.FC = () => { variant="awsui-h1-sticky" actions={ - +