From d80820485c579735896c17a39ee562f38a4c8926 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Sun, 7 Sep 2025 21:54:09 +0300 Subject: [PATCH 1/8] [UI] Project wizard https://github.com/dstackai/dstack-cloud/issues/323 --- frontend/package-lock.json | 16 +- frontend/package.json | 3 +- frontend/src/components/index.ts | 1 + frontend/src/locale/en.json | 17 + .../pages/Project/CreateWizard/constants.ts | 22 ++ .../src/pages/Project/CreateWizard/index.tsx | 362 ++++++++++++++++++ .../Project/CreateWizard/styles.module.scss | 7 + .../src/pages/Project/CreateWizard/types.ts | 8 + frontend/src/pages/Project/index.tsx | 1 + frontend/src/router.tsx | 17 +- 10 files changed, 444 insertions(+), 10 deletions(-) create mode 100644 frontend/src/pages/Project/CreateWizard/constants.ts create mode 100644 frontend/src/pages/Project/CreateWizard/index.tsx create mode 100644 frontend/src/pages/Project/CreateWizard/styles.module.scss create mode 100644 frontend/src/pages/Project/CreateWizard/types.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0317cc74f..74c222b6a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@cloudscape-design/global-styles": "^1.0.33", "@hookform/resolvers": "^2.9.10", "@reduxjs/toolkit": "^1.9.1", + "@types/yup": "^0.29.14", "ace-builds": "^1.36.3", "classnames": "^2.5.1", "css-minimizer-webpack-plugin": "^4.2.2", @@ -23,7 +24,7 @@ "i18next": "^24.0.2", "lodash": "^4.17.21", "openai": "^4.33.1", - "prismjs": "^1.29.0", + "prismjs": "^1.30.0", "rc-tooltip": "^5.2.2", "react": "^18.3.1", "react-avatar": "^5.0.3", @@ -5204,6 +5205,12 @@ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" }, + "node_modules/@types/yup": { + "version": "0.29.14", + "resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.29.14.tgz", + "integrity": "sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.33.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.1.tgz", @@ -20349,9 +20356,10 @@ "peer": true }, "node_modules/prismjs": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", - "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", "engines": { "node": ">=6" } diff --git a/frontend/package.json b/frontend/package.json index f3b706924..f07117bcf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -103,6 +103,7 @@ "@cloudscape-design/global-styles": "^1.0.33", "@hookform/resolvers": "^2.9.10", "@reduxjs/toolkit": "^1.9.1", + "@types/yup": "^0.29.14", "ace-builds": "^1.36.3", "classnames": "^2.5.1", "css-minimizer-webpack-plugin": "^4.2.2", @@ -110,7 +111,7 @@ "i18next": "^24.0.2", "lodash": "^4.17.21", "openai": "^4.33.1", - "prismjs": "^1.29.0", + "prismjs": "^1.30.0", "rc-tooltip": "^5.2.2", "react": "^18.3.1", "react-avatar": "^5.0.3", diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index f68297f1d..b109147ad 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -56,6 +56,7 @@ export { default as PropertyFilter } from '@cloudscape-design/components/propert export type { PropertyFilterProps } from '@cloudscape-design/components/property-filter'; export type { LineChartProps } from '@cloudscape-design/components/line-chart/interfaces'; export type { ModalProps } from '@cloudscape-design/components/modal'; +export { default as Wizard } from '@cloudscape-design/components/wizard'; // custom components export { NavigateLink } from './NavigateLink'; diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 7c40a16bd..1c1abbd98 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -10,6 +10,8 @@ "delete": "Delete", "remove": "Remove", "apply": "Apply", + "next": "Next", + "previous": "Previous", "settings": "Settings", "match_count_with_value_one": "{{count}} match", "match_count_with_value_other": "{{count}} matches", @@ -187,11 +189,26 @@ "backend": "Backend", "settings": "Settings" }, + "wizard": { + "submit": "Create project" + }, "edit": { "general": "General", "project_name": "Project name", "owner": "Owner", "project_name_description": "Only latin characters, dashes, underscores, and digits", + "project_type": "Project type", + "project_type_description": "You can choose one of project types", + "backends": "Backends", + "backends_description": "You can choose one or more of backend types", + "default_fleet": "Create default fleet", + "default_fleet_description": "You can create default fleet for project", + "fleet_name": "Fleet name", + "fleet_name_description": "Only latin characters, dashes, underscores, and digits", + "fleet_min_instances": "Min number of instances", + "fleet_min_instances_description": "Only digits", + "fleet_max_instances": "Max number of instances", + "fleet_max_instances_description": "Only digits", "is_public": "Make project public", "is_public_description": "Public projects can be accessed by any user without being a member", "backend": "Backend", diff --git a/frontend/src/pages/Project/CreateWizard/constants.ts b/frontend/src/pages/Project/CreateWizard/constants.ts new file mode 100644 index 000000000..9dcc3090f --- /dev/null +++ b/frontend/src/pages/Project/CreateWizard/constants.ts @@ -0,0 +1,22 @@ +import { FormMultiselectOptions } from 'components'; + +export const backendOptions: FormMultiselectOptions = [ + { label: 'aws', value: 'aws' }, + { label: 'azure', value: 'azure' }, + { label: 'cudo', value: 'cudo' }, + { label: 'datacrunch', value: 'datacrunch' }, + { label: 'dstack', value: 'dstack' }, + { label: 'gcp', value: 'gcp' }, + { label: 'kubernetes', value: 'kubernetes' }, + { label: 'lambda', value: 'lambda' }, + { label: 'local', value: 'local' }, + { label: 'nebius', value: 'nebius' }, + { label: 'remote', value: 'remote' }, + { label: 'oci', value: 'oci' }, + { label: 'runpod', value: 'runpod' }, + { label: 'tensordock', value: 'tensordock' }, + { label: 'vastai', value: 'vastai' }, + { label: 'cloudrift', value: 'cloudrift' }, + { label: 'hotaisle', value: 'hotaisle' }, + { label: 'vultr', value: 'vultr' }, +]; diff --git a/frontend/src/pages/Project/CreateWizard/index.tsx b/frontend/src/pages/Project/CreateWizard/index.tsx new file mode 100644 index 000000000..24b07df57 --- /dev/null +++ b/frontend/src/pages/Project/CreateWizard/index.tsx @@ -0,0 +1,362 @@ +import React, { useCallback, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import * as yup from 'yup'; +import { WizardProps } from '@cloudscape-design/components'; + +import { + Box, + Container, + FormCheckbox, + FormField, + FormInput, + FormMultiselect, + FormTiles, + SpaceBetween, + StatusIndicator, + Wizard, +} from 'components'; + +import { useBreadcrumbs, useNotifications } from 'hooks'; +import { ROUTES } from 'routes'; + +import { getServerError } from '../../../libs'; +import { useCreateProjectMutation } from '../../../services/project'; +import { backendOptions } from './constants'; + +import { IProjectWizardForm } from './types'; + +import styles from './styles.module.scss'; + +const requiredFieldError = 'This is required field'; +const namesFieldError = 'Only latin characters, dashes, underscores, and digits'; +const numberFieldError = 'This is number field'; + +const projectValidationSchema = yup.object({ + project_name: yup + .string() + .required(requiredFieldError) + .matches(/^[a-zA-Z0-9-_]+$/, namesFieldError), + project_type: yup.string().required(requiredFieldError), + backends: yup.array().when('project_type', { + is: 'gpu_marketplace', + then: yup.array().required(requiredFieldError), + }), + fleet_name: yup.string().when('enable_fleet', { + is: true, + then: yup + .string() + .required(requiredFieldError) + .matches(/^[a-zA-Z0-9-_]+$/, namesFieldError), + }), + fleet_min_instances: yup.number().when('enable_fleet', { + is: true, + then: yup + .number() + .required(requiredFieldError) + .typeError(numberFieldError) + .min(1) + .test('is-smaller-than-man', 'The minimum value must be less than the maximum value.', (value, context) => { + const { fleet_max_instances } = context.parent; + if (typeof fleet_max_instances !== 'number' || typeof value !== 'number') return true; + return value <= fleet_max_instances; + }), + }), + fleet_max_instances: yup.number().when('enable_fleet', { + is: true, + then: yup + .number() + .required(requiredFieldError) + .typeError(numberFieldError) + .min(1) + .test('is-greater-than-min', 'The maximum value must be greater than the minimum value', (value, context) => { + const { fleet_min_instances } = context.parent; + if (typeof fleet_min_instances !== 'number' || typeof value !== 'number') return true; + return value >= fleet_min_instances; + }), + }), +}); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +const useYupValidationResolver = (validationSchema) => + useCallback( + async (data: IProjectWizardForm) => { + try { + const values = await validationSchema.validate(data, { + abortEarly: false, + }); + + return { + values, + errors: {}, + }; + } catch (errors) { + return { + values: {}, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + errors: errors.inner.reduce( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + (allErrors, currentError) => ({ + ...allErrors, + [currentError.path]: { + type: currentError.type ?? 'validation', + message: currentError.message, + }, + }), + {}, + ), + }; + } + }, + [validationSchema], + ); + +export const CreateProjectWizard: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [pushNotification] = useNotifications(); + const [activeStepIndex, setActiveStepIndex] = useState(0); + const [createProject, { isLoading }] = useCreateProjectMutation(); + + const loading = isLoading; + + useBreadcrumbs([ + { + text: t('navigation.project_other'), + href: ROUTES.PROJECT.LIST, + }, + { + text: t('common.create', { text: t('navigation.project') }), + href: ROUTES.PROJECT.ADD, + }, + ]); + + const resolver = useYupValidationResolver(projectValidationSchema); + const formMethods = useForm({ + resolver, + defaultValues: { enable_fleet: true, fleet_min_instances: 0 }, + }); + const { handleSubmit, control, watch, trigger, formState, getValues } = formMethods; + const projectType = watch('project_type'); + const isEnabledFleet = watch('enable_fleet'); + + const onCancelHandler = () => { + navigate(ROUTES.PROJECT.LIST); + }; + + const onSubmit = (data: IProjectWizardForm) => { + console.log(data); + }; + + const validateFirstStep = async () => { + return await trigger(['project_type', 'project_name']); + }; + + const validateSecondStep = async () => { + if (projectType === 'gpu_marketplace') { + return await trigger(['backends']); + } + + return Promise.resolve(true); + }; + + const emptyValidator = async () => Promise.resolve(true); + + const onNavigate: WizardProps['onNavigate'] = ({ detail }) => { + const stepValidators = [validateFirstStep, validateSecondStep, emptyValidator]; + + if (detail.requestedStepIndex > activeStepIndex) { + stepValidators[activeStepIndex]?.().then((isValid) => { + if (isValid) { + setActiveStepIndex(detail.requestedStepIndex); + } + }); + } else { + setActiveStepIndex(detail.requestedStepIndex); + } + }; + + const onSubmitWizard = async () => { + const isValid = await trigger(); + + if (!isValid) { + return; + } + + const { project_name } = getValues(); + + const request = createProject({ project_name } as IProject).unwrap(); + + request + .then((data) => { + pushNotification({ + type: 'success', + content: t('projects.create.success_notification'), + }); + + navigate(ROUTES.PROJECT.DETAILS.SETTINGS.FORMAT(data.project_name)); + }) + .catch((error) => { + pushNotification({ + type: 'error', + content: t('common.server_error', { error: getServerError(error) }), + }); + }); + }; + + return ( +
+ `Step ${stepNumber}`, + navigationAriaLabel: 'Steps', + cancelButton: t('common.cancel'), + previousButton: t('common.previous'), + nextButton: t('common.next'), + optional: 'optional', + }} + onCancel={onCancelHandler} + submitButtonText={t('projects.wizard.submit')} + steps={[ + { + title: 'Project name and type', + content: ( + + + + +
+ + + +
+
+
+ ), + }, + { + title: 'Backends', + content: ( + + {projectType === 'gpu_marketplace' && ( + + )} + + {projectType === 'own_cloud' && ( +
+ + You will be able to configure own cloud after + creating project + +
+ )} +
+ ), + }, + { + title: 'Fleets', + content: ( + + + + + {isEnabledFleet && ( + <> + +
+ To create dev environments, submit tasks, or + run services, you need at least one fleet. +
+ +
+ It's recommended to create it now, or you + can set it up manually later. +
+ +
+ + Don't worry, creating a fleet doesn’t necessarily create cloud instances. +
+
+ + + + + + + + )} +
+
+ ), + }, + ]} + /> + + ); +}; diff --git a/frontend/src/pages/Project/CreateWizard/styles.module.scss b/frontend/src/pages/Project/CreateWizard/styles.module.scss new file mode 100644 index 000000000..443af817e --- /dev/null +++ b/frontend/src/pages/Project/CreateWizard/styles.module.scss @@ -0,0 +1,7 @@ +.ownCloudInfo { + display: flex; + align-items: center; + justify-content: center; + padding-top: 40px; + padding-bottom: 40px; +} \ No newline at end of file diff --git a/frontend/src/pages/Project/CreateWizard/types.ts b/frontend/src/pages/Project/CreateWizard/types.ts new file mode 100644 index 000000000..6244cb861 --- /dev/null +++ b/frontend/src/pages/Project/CreateWizard/types.ts @@ -0,0 +1,8 @@ +export interface IProjectWizardForm extends Pick { + project_type: 'gpu_marketplace' | 'own_cloud'; + backends: TBackendType[]; + enable_fleet?: boolean; + fleet_name?: string; + fleet_min_instances?: number; + fleet_max_instances?: string; +} diff --git a/frontend/src/pages/Project/index.tsx b/frontend/src/pages/Project/index.tsx index b90526365..a7bdc1617 100644 --- a/frontend/src/pages/Project/index.tsx +++ b/frontend/src/pages/Project/index.tsx @@ -3,6 +3,7 @@ export { ProjectList } from './List'; export { ProjectDetails } from './Details'; export { ProjectSettings } from './Details/Settings'; export { ProjectAdd } from './Add'; +export { CreateProjectWizard } from './CreateWizard'; export const Project: React.FC = () => { return null; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 809b08688..13798ca73 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -14,7 +14,7 @@ import { FleetDetails, FleetList } from 'pages/Fleets'; import { InstanceList } from 'pages/Instances'; import { ModelsList } from 'pages/Models'; import { ModelDetails } from 'pages/Models/Details'; -import { ProjectAdd, ProjectDetails, ProjectList, ProjectSettings } from 'pages/Project'; +import { CreateProjectWizard, ProjectAdd, ProjectDetails, ProjectList, ProjectSettings } from 'pages/Project'; import { BackendAdd, BackendEdit } from 'pages/Project/Backends'; import { AddGateway, EditGateway } from 'pages/Project/Gateways'; import { JobLogs, JobMetrics, RunDetails, RunDetailsPage, RunList } from 'pages/Runs'; @@ -126,10 +126,17 @@ export const router = createBrowserRouter([ }, ], }, - { - path: ROUTES.PROJECT.ADD, - element: , - }, + + ...([ + process.env.UI_VERSION !== 'sky' && { + path: ROUTES.PROJECT.ADD, + element: , + }, + process.env.UI_VERSION === 'sky' && { + path: ROUTES.PROJECT.ADD, + element: , + }, + ].filter(Boolean) as RouteObject[]), // Runs { From b0ea0193d4a867bbaa1d7b2247bbb0f204c3aaa7 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Mon, 15 Sep 2025 21:55:47 +0300 Subject: [PATCH 2/8] [UI] Project wizard #323 --- frontend/src/api.ts | 2 + frontend/src/components/form/Cards/index.tsx | 38 ++ frontend/src/components/form/Cards/types.ts | 7 + frontend/src/components/index.ts | 3 + .../pages/Project/CreateWizard/constants.ts | 33 +- .../src/pages/Project/CreateWizard/index.tsx | 415 ++++++++++++------ frontend/src/services/backend.ts | 7 + frontend/src/services/project.ts | 15 +- frontend/src/types/project.d.ts | 9 + 9 files changed, 361 insertions(+), 168 deletions(-) create mode 100644 frontend/src/components/form/Cards/index.tsx create mode 100644 frontend/src/components/form/Cards/types.ts diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 661f72ef5..c1e453c11 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -58,6 +58,7 @@ export const API = { BASE: () => `${API.BASE()}/projects`, LIST: () => `${API.PROJECTS.BASE()}/list`, CREATE: () => `${API.PROJECTS.BASE()}/create`, + CREATE_WIZARD: () => `${API.PROJECTS.BASE()}/create_wizard`, DELETE: () => `${API.PROJECTS.BASE()}/delete`, DETAILS: (name: IProject['project_name']) => `${API.PROJECTS.BASE()}/${name}`, DETAILS_INFO: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/get`, @@ -112,6 +113,7 @@ export const API = { BACKENDS: { BASE: () => `${API.BASE()}/backends`, LIST_TYPES: () => `${API.BACKENDS.BASE()}/list_types`, + LIST_BASE_TYPES: () => `${API.BACKENDS.BASE()}/list_base_types`, CONFIG_VALUES: () => `${API.BACKENDS.BASE()}/config_values`, }, diff --git a/frontend/src/components/form/Cards/index.tsx b/frontend/src/components/form/Cards/index.tsx new file mode 100644 index 000000000..17b12f193 --- /dev/null +++ b/frontend/src/components/form/Cards/index.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Controller, FieldValues } from 'react-hook-form'; +import Cards from '@cloudscape-design/components/cards'; +import { CardsProps } from '@cloudscape-design/components/cards'; + +import { FormCardsProps } from './types'; + +export const FormCards = ({ + name, + control, + onSelectionChange: onSelectionChangeProp, + ...props +}: FormCardsProps) => { + return ( + { + const onSelectionChange: CardsProps['onSelectionChange'] = (event) => { + onChange(event.detail.selectedItems.map(({ value }) => value)); + onSelectionChangeProp?.(event); + }; + + const selectedItems = props.items.filter((item) => fieldRest.value?.includes(item.value)); + + return ( + + ); + }} + /> + ); +}; diff --git a/frontend/src/components/form/Cards/types.ts b/frontend/src/components/form/Cards/types.ts new file mode 100644 index 000000000..857ac77a5 --- /dev/null +++ b/frontend/src/components/form/Cards/types.ts @@ -0,0 +1,7 @@ +import { Control, FieldValues, Path } from 'react-hook-form'; +import { CardsProps } from '@cloudscape-design/components/cards'; + +export type FormCardsProps = CardsProps & { + control: Control; + name: Path; +}; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 1537704bf..f69a5589f 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -61,6 +61,7 @@ export type { LineChartProps } from '@cloudscape-design/components/line-chart/in export type { ModalProps } from '@cloudscape-design/components/modal'; export { default as AnchorNavigation } from '@cloudscape-design/components/anchor-navigation'; export { default as ExpandableSection } from '@cloudscape-design/components/expandable-section'; +export { default as KeyValuePairs } from '@cloudscape-design/components/key-value-pairs'; export { I18nProvider } from '@cloudscape-design/components/i18n'; export { default as Wizard } from '@cloudscape-design/components/wizard'; @@ -81,6 +82,8 @@ export type { FormMultiselectOptions, FormMultiselectProps } from './form/Multis export { FormS3BucketSelector } from './form/S3BucketSelector'; export type { FormTilesProps } from './form/Tiles/types'; export { FormTiles } from './form/Tiles'; +export type { FormCardsProps } from './form/Cards/types'; +export { FormCards } from './form/Cards'; export { Notifications } from './Notifications'; export { ConfirmationDialog } from './ConfirmationDialog'; export { FileUploader } from './FileUploader'; diff --git a/frontend/src/pages/Project/CreateWizard/constants.ts b/frontend/src/pages/Project/CreateWizard/constants.ts index 9dcc3090f..3e7321a7e 100644 --- a/frontend/src/pages/Project/CreateWizard/constants.ts +++ b/frontend/src/pages/Project/CreateWizard/constants.ts @@ -1,22 +1,13 @@ -import { FormMultiselectOptions } from 'components'; - -export const backendOptions: FormMultiselectOptions = [ - { label: 'aws', value: 'aws' }, - { label: 'azure', value: 'azure' }, - { label: 'cudo', value: 'cudo' }, - { label: 'datacrunch', value: 'datacrunch' }, - { label: 'dstack', value: 'dstack' }, - { label: 'gcp', value: 'gcp' }, - { label: 'kubernetes', value: 'kubernetes' }, - { label: 'lambda', value: 'lambda' }, - { label: 'local', value: 'local' }, - { label: 'nebius', value: 'nebius' }, - { label: 'remote', value: 'remote' }, - { label: 'oci', value: 'oci' }, - { label: 'runpod', value: 'runpod' }, - { label: 'tensordock', value: 'tensordock' }, - { label: 'vastai', value: 'vastai' }, - { label: 'cloudrift', value: 'cloudrift' }, - { label: 'hotaisle', value: 'hotaisle' }, - { label: 'vultr', value: 'vultr' }, +export const projectTypeOptions = [ + { + label: 'GPU marketplace', + description: + 'Find the cheapest GPUs available in our marketplace. Enjoy $5 in free credits, and easily top up your balance with a credit card.', + value: 'gpu_marketplace', + }, + { + label: 'Your cloud accounts', + description: 'Connect and manage your cloud accounts. dstack supports all major GPU cloud providers.', + value: 'own_cloud', + }, ]; diff --git a/frontend/src/pages/Project/CreateWizard/index.tsx b/frontend/src/pages/Project/CreateWizard/index.tsx index 24b07df57..0d7ff04f4 100644 --- a/frontend/src/pages/Project/CreateWizard/index.tsx +++ b/frontend/src/pages/Project/CreateWizard/index.tsx @@ -1,37 +1,43 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import * as yup from 'yup'; import { WizardProps } from '@cloudscape-design/components'; +import { TilesProps } from '@cloudscape-design/components/tiles'; import { - Box, + // Box, + Cards, Container, - FormCheckbox, + FormCards, + // FormCheckbox, FormField, FormInput, - FormMultiselect, + // FormMultiselect, FormTiles, + KeyValuePairs, SpaceBetween, - StatusIndicator, + // StatusIndicator, Wizard, } from 'components'; import { useBreadcrumbs, useNotifications } from 'hooks'; +import { getServerError } from 'libs'; import { ROUTES } from 'routes'; +import { useGetBackendBaseTypesQuery, useGetBackendTypesQuery } from 'services/backend'; +import { useCreateWizardProjectMutation } from 'services/project'; -import { getServerError } from '../../../libs'; -import { useCreateProjectMutation } from '../../../services/project'; -import { backendOptions } from './constants'; +import { projectTypeOptions } from './constants'; import { IProjectWizardForm } from './types'; -import styles from './styles.module.scss'; +// import styles from './styles.module.scss'; const requiredFieldError = 'This is required field'; +const minOneLengthError = 'Need to choose one or more'; const namesFieldError = 'Only latin characters, dashes, underscores, and digits'; -const numberFieldError = 'This is number field'; +// const numberFieldError = 'This is number field'; const projectValidationSchema = yup.object({ project_name: yup @@ -41,41 +47,41 @@ const projectValidationSchema = yup.object({ project_type: yup.string().required(requiredFieldError), backends: yup.array().when('project_type', { is: 'gpu_marketplace', - then: yup.array().required(requiredFieldError), - }), - fleet_name: yup.string().when('enable_fleet', { - is: true, - then: yup - .string() - .required(requiredFieldError) - .matches(/^[a-zA-Z0-9-_]+$/, namesFieldError), - }), - fleet_min_instances: yup.number().when('enable_fleet', { - is: true, - then: yup - .number() - .required(requiredFieldError) - .typeError(numberFieldError) - .min(1) - .test('is-smaller-than-man', 'The minimum value must be less than the maximum value.', (value, context) => { - const { fleet_max_instances } = context.parent; - if (typeof fleet_max_instances !== 'number' || typeof value !== 'number') return true; - return value <= fleet_max_instances; - }), - }), - fleet_max_instances: yup.number().when('enable_fleet', { - is: true, - then: yup - .number() - .required(requiredFieldError) - .typeError(numberFieldError) - .min(1) - .test('is-greater-than-min', 'The maximum value must be greater than the minimum value', (value, context) => { - const { fleet_min_instances } = context.parent; - if (typeof fleet_min_instances !== 'number' || typeof value !== 'number') return true; - return value >= fleet_min_instances; - }), + then: yup.array().min(1, minOneLengthError).required(requiredFieldError), }), + // fleet_name: yup.string().when('enable_fleet', { + // is: true, + // then: yup + // .string() + // .required(requiredFieldError) + // .matches(/^[a-zA-Z0-9-_]+$/, namesFieldError), + // }), + // fleet_min_instances: yup.number().when('enable_fleet', { + // is: true, + // then: yup + // .number() + // .required(requiredFieldError) + // .typeError(numberFieldError) + // .min(1) + // .test('is-smaller-than-man', 'The minimum value must be less than the maximum value.', (value, context) => { + // const { fleet_max_instances } = context.parent; + // if (typeof fleet_max_instances !== 'number' || typeof value !== 'number') return true; + // return value <= fleet_max_instances; + // }), + // }), + // fleet_max_instances: yup.number().when('enable_fleet', { + // is: true, + // then: yup + // .number() + // .required(requiredFieldError) + // .typeError(numberFieldError) + // .min(1) + // .test('is-greater-than-min', 'The maximum value must be greater than the minimum value', (value, context) => { + // const { fleet_min_instances } = context.parent; + // if (typeof fleet_min_instances !== 'number' || typeof value !== 'number') return true; + // return value >= fleet_min_instances; + // }), + // }), }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -120,7 +126,9 @@ export const CreateProjectWizard: React.FC = () => { const navigate = useNavigate(); const [pushNotification] = useNotifications(); const [activeStepIndex, setActiveStepIndex] = useState(0); - const [createProject, { isLoading }] = useCreateProjectMutation(); + const [createProject, { isLoading }] = useCreateWizardProjectMutation(); + const { data: backendBaseTypesData, isLoading: isBackendBaseTypesLoading } = useGetBackendBaseTypesQuery(); + const { data: backendTypesData, isLoading: isBackendTypesLoading } = useGetBackendTypesQuery(); const loading = isLoading; @@ -135,29 +143,82 @@ export const CreateProjectWizard: React.FC = () => { }, ]); + const backendBaseOptions = useMemo(() => { + if (!backendBaseTypesData) { + return []; + } + + return backendBaseTypesData.map((b: TProjectBackend) => ({ + label: b, + value: b, + })); + }, [backendBaseTypesData]); + + const backendOptions = useMemo(() => { + if (!backendTypesData) { + return []; + } + + return backendTypesData.map((b: TProjectBackend) => ({ + label: b, + value: b, + })); + }, [backendTypesData]); + const resolver = useYupValidationResolver(projectValidationSchema); const formMethods = useForm({ resolver, defaultValues: { enable_fleet: true, fleet_min_instances: 0 }, }); - const { handleSubmit, control, watch, trigger, formState, getValues } = formMethods; - const projectType = watch('project_type'); - const isEnabledFleet = watch('enable_fleet'); + const { handleSubmit, control, watch, trigger, formState, getValues, setValue, setError } = formMethods; + const formValues = watch(); const onCancelHandler = () => { navigate(ROUTES.PROJECT.LIST); }; - const onSubmit = (data: IProjectWizardForm) => { - console.log(data); + const getFormValuesForServer = (): TCreateWizardProjectParams => { + const { project_name, backends, project_type } = getValues(); + + return { + project_name, + config: { + base_backends: project_type === 'gpu_marketplace' ? backends : [], + }, + }; }; - const validateFirstStep = async () => { - return await trigger(['project_type', 'project_name']); + const validateNameAndType = async () => { + try { + const yupValidationResult = await trigger(['project_type', 'project_name']); + + const serverValidationResult = await createProject({ + ...getFormValuesForServer(), + dry: true, + }) + .unwrap() + .then(() => true) + .catch((error) => { + const errorDetail = (error?.data?.detail ?? []) as { msg: string; code: string }[]; + const projectExist = errorDetail.some(({ code }) => code === 'resource_exists'); + + if (projectExist) { + setError('project_name', { type: 'custom', message: 'Project is already exist' }); + } + + return false; + }); + + console.log({ serverValidationResult }); + + return yupValidationResult && serverValidationResult; + } catch (e) { + return false; + } }; - const validateSecondStep = async () => { - if (projectType === 'gpu_marketplace') { + const validateBackends = async () => { + if (formValues['project_type'] === 'gpu_marketplace') { return await trigger(['backends']); } @@ -166,17 +227,38 @@ export const CreateProjectWizard: React.FC = () => { const emptyValidator = async () => Promise.resolve(true); - const onNavigate: WizardProps['onNavigate'] = ({ detail }) => { - const stepValidators = [validateFirstStep, validateSecondStep, emptyValidator]; + const onNavigate = ({ + requestedStepIndex, + reason, + }: { + requestedStepIndex: number; + reason: WizardProps.NavigationReason; + }) => { + const stepValidators = [validateNameAndType, validateBackends, emptyValidator]; - if (detail.requestedStepIndex > activeStepIndex) { + if (reason === 'next') { stepValidators[activeStepIndex]?.().then((isValid) => { if (isValid) { - setActiveStepIndex(detail.requestedStepIndex); + setActiveStepIndex(requestedStepIndex); } }); } else { - setActiveStepIndex(detail.requestedStepIndex); + setActiveStepIndex(requestedStepIndex); + } + }; + + const onNavigateHandler: WizardProps['onNavigate'] = ({ detail: { requestedStepIndex, reason } }) => { + onNavigate({ requestedStepIndex, reason }); + }; + + const onChangeProjectType: TilesProps['onChange'] = ({ detail: { value } }) => { + if (value === 'gpu_marketplace') { + setValue( + 'backends', + backendBaseOptions.map((b: { value: string }) => b.value), + ); + } else { + trigger(['backends']).catch(console.log); } }; @@ -187,9 +269,7 @@ export const CreateProjectWizard: React.FC = () => { return; } - const { project_name } = getValues(); - - const request = createProject({ project_name } as IProject).unwrap(); + const request = createProject(getFormValuesForServer()).unwrap(); request .then((data) => { @@ -208,11 +288,19 @@ export const CreateProjectWizard: React.FC = () => { }); }; + const onSubmit = () => { + if (activeStepIndex < 2) { + onNavigate({ requestedStepIndex: activeStepIndex + 1, reason: 'next' }); + } else { + onSubmitWizard().catch(console.log); + } + }; + return (
`Step ${stepNumber}`, @@ -247,20 +335,8 @@ export const CreateProjectWizard: React.FC = () => { @@ -272,86 +348,133 @@ export const CreateProjectWizard: React.FC = () => { title: 'Backends', content: ( - {projectType === 'gpu_marketplace' && ( - + +
+ + {formValues['project_type'] === 'gpu_marketplace' && ( + item.label, + }} + cardsPerRow={[{ cards: 1 }, { minWidth: 400, cards: 2 }, { minWidth: 800, cards: 3 }]} /> )} - {projectType === 'own_cloud' && ( -
- - You will be able to configure own cloud after - creating project - -
+ {formValues['project_type'] === 'own_cloud' && ( + item.label, + }} + cardsPerRow={[{ cards: 1 }, { minWidth: 400, cards: 2 }, { minWidth: 800, cards: 3 }]} + /> )}
), }, + // { + // title: 'Fleets', + // content: ( + // + // + // + // + // {formValues['enable_fleet'] && ( + // <> + // + //
+ // To create dev environments, submit tasks, or + // run services, you need at least one fleet. + //
+ // + //
+ // It's recommended to create it now, or you + // can set it up manually later. + //
+ // + //
+ // + // Don't worry, creating a fleet doesn’t necessarily create cloud instances. + //
+ //
+ // + // + // + // + // + // + // + // )} + //
+ //
+ // ), + // }, { - title: 'Fleets', + title: 'Summary', content: ( - - - - {isEnabledFleet && ( - <> - -
- To create dev environments, submit tasks, or - run services, you need at least one fleet. -
- -
- It's recommended to create it now, or you - can set it up manually later. -
- -
- - Don't worry, creating a fleet doesn’t necessarily create cloud instances. -
-
- - - - - - - - )} -
+ value === formValues['project_type']) + ?.label, + }, + { + label: t('projects.edit.backends'), + value: (formValues['project_type'] === 'gpu_marketplace' + ? (formValues['backends'] ?? []) + : backendOptions.map((b: { value: string }) => b.value) + ).join(', '), + }, + ]} + />
), }, diff --git a/frontend/src/services/backend.ts b/frontend/src/services/backend.ts index 8e5da3f2e..456c5b896 100644 --- a/frontend/src/services/backend.ts +++ b/frontend/src/services/backend.ts @@ -10,6 +10,12 @@ export const extendedProjectApi = projectApi.injectEndpoints({ method: 'POST', }), }), + getBackendBaseTypes: builder.query({ + query: () => ({ + url: API.BACKENDS.LIST_BASE_TYPES(), + method: 'POST', + }), + }), createBackend: builder.mutation({ query: ({ projectName, config }) => ({ @@ -108,6 +114,7 @@ export const extendedProjectApi = projectApi.injectEndpoints({ export const { useGetBackendTypesQuery, + useGetBackendBaseTypesQuery, useDeleteProjectBackendMutation, useCreateBackendMutation, useBackendValuesMutation, diff --git a/frontend/src/services/project.ts b/frontend/src/services/project.ts index c7559784e..10fd9b33e 100644 --- a/frontend/src/services/project.ts +++ b/frontend/src/services/project.ts @@ -10,7 +10,7 @@ const decoder = new TextDecoder('utf-8'); // eslint-disable-next-line @typescript-eslint/no-explicit-any const transformProjectResponse = (project: any): IProject => ({ ...project, - isPublic: project.is_public, + isPublic: project?.is_public, }); export const projectApi = createApi({ @@ -65,6 +65,18 @@ export const projectApi = createApi({ invalidatesTags: () => ['Projects'], }), + createWizardProject: builder.mutation({ + query: (project) => ({ + url: API.PROJECTS.CREATE_WIZARD(), + method: 'POST', + body: project, + }), + + transformResponse: transformProjectResponse, + + invalidatesTags: () => ['Projects'], + }), + updateProjectMembers: builder.mutation({ query: ({ project_name, members }) => ({ url: API.PROJECTS.SET_MEMBERS(project_name), @@ -170,6 +182,7 @@ export const { useGetProjectsQuery, useGetProjectQuery, useCreateProjectMutation, + useCreateWizardProjectMutation, useUpdateProjectMembersMutation, useAddProjectMemberMutation, useRemoveProjectMemberMutation, diff --git a/frontend/src/types/project.d.ts b/frontend/src/types/project.d.ts index 77eab8196..5defe6b9e 100644 --- a/frontend/src/types/project.d.ts +++ b/frontend/src/types/project.d.ts @@ -1,3 +1,12 @@ +declare type TCreateWizardProjectParams = { + project_name: string; + dry?: boolean; + is_public?: boolean; + config: { + base_backends: string[]; + }; +}; + declare type TProjectBackend = { name: string; config: IBackendAWS | IBackendAzure | IBackendGCP | IBackendLambda | IBackendLocal | IBackendDstack; From 7b536ab6be724643f082fb04579e0b704f08fdd0 Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Mon, 15 Sep 2025 21:39:31 +0200 Subject: [PATCH 3/8] [UI] Project wizard #323 Cosmetics --- frontend/src/locale/en.json | 13 +++++++------ frontend/src/pages/Project/CreateWizard/index.tsx | 8 ++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 17d05ca4f..de559dbc4 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -11,7 +11,7 @@ "remove": "Remove", "apply": "Apply", "next": "Next", - "previous": "Previous", + "previous": "Back", "settings": "Settings", "match_count_with_value_one": "{{count}} match", "match_count_with_value_other": "{{count}} matches", @@ -71,7 +71,7 @@ "runs": "Runs", "models": "Models", "fleets": "Fleets", - "project": "Project", + "project": "project", "project_other": "Projects", "general": "General", "users": "Users", @@ -190,7 +190,7 @@ "settings": "Settings" }, "wizard": { - "submit": "Create project" + "submit": "Create" }, "edit": { "general": "General", @@ -198,9 +198,10 @@ "owner": "Owner", "project_name_description": "Only latin characters, dashes, underscores, and digits", "project_type": "Project type", - "project_type_description": "You can choose one of project types", + "project_type_description": "Choose which project type you want to create", "backends": "Backends", - "backends_description": "You can choose one or more of backend types", + "base_backends_description": "dstack will automatically collect offers from the following providers. Deselect providers you don’t want to use.", + "backends_description": "The following backends can be configured with your own cloud credentials in the project settings after the project is created.", "default_fleet": "Create default fleet", "default_fleet_description": "You can create default fleet for project", "fleet_name": "Fleet name", @@ -223,7 +224,7 @@ "update_visibility_confirm_title": "Change project visibility", "update_visibility_confirm_message": "Are you sure you want to change the project visibility? This will affect who can access this project.", "change_visibility": "Change visibility", - "project_visibility": "Project visibility", + "project_visibility": "Visibility", "project_visibility_description": "Control who can access this project", "make_project_public": "Make project public", "delete_project_confirm_title": "Delete project", diff --git a/frontend/src/pages/Project/CreateWizard/index.tsx b/frontend/src/pages/Project/CreateWizard/index.tsx index 0d7ff04f4..f5d31b0ca 100644 --- a/frontend/src/pages/Project/CreateWizard/index.tsx +++ b/frontend/src/pages/Project/CreateWizard/index.tsx @@ -203,7 +203,7 @@ export const CreateProjectWizard: React.FC = () => { const projectExist = errorDetail.some(({ code }) => code === 'resource_exists'); if (projectExist) { - setError('project_name', { type: 'custom', message: 'Project is already exist' }); + setError('project_name', { type: 'custom', message: 'Project with this name already exists' }); } return false; @@ -314,7 +314,7 @@ export const CreateProjectWizard: React.FC = () => { submitButtonText={t('projects.wizard.submit')} steps={[ { - title: 'Project name and type', + title: 'Name and type', content: ( @@ -352,8 +352,8 @@ export const CreateProjectWizard: React.FC = () => { label={t('projects.edit.backends')} description={ formValues['project_type'] === 'gpu_marketplace' - ? t('projects.edit.backends_description') - : 'Own_cloud_backends_placeholder' + ? t('projects.edit.base_backends_description') + : t('projects.edit.backends_description') } errorText={formState.errors.backends?.message} /> From b5b840784e0a890fba90ca3df7e1aae8cbe7d790 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Mon, 15 Sep 2025 22:59:54 +0300 Subject: [PATCH 4/8] [UI] Project wizard #323 --- .../App/Login/LoginByGithubCallback/index.tsx | 14 +++- frontend/src/App/slice.ts | 1 + frontend/src/App/types.ts | 1 + .../AppLayout/TutorialPanel/constants.tsx | 26 +++++++ .../layouts/AppLayout/TutorialPanel/hooks.ts | 76 ++++++++++++++++--- .../src/pages/Project/CreateWizard/index.tsx | 13 ++-- frontend/src/services/project.ts | 1 + 7 files changed, 113 insertions(+), 19 deletions(-) diff --git a/frontend/src/App/Login/LoginByGithubCallback/index.tsx b/frontend/src/App/Login/LoginByGithubCallback/index.tsx index 9f99c7c4b..814846631 100644 --- a/frontend/src/App/Login/LoginByGithubCallback/index.tsx +++ b/frontend/src/App/Login/LoginByGithubCallback/index.tsx @@ -8,6 +8,7 @@ import { UnauthorizedLayout } from 'layouts/UnauthorizedLayout'; import { useAppDispatch } from 'hooks'; import { ROUTES } from 'routes'; import { useGithubCallbackMutation } from 'services/auth'; +import { useLazyGetProjectsQuery } from 'services/project'; import { AuthErrorMessage } from 'App/AuthErrorMessage'; import { Loading } from 'App/Loading'; @@ -22,13 +23,24 @@ export const LoginByGithubCallback: React.FC = () => { const dispatch = useAppDispatch(); const [githubCallback] = useGithubCallbackMutation(); + const [getProjects] = useLazyGetProjectsQuery(); const checkCode = () => { if (code) { githubCallback({ code }) .unwrap() - .then(({ creds: { token } }) => { + .then(async ({ creds: { token } }) => { dispatch(setAuthData({ token })); + + if (process.env.UI_VERSION === 'sky') { + const result = await getProjects().unwrap(); + + if (result?.length === 0) { + navigate(ROUTES.PROJECT.ADD); + return; + } + } + navigate('/'); }) .catch(() => { diff --git a/frontend/src/App/slice.ts b/frontend/src/App/slice.ts index dc53eef41..9d684d585 100644 --- a/frontend/src/App/slice.ts +++ b/frontend/src/App/slice.ts @@ -61,6 +61,7 @@ const getInitialState = (): IAppState => { }, tutorialPanel: { + createProjectCompleted: false, billingCompleted: false, configureCLICompleted: false, discordCompleted: false, diff --git a/frontend/src/App/types.ts b/frontend/src/App/types.ts index 6b6f87a4d..262c1b156 100644 --- a/frontend/src/App/types.ts +++ b/frontend/src/App/types.ts @@ -32,6 +32,7 @@ export interface IAppState { }; tutorialPanel: { + createProjectCompleted: boolean; billingCompleted: boolean; configureCLICompleted: boolean; discordCompleted: boolean; diff --git a/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx b/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx index 3a418a6d6..03c5ff67a 100644 --- a/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx +++ b/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx @@ -44,6 +44,7 @@ export enum HotspotIds { ADD_TOP_UP_BALANCE = 'billing-top-up-balance', PAYMENT_CONTINUE_BUTTON = 'billing-payment-continue-button', CONFIGURE_CLI_COMMAND = 'configure-cli-command', + CREATE_FIRST_PROJECT = 'create-first-project', } export const BILLING_TUTORIAL: TutorialPanelProps.Tutorial = { @@ -101,6 +102,31 @@ export const CONFIGURE_CLI_TUTORIAL: TutorialPanelProps.Tutorial = { ], }; +export const CREATE_FIRST_PROJECT: TutorialPanelProps.Tutorial = { + completed: false, + title: 'Create the first project', + description: ( + <> + + Configure the first your project + + + ), + completedScreenDescription: 'TBA', + tasks: [ + { + title: 'Create the first project', + steps: [ + { + title: 'Create the first project', + content: 'Create the first project', + hotspotId: HotspotIds.CREATE_FIRST_PROJECT, + }, + ], + }, + ], +}; + export const JOIN_DISCORD_TUTORIAL: TutorialPanelProps.Tutorial = { completed: false, title: 'Community', diff --git a/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts b/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts index 25ee53220..f27575f26 100644 --- a/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts +++ b/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { DISCORD_URL, @@ -8,6 +8,8 @@ import { } from 'consts'; import { useAppDispatch, useAppSelector } from 'hooks'; import { goToUrl } from 'libs'; +import { ROUTES } from 'routes'; +import { useGetProjectsQuery } from 'services/project'; import { useGetRunsQuery } from 'services/run'; import { useGetUserBillingInfoQuery } from 'services/user'; @@ -17,6 +19,7 @@ import { useSideNavigation } from '../hooks'; import { BILLING_TUTORIAL, CONFIGURE_CLI_TUTORIAL, + CREATE_FIRST_PROJECT, // CREDITS_TUTORIAL, JOIN_DISCORD_TUTORIAL, QUICKSTART_TUTORIAL, @@ -26,13 +29,22 @@ import { ITutorialItem } from 'App/types'; export const useTutorials = () => { const navigate = useNavigate(); + const location = useLocation(); const dispatch = useAppDispatch(); const { billingUrl } = useSideNavigation(); const useName = useAppSelector(selectUserName); - const { billingCompleted, configureCLICompleted, discordCompleted, tallyCompleted, quickStartCompleted, hideStartUp } = - useAppSelector(selectTutorialPanel); + const { + billingCompleted, + createProjectCompleted, + configureCLICompleted, + discordCompleted, + tallyCompleted, + quickStartCompleted, + hideStartUp, + } = useAppSelector(selectTutorialPanel); const { data: userBillingData } = useGetUserBillingInfoQuery({ username: useName ?? '' }, { skip: !useName }); + const { data: projectData } = useGetProjectsQuery(); const { data: runsData } = useGetRunsQuery({ limit: 1, }); @@ -40,18 +52,32 @@ export const useTutorials = () => { const completeIsChecked = useRef(false); useEffect(() => { - if (userBillingData && runsData && !completeIsChecked.current) { + if ( + userBillingData && + projectData && + runsData && + !completeIsChecked.current && + location.pathname !== ROUTES.PROJECT.ADD + ) { const billingCompleted = userBillingData.balance > 0; const configureCLICompleted = runsData.length > 0; + const createProjectCompleted = projectData.length > 0; let tempHideStartUp = hideStartUp; if (hideStartUp === null) { - tempHideStartUp = billingCompleted && configureCLICompleted; + tempHideStartUp = billingCompleted && configureCLICompleted && createProjectCompleted; } // Set hideStartUp without updating localstorage - dispatch(updateTutorialPanelState({ billingCompleted, configureCLICompleted, hideStartUp: tempHideStartUp })); + dispatch( + updateTutorialPanelState({ + billingCompleted, + configureCLICompleted, + createProjectCompleted, + hideStartUp: tempHideStartUp, + }), + ); if (!tempHideStartUp && process.env.UI_VERSION === 'sky') { dispatch(openTutorialPanel()); @@ -59,7 +85,17 @@ export const useTutorials = () => { completeIsChecked.current = true; } - }, [userBillingData, runsData]); + }, [userBillingData, runsData, projectData, location.pathname]); + + useEffect(() => { + if (projectData && projectData.length > 0 && !createProjectCompleted) { + dispatch( + updateTutorialPanelState({ + createProjectCompleted: true, + }), + ); + } + }, [projectData]); const startBillingTutorial = useCallback(() => { navigate(billingUrl); @@ -69,6 +105,14 @@ export const useTutorials = () => { dispatch(updateTutorialPanelState({ billingCompleted: true })); }, []); + const startFirstProjectTutorial = useCallback(() => { + navigate(ROUTES.PROJECT.ADD); + }, []); + + const finishFirstProjectTutorial = useCallback(() => { + dispatch(updateTutorialPanelState({ createProjectCompleted: true })); + }, []); + const startConfigCliTutorial = useCallback(() => {}, [billingUrl]); const finishConfigCliTutorial = useCallback(() => { @@ -103,8 +147,16 @@ export const useTutorials = () => { // }, { - ...CONFIGURE_CLI_TUTORIAL, + ...CREATE_FIRST_PROJECT, id: 2, + completed: createProjectCompleted, + startCallback: startFirstProjectTutorial, + finishCallback: finishFirstProjectTutorial, + }, + + { + ...CONFIGURE_CLI_TUTORIAL, + id: 3, completed: configureCLICompleted, startCallback: startConfigCliTutorial, finishCallback: finishConfigCliTutorial, @@ -112,7 +164,7 @@ export const useTutorials = () => { { ...BILLING_TUTORIAL, - id: 3, + id: 4, completed: billingCompleted, startCallback: startBillingTutorial, finishCallback: finishBillingTutorial, @@ -120,7 +172,7 @@ export const useTutorials = () => { { ...QUICKSTART_TUTORIAL, - id: 4, + id: 5, startWithoutActivation: true, completed: quickStartCompleted, startCallback: startQuickStartTutorial, @@ -128,7 +180,7 @@ export const useTutorials = () => { { ...JOIN_DISCORD_TUTORIAL, - id: 5, + id: 6, startWithoutActivation: true, completed: discordCompleted, startCallback: startDiscordTutorial, @@ -136,11 +188,13 @@ export const useTutorials = () => { ]; }, [ billingUrl, + createProjectCompleted, quickStartCompleted, discordCompleted, tallyCompleted, billingCompleted, configureCLICompleted, + finishFirstProjectTutorial, finishBillingTutorial, finishConfigCliTutorial, ]); diff --git a/frontend/src/pages/Project/CreateWizard/index.tsx b/frontend/src/pages/Project/CreateWizard/index.tsx index f5d31b0ca..d90515daa 100644 --- a/frontend/src/pages/Project/CreateWizard/index.tsx +++ b/frontend/src/pages/Project/CreateWizard/index.tsx @@ -168,7 +168,7 @@ export const CreateProjectWizard: React.FC = () => { const resolver = useYupValidationResolver(projectValidationSchema); const formMethods = useForm({ resolver, - defaultValues: { enable_fleet: true, fleet_min_instances: 0 }, + defaultValues: { project_type: 'gpu_marketplace', enable_fleet: true, fleet_min_instances: 0 }, }); const { handleSubmit, control, watch, trigger, formState, getValues, setValue, setError } = formMethods; const formValues = watch(); @@ -209,10 +209,9 @@ export const CreateProjectWizard: React.FC = () => { return false; }); - console.log({ serverValidationResult }); - return yupValidationResult && serverValidationResult; } catch (e) { + console.log(e); return false; } }; @@ -468,10 +467,10 @@ export const CreateProjectWizard: React.FC = () => { }, { label: t('projects.edit.backends'), - value: (formValues['project_type'] === 'gpu_marketplace' - ? (formValues['backends'] ?? []) - : backendOptions.map((b: { value: string }) => b.value) - ).join(', '), + value: + formValues['project_type'] === 'gpu_marketplace' + ? (formValues['backends'] ?? []).join(', ') + : 'The backends can be configured with your own cloud credentials in the project settings after the project is created.', }, ]} /> diff --git a/frontend/src/services/project.ts b/frontend/src/services/project.ts index 10fd9b33e..1dfbe25ef 100644 --- a/frontend/src/services/project.ts +++ b/frontend/src/services/project.ts @@ -180,6 +180,7 @@ export const projectApi = createApi({ export const { useGetProjectsQuery, + useLazyGetProjectsQuery, useGetProjectQuery, useCreateProjectMutation, useCreateWizardProjectMutation, From 1962d5651a9d810273067cb5b5ebca1344f29b3e Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Mon, 15 Sep 2025 23:14:58 +0300 Subject: [PATCH 5/8] [UI] Project wizard #323 --- frontend/src/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index f2ec64205..8820bd30d 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -19,7 +19,8 @@ const container = document.getElementById('root'); const theme: Theme = { tokens: { - fontFamilyBase: 'metro-web, Metro, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif', + fontFamilyBase: + 'metro-web, Metro, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif', fontSizeHeadingS: '15px', fontSizeHeadingL: '19px', fontSizeHeadingXl: '22px', From d78d44d56f59e3683c829fefceb67c5d2ed12ba2 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Mon, 15 Sep 2025 23:18:04 +0300 Subject: [PATCH 6/8] [UI] Project wizard #323 --- frontend/src/pages/Project/CreateWizard/styles.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Project/CreateWizard/styles.module.scss b/frontend/src/pages/Project/CreateWizard/styles.module.scss index 443af817e..95a6f77a0 100644 --- a/frontend/src/pages/Project/CreateWizard/styles.module.scss +++ b/frontend/src/pages/Project/CreateWizard/styles.module.scss @@ -4,4 +4,4 @@ justify-content: center; padding-top: 40px; padding-bottom: 40px; -} \ No newline at end of file +} From 6717d777703b41517a2c1c8ad4cf793fb1fdb3f8 Mon Sep 17 00:00:00 2001 From: Oleg Vavilov Date: Mon, 15 Sep 2025 23:21:58 +0300 Subject: [PATCH 7/8] [UI] Project wizard #323 --- frontend/src/pages/Project/CreateWizard/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Project/CreateWizard/index.tsx b/frontend/src/pages/Project/CreateWizard/index.tsx index d90515daa..ab7863406 100644 --- a/frontend/src/pages/Project/CreateWizard/index.tsx +++ b/frontend/src/pages/Project/CreateWizard/index.tsx @@ -183,7 +183,7 @@ export const CreateProjectWizard: React.FC = () => { return { project_name, config: { - base_backends: project_type === 'gpu_marketplace' ? backends : [], + base_backends: project_type === 'gpu_marketplace' ? (backends ?? []) : [], }, }; }; From 5277057aee8fb59d20116154efd468433c8cb855 Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Mon, 15 Sep 2025 22:27:43 +0200 Subject: [PATCH 8/8] [UI] Project wizard #323 Captions --- frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx b/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx index 03c5ff67a..5292e24d6 100644 --- a/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx +++ b/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx @@ -53,7 +53,7 @@ export const BILLING_TUTORIAL: TutorialPanelProps.Tutorial = { description: ( <> - Top up your balance via a credit card to use GPU by dstack Sky. + If you plan to use the GPU marketplace, top up your balance with a credit card. ), @@ -104,11 +104,11 @@ export const CONFIGURE_CLI_TUTORIAL: TutorialPanelProps.Tutorial = { export const CREATE_FIRST_PROJECT: TutorialPanelProps.Tutorial = { completed: false, - title: 'Create the first project', + title: 'Create a project', description: ( <> - Configure the first your project + Create your first project. Choose to use the GPU marketplace or configure your own cloud credentials. ),