['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={
- {t('runs.launch_button')}
+
+ {t('runs.launch_button')}
+
{t('common.abort')}
diff --git a/frontend/src/pages/Runs/index.ts b/frontend/src/pages/Runs/index.ts
index ee5fe86c30..0b173115f4 100644
--- a/frontend/src/pages/Runs/index.ts
+++ b/frontend/src/pages/Runs/index.ts
@@ -6,3 +6,4 @@ export { EventsList } from './Details/Events/List';
export { JobLogs } from './Details/Logs';
export { Artifacts } from './Details/Artifacts';
export { Launch } from './Launch';
+export { LaunchOptions } from './LaunchOptions';
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index 0617436182..d0a66b57b4 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -27,7 +27,16 @@ import { ModelDetails } from 'pages/Models/Details';
import { CreateProjectWizard, ProjectAdd, ProjectDetails, ProjectEvents, ProjectList, ProjectSettings } from 'pages/Project';
import { BackendAdd, BackendEdit } from 'pages/Project/Backends';
import { AddGateway, EditGateway } from 'pages/Project/Gateways';
-import { EventsList as RunEvents, JobLogs, JobMetrics, Launch, RunDetails, RunDetailsPage, RunList } from 'pages/Runs';
+import {
+ EventsList as RunEvents,
+ JobLogs,
+ JobMetrics,
+ Launch,
+ LaunchOptions,
+ RunDetails,
+ RunDetailsPage,
+ RunList,
+} from 'pages/Runs';
import { RunInspect } from 'pages/Runs/Details/Inspect';
import { JobDetailsPage } from 'pages/Runs/Details/Jobs/Details';
import { EventsList as JobEvents } from 'pages/Runs/Details/Jobs/Events';
@@ -201,10 +210,12 @@ export const router = createBrowserRouter([
},
{
- path: ROUTES.RUNS.CREATE_DEV_ENV,
- element: ,
+ path: ROUTES.RUNS.LAUNCH,
+ element: ,
},
+ ...(product.hasTemplates ? [{ path: ROUTES.RUNS.CREATE_DEV_ENV, element: }] : []),
+
// Offers
{
path: ROUTES.OFFERS.LIST,
diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts
index 613869acc3..7cfd9bb92a 100644
--- a/frontend/src/routes.ts
+++ b/frontend/src/routes.ts
@@ -124,7 +124,8 @@ export const ROUTES = {
RUNS: {
LIST: '/runs',
- CREATE_DEV_ENV: '/runs/launch',
+ LAUNCH: '/runs/launch',
+ CREATE_DEV_ENV: '/runs/launch/template',
},
OFFERS: {
diff --git a/frontend/src/services/gpu.ts b/frontend/src/services/gpu.ts
index 9050d19be2..92599ff95d 100644
--- a/frontend/src/services/gpu.ts
+++ b/frontend/src/services/gpu.ts
@@ -30,4 +30,4 @@ export const gpuApi = createApi({
}),
});
-export const { useGetGpusListQuery } = gpuApi;
+export const { useGetGpusListQuery, useLazyGetGpusListQuery } = gpuApi;