diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5eb8e61101..e66eba969c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "css-minimizer-webpack-plugin": "^4.2.2", "date-fns": "^2.29.3", "i18next": "^24.0.2", + "js-yaml": "^4.1.0", "lodash": "^4.17.21", "openai": "^4.33.1", "prismjs": "^1.30.0", @@ -61,6 +62,7 @@ "@types/date-fns": "^2.6.0", "@types/enzyme": "^3.10.18", "@types/jest": "^29.5.14", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.13", "@types/node": "^22.10.1", "@types/react": "^18.3.12", @@ -147,6 +149,30 @@ "js-yaml": "^3.13.1" } }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@apidevtools/openapi-schemas": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", @@ -2663,13 +2689,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -2683,19 +2702,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3241,6 +3247,16 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -3263,6 +3279,20 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -4974,6 +5004,13 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -5930,13 +5967,10 @@ "dev": true }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" }, "node_modules/aria-query": { "version": "5.3.0", @@ -9061,6 +9095,7 @@ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, + "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -12304,13 +12339,12 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -19627,12 +19661,6 @@ "webpack": "^5.0.0" } }, - "node_modules/postcss-loader/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/postcss-loader/node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -19659,18 +19687,6 @@ } } }, - "node_modules/postcss-loader/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/postcss-loader/node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -22207,7 +22223,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/stable": { "version": "0.1.8", diff --git a/frontend/package.json b/frontend/package.json index fa5c511cac..0c002d39df 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "@types/date-fns": "^2.6.0", "@types/enzyme": "^3.10.18", "@types/jest": "^29.5.14", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.13", "@types/node": "^22.10.1", "@types/react": "^18.3.12", @@ -110,6 +111,7 @@ "css-minimizer-webpack-plugin": "^4.2.2", "date-fns": "^2.29.3", "i18next": "^24.0.2", + "js-yaml": "^4.1.0", "lodash": "^4.17.21", "openai": "^4.33.1", "prismjs": "^1.30.0", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c1e453c114..b0266e74ea 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -79,6 +79,7 @@ export const API = { RUNS_DELETE: (projectName: IProject['project_name']) => `${API.PROJECTS.RUNS(projectName)}/delete`, RUNS_STOP: (projectName: IProject['project_name']) => `${API.PROJECTS.RUNS(projectName)}/stop`, RUNS_SUBMIT: (projectName: IProject['project_name']) => `${API.PROJECTS.RUNS(projectName)}/submit`, + RUNS_APPLY: (projectName: IProject['project_name']) => `${API.PROJECTS.RUNS(projectName)}/apply`, // Logs LOGS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/logs/poll`, diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index f69a5589fa..bfe834152c 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,6 +1,7 @@ export { default as Alert } from '@cloudscape-design/components/alert'; export type { AlertProps } from '@cloudscape-design/components/alert'; export { default as Icon } from '@cloudscape-design/components/icon'; +export { default as ButtonDropdown } from '@cloudscape-design/components/button-dropdown'; export type { ButtonDropdownProps } from '@cloudscape-design/components/button-dropdown'; export { default as AppLayout } from '@cloudscape-design/components/app-layout'; export type { AppLayoutProps } from '@cloudscape-design/components/app-layout'; diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index de559dbc45..7d32a7545d 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -51,7 +51,8 @@ "control_plane": "Control plane", "refresh": "Refresh", "quickstart": "Quickstart", - "ask_ai": "Ask AI" + "ask_ai": "Ask AI", + "new": "New" }, "auth": { @@ -465,6 +466,24 @@ "size": "Size" } }, + "runs": { + "dev_env": { + "wizard": { + "title": "New dev environment", + "submit": "Apply", + "offer": "Offer", + "offer_description": "Select an offer for the dev environment.", + "name": "Name", + "name_description": "The name of the run. If not specified, the name will be generated automatically.", + "name_placeholder": "Optional", + "ide": "IDE", + "ide_description": "Select which IDE would you like to use with the dev environment.", + "config": "Configuration file", + "config_description": "Review the configuration file and adjust it if needed. Click Info for examples.", + "success_notification": "The run is submitted!" + } + } + }, "offer": { "title": "Offers", "filter_property_placeholder": "Filter by properties", diff --git a/frontend/src/pages/Offers/List/hooks/useFilters.ts b/frontend/src/pages/Offers/List/hooks/useFilters.ts index 3270bce334..ce93ca2853 100644 --- a/frontend/src/pages/Offers/List/hooks/useFilters.ts +++ b/frontend/src/pages/Offers/List/hooks/useFilters.ts @@ -16,6 +16,7 @@ import { getPropertyFilterOptions } from '../helpers'; type Args = { gpus: IGpu[]; + withSearchParams?: boolean; }; type RequestParamsKeys = 'project_name' | 'gpu_name' | 'gpu_count' | 'gpu_memory' | 'backend' | 'spot_policy' | 'group_by'; @@ -52,7 +53,7 @@ const defaultGroupByOptions = [{ ...gpuFilterOption }, { label: 'Backend', value const groupByRequestParamName: RequestParamsKeys = 'group_by'; -export const useFilters = ({ gpus }: Args) => { +export const useFilters = ({ gpus, withSearchParams = true }: Args) => { const [searchParams, setSearchParams] = useSearchParams(); const { projectOptions } = useProjectFilter({ localStorePrefix: 'offers-list-projects' }); const projectNameIsChecked = useRef(false); @@ -75,7 +76,9 @@ export const useFilters = ({ gpus }: Args) => { }); const clearFilter = () => { - setSearchParams({}); + if (withSearchParams) { + setSearchParams({}); + } setPropertyFilterQuery(EMPTY_QUERY); setGroupBy([]); }; @@ -137,6 +140,10 @@ export const useFilters = ({ gpus }: Args) => { tokens: PropertyFilterProps.Query['tokens']; groupBy: MultiselectProps.Options; }) => { + if (!withSearchParams) { + return; + } + const searchParams = tokensToSearchParams(tokens); groupBy.forEach(({ value }) => searchParams.append(groupByRequestParamName, value as string)); diff --git a/frontend/src/pages/Offers/List/index.tsx b/frontend/src/pages/Offers/List/index.tsx index eef6204594..a0814b91a1 100644 --- a/frontend/src/pages/Offers/List/index.tsx +++ b/frontend/src/pages/Offers/List/index.tsx @@ -1,14 +1,13 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Cards, CardsProps, Header, Link, MultiselectCSD, PropertyFilter, StatusIndicator } from 'components'; +import { Cards, CardsProps, Link, MultiselectCSD, PropertyFilter, StatusIndicator } from 'components'; -import { useBreadcrumbs, useCollection } from 'hooks'; +import { useCollection } from 'hooks'; import { useGetGpusListQuery } from 'services/gpu'; import { useEmptyMessages } from './hooks/useEmptyMessages'; import { useFilters } from './hooks/useFilters'; -import { ROUTES } from '../../../routes'; import { convertMiBToGB, rangeToObject, renderRange, round } from './helpers'; import styles from './styles.module.scss'; @@ -67,17 +66,14 @@ const getRequestParams = ({ }; }; -export const OfferList = () => { +type OfferListProps = Pick & { + withSearchParams?: boolean; + onChangeProjectName?: (value: string) => void; +}; + +export const OfferList: React.FC = ({ withSearchParams, onChangeProjectName, ...props }) => { const { t } = useTranslation(); const [requestParams, setRequestParams] = useState(); - - useBreadcrumbs([ - { - text: t('offer.title'), - href: ROUTES.OFFERS.LIST, - }, - ]); - const { data, isLoading, isFetching } = useGetGpusListQuery( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error @@ -97,7 +93,7 @@ export const OfferList = () => { groupBy, groupByOptions, onChangeGroupBy, - } = useFilters({ gpus: data?.gpus ?? [] }); + } = useFilters({ gpus: data?.gpus ?? [], withSearchParams }); useEffect(() => { setRequestParams( @@ -110,6 +106,10 @@ export const OfferList = () => { ); }, [JSON.stringify(filteringRequestParams), groupBy]); + useEffect(() => { + onChangeProjectName?.(filteringRequestParams.project_name ?? ''); + }, [filteringRequestParams.project_name]); + const { renderEmptyMessage, renderNoMatchMessage } = useEmptyMessages({ clearFilter, projectNameSelected: Boolean(requestParams?.['project_name']), @@ -188,6 +188,7 @@ export const OfferList = () => { return ( {gpu.name}, @@ -196,8 +197,6 @@ export const OfferList = () => { loading={isLoading || isFetching} loadingText={t('common.loading')} stickyHeader={true} - header={
{t('offer.title')}
} - variant="full-page" filter={
diff --git a/frontend/src/pages/Offers/ListPage.tsx b/frontend/src/pages/Offers/ListPage.tsx new file mode 100644 index 0000000000..e8cd939f43 --- /dev/null +++ b/frontend/src/pages/Offers/ListPage.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useBreadcrumbs } from 'hooks'; +import { ROUTES } from 'routes'; + +import { Header } from '../../components'; +import { OfferList } from './List'; + +export const ListPage: React.FC = () => { + const { t } = useTranslation(); + + useBreadcrumbs([ + { + text: t('offer.title'), + href: ROUTES.OFFERS.LIST, + }, + ]); + + return {t('offer.title')}} />; +}; diff --git a/frontend/src/pages/Offers/index.ts b/frontend/src/pages/Offers/index.ts index f49062bea0..2f8cba53fb 100644 --- a/frontend/src/pages/Offers/index.ts +++ b/frontend/src/pages/Offers/index.ts @@ -1 +1 @@ -export { OfferList } from './List'; +export { ListPage as OfferList } from './ListPage'; diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx b/frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx new file mode 100644 index 0000000000..f8ae58895e --- /dev/null +++ b/frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +export const CONFIG_INFO = { + header:

Credits history

, + body: ( + <> +

Available for only the global admin role

+ + ), +}; diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/helpers/getRunSpecConfigurationResources.ts b/frontend/src/pages/Runs/CreateDevEnvironment/helpers/getRunSpecConfigurationResources.ts new file mode 100644 index 0000000000..81dac7f1da --- /dev/null +++ b/frontend/src/pages/Runs/CreateDevEnvironment/helpers/getRunSpecConfigurationResources.ts @@ -0,0 +1,92 @@ +const isVendor = (value: string) => ['amd', 'nvidia', 'google', 'tpu', 'intel'].includes(value); +const isMemory = (value: string) => /^\d+GB/.test(value); +const isCount = (value: string) => /^\d+(?:\.\.)*(?:\d+)*$/.test(value); + +const parseRange = (rangeString: string) => { + const [min, max] = rangeString.split('..'); + + if (!min && !max) { + return rangeString; + } + + const numberMin = parseInt(min, 10); + const numberMax = parseInt(max, 10); + + return { + ...(!isNaN(numberMin) ? { min: numberMin } : {}), + ...(!isNaN(numberMax) ? { max: numberMax } : {}), + }; +}; + +export const getRunSpecConfigurationResources = (json: unknown): TDevEnvironmentConfiguration['resources'] => { + const { gpu, cpu, memory, shm_size, disk } = (json ?? {}) as { [key: string]: string }; + const result: TDevEnvironmentConfiguration['resources'] = {}; + + let gpuResources: TGPUResources = {}; + + if (typeof gpu === 'string') { + const attributes = ((gpu as string) ?? '').split(':'); + + attributes.forEach((attribute, index) => { + if (isVendor(attribute)) { + gpuResources.vendor = attribute; + return; + } + + if (isMemory(attribute)) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + gpuResources.memory = parseRange(attribute); + return; + } + + if (isCount(attribute)) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + gpuResources.count = parseRange(attribute); + return; + } + + if (index < 2) { + gpuResources.name = attribute.split(','); + return; + } + }); + } else if (typeof gpu === 'object') { + gpuResources = gpu; + } + + result['gpu'] = gpuResources; + + if (memory && isMemory(memory)) { + result['memory'] = parseRange(memory); + } + + if (shm_size) { + const shmSizeNum = parseInt(shm_size, 10); + + if (!isNaN(shmSizeNum)) { + result['shm_size'] = shmSizeNum; + } else { + result['shm_size'] = shm_size; + } + } + + if (cpu) { + const cpuNum = parseInt(cpu, 10); + + if (!isNaN(cpuNum)) { + result['cpu'] = cpuNum; + } else { + result['cpu'] = cpu; + } + } + + if (disk && isMemory(disk)) { + result['disk'] = { + size: parseRange(disk), + }; + } + + return result; +}; diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/helpers/getRunSpecFromYaml.ts b/frontend/src/pages/Runs/CreateDevEnvironment/helpers/getRunSpecFromYaml.ts new file mode 100644 index 0000000000..a4d029c44e --- /dev/null +++ b/frontend/src/pages/Runs/CreateDevEnvironment/helpers/getRunSpecFromYaml.ts @@ -0,0 +1,71 @@ +import jsYaml from 'js-yaml'; + +import { getRunSpecConfigurationResources } from './getRunSpecConfigurationResources'; + +// TODO add next fields: volumes, repos, +const supportedFields: (keyof TDevEnvironmentConfiguration)[] = [ + 'type', + 'init', + 'inactivity_duration', + 'image', + 'user', + 'privileged', + 'entrypoint', + 'working_dir', + 'registry_auth', + 'python', + 'nvcc', + 'env', + 'docker', + 'backends', + 'regions', + 'instance_types', + 'spot_policy', + 'retry', + 'max_duration', + 'max_price', + 'idle_duration', + 'utilization_policy', + 'fleets', +]; + +export const getRunSpecFromYaml = async (yaml: string) => { + let parsedYaml; + + try { + parsedYaml = (await jsYaml.load(yaml)) as { [key: string]: unknown }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { + throw new Error(`Invalid YAML`); + } + + const { name, ...otherFields } = parsedYaml; + + const runSpec: TRunSpec = { + run_name: name as string, + configuration: {} as TDevEnvironmentConfiguration, + }; + + Object.keys(otherFields).forEach((fieldName) => { + switch (fieldName) { + case 'ide': + runSpec.configuration.ide = otherFields[fieldName] as TIde; + break; + case 'resources': + runSpec.configuration.resources = getRunSpecConfigurationResources(otherFields[fieldName]); + break; + default: + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + if (!supportedFields.includes(fieldName)) { + throw new Error(`Unsupported field: ${fieldName}`); + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + runSpec.configuration[fieldName] = otherFields[fieldName]; + return {}; + } + }); + + return runSpec; +}; diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx new file mode 100644 index 0000000000..bab507f8a6 --- /dev/null +++ b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx @@ -0,0 +1,351 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import cn from 'classnames'; +import * as yup from 'yup'; +import { Box, Link, WizardProps } from '@cloudscape-design/components'; +import { CardsProps } from '@cloudscape-design/components/cards'; + +import { Container, FormCodeEditor, FormField, FormInput, FormSelect, SpaceBetween, Wizard } from 'components'; + +import { useBreadcrumbs, useNotifications } from 'hooks'; +import { getServerError } from 'libs'; +import { ROUTES } from 'routes'; +import { useApplyRunMutation } from 'services/run'; + +import { OfferList } from 'pages/Offers/List'; +import { convertMiBToGB, renderRange, round } from 'pages/Offers/List/helpers'; + +import { getRunSpecFromYaml } from './helpers/getRunSpecFromYaml'; + +import { IRunEnvironmentFormValues } from './types'; + +import styles from './styles.module.scss'; + +const requiredFieldError = 'This is required field'; +const namesFieldError = 'Only latin characters, dashes, and digits'; + +const ideOptions = [ + { + label: 'Cursor', + value: 'cursor', + }, + { + label: 'VS Code', + value: 'vscode', + }, +]; + +const envValidationSchema = yup.object({ + offer: yup.object().required(requiredFieldError), + name: yup.string().matches(/^[a-z][a-z0-9-]{1,40}$/, namesFieldError), + ide: yup.string().required(requiredFieldError), + config_yaml: yup.string().required(requiredFieldError), +}); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +const useYupValidationResolver = (validationSchema) => + useCallback( + async (data: IRunEnvironmentFormValues) => { + 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 CreateDevEnvironment: React.FC = () => { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [pushNotification] = useNotifications(); + const [activeStepIndex, setActiveStepIndex] = useState(0); + const [selectedOffers, setSelectedOffers] = useState([]); + const [selectedProject, setSelectedProject] = useState( + () => searchParams.get('project_name') ?? null, + ); + + const [applyRun, { isLoading: isApplying }] = useApplyRunMutation(); + + const loading = isApplying; + + useBreadcrumbs([ + { + text: t('projects.runs'), + href: ROUTES.RUNS.LIST, + }, + { + text: t('runs.dev_env.wizard.title'), + href: ROUTES.RUNS.CREATE_DEV_ENV, + }, + ]); + + const resolver = useYupValidationResolver(envValidationSchema); + const formMethods = useForm({ + resolver, + defaultValues: { + ide: 'cursor', + }, + }); + const { handleSubmit, control, trigger, setValue, watch, formState, getValues } = formMethods; + const formValues = watch(); + + const onCancelHandler = () => { + navigate(ROUTES.RUNS.LIST); + }; + + const validateOffer = async () => { + return await trigger(['offer']); + }; + + const validateName = async () => { + return await trigger(['name', 'ide']); + }; + + const validateConfig = async () => { + return await trigger(['config_yaml']); + }; + + const emptyValidator = async () => Promise.resolve(true); + + const onNavigate = ({ + requestedStepIndex, + reason, + }: { + requestedStepIndex: number; + reason: WizardProps.NavigationReason; + }) => { + const stepValidators = [validateOffer, validateName, validateConfig, emptyValidator]; + + if (reason === 'next') { + stepValidators[activeStepIndex]?.().then((isValid) => { + if (isValid) { + setActiveStepIndex(requestedStepIndex); + } else if (activeStepIndex == 0) { + window.scrollTo(0, 0); + } + }); + } else { + setActiveStepIndex(requestedStepIndex); + } + }; + + const onNavigateHandler: WizardProps['onNavigate'] = ({ detail: { requestedStepIndex, reason } }) => { + onNavigate({ requestedStepIndex, reason }); + }; + + const onChangeOffer: CardsProps['onSelectionChange'] = ({ detail }) => { + const newSelectedOffers = detail?.selectedItems ?? []; + setSelectedOffers(newSelectedOffers); + setValue('offer', newSelectedOffers?.[0] ?? null); + }; + + const onSubmitWizard = async () => { + const isValid = await trigger(); + + if (!isValid) { + return; + } + + const { config_yaml } = getValues(); + + let runSpec; + + try { + runSpec = await getRunSpecFromYaml(config_yaml); + } catch (error) { + pushNotification({ + type: 'error', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + content: error?.message, + }); + + window.scrollTo(0, 0); + + return; + } + + const requestParams: TRunApplyRequestParams = { + project_name: selectedProject ?? '', + plan: { + run_spec: runSpec, + }, + force: false, + }; + + applyRun(requestParams) + .unwrap() + .then((data) => { + pushNotification({ + type: 'success', + content: t('runs.dev_env.wizard.success_notification'), + }); + + navigate(ROUTES.PROJECT.DETAILS.RUNS.DETAILS.FORMAT(data.project_name, data.id)); + }) + .catch((error) => { + pushNotification({ + type: 'error', + content: t('common.server_error', { error: getServerError(error) }), + }); + }); + }; + + const onSubmit = () => { + if (activeStepIndex < 3) { + onNavigate({ requestedStepIndex: activeStepIndex + 1, reason: 'next' }); + } else { + onSubmitWizard().catch(console.log); + } + }; + + useEffect(() => { + if (!formValues.offer || !formValues.ide) { + return; + } + + setValue( + 'config_yaml', + `type: dev-environment +${`${ + formValues.name + ? `name: ${formValues.name} + +` + : '' +}`}ide: ${formValues.ide} + +resources: + gpu: ${formValues.offer.name}:${round(convertMiBToGB(formValues.offer.memory_mib))}GB:${renderRange(formValues.offer.count)} + +backends: [${formValues.offer.backends?.join(', ')}] + +spot_policy: auto + `, + ); + }, [formValues.name, formValues.ide, formValues.offer]); + + return ( +
+ `Step ${stepNumber}`, + navigationAriaLabel: 'Steps', + cancelButton: t('common.cancel'), + previousButton: t('common.previous'), + nextButton: t('common.next'), + optional: 'optional', + }} + onCancel={onCancelHandler} + submitButtonText={t('runs.dev_env.wizard.submit')} + steps={[ + { + title: 'Resources', + content: ( + <> + + {formState.errors.offer?.message &&
} + setSelectedProject(projectName)} + selectionType="single" + withSearchParams={false} + selectedItems={selectedOffers} + onSelectionChange={onChangeOffer} + /> + + ), + }, + + { + title: 'Settings', + content: ( + + + + + + + ), + }, + + { + title: 'Configuration', + content: ( + + + Review the configuration file and adjust it if needed. See{' '} + + examples + + . + + } + name="config_yaml" + language="yaml" + loading={loading} + editorContentHeight={600} + /> + + ), + }, + ]} + /> + + ); +}; diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/styles.module.scss b/frontend/src/pages/Runs/CreateDevEnvironment/styles.module.scss new file mode 100644 index 0000000000..9939479f0d --- /dev/null +++ b/frontend/src/pages/Runs/CreateDevEnvironment/styles.module.scss @@ -0,0 +1,14 @@ +@use '@cloudscape-design/design-tokens/index' as awsui; + +.wizardForm { + & [class^="awsui_wizard"] { + & [class^="awsui_footer"] { + position: sticky; + bottom: 0; + background-color: awsui.$color-background-layout-main; + margin-block-start: 0 !important; + padding-top: awsui.$space-scaled-l; + z-index: 100; + } + } +} diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/types.ts b/frontend/src/pages/Runs/CreateDevEnvironment/types.ts new file mode 100644 index 0000000000..74e5da6bb1 --- /dev/null +++ b/frontend/src/pages/Runs/CreateDevEnvironment/types.ts @@ -0,0 +1,6 @@ +export interface IRunEnvironmentFormValues { + offer: IGpu; + name: string; + ide: 'cursor' | 'vscode'; + config_yaml: string; +} diff --git a/frontend/src/pages/Runs/Details/index.tsx b/frontend/src/pages/Runs/Details/index.tsx index 91fc135bda..f68c98fa17 100644 --- a/frontend/src/pages/Runs/Details/index.tsx +++ b/frontend/src/pages/Runs/Details/index.tsx @@ -37,10 +37,13 @@ export const RunDetailsPage: React.FC = () => { error: runError, isLoading, refetch, - } = useGetRunQuery({ - project_name: paramProjectName, - id: paramRunId, - }); + } = useGetRunQuery( + { + project_name: paramProjectName, + id: paramRunId, + }, + { pollingInterval: 10000 }, + ); useEffect(() => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/frontend/src/pages/Runs/List/index.tsx b/frontend/src/pages/Runs/List/index.tsx index 4f90e5430d..e9715a76b3 100644 --- a/frontend/src/pages/Runs/List/index.tsx +++ b/frontend/src/pages/Runs/List/index.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { ButtonDropdownProps } from '@cloudscape-design/components'; -import { Button, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } from 'components'; +import { Button, ButtonDropdown, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } from 'components'; import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; @@ -25,6 +27,7 @@ import styles from './styles.module.scss'; export const RunList: React.FC = () => { const { t } = useTranslation(); const [preferences] = useRunListPreferences(); + const navigate = useNavigate(); useBreadcrumbs([ { @@ -106,6 +109,14 @@ export const RunList: React.FC = () => { // deleteRuns([...selectedItems]).catch(console.log); // }; + const onFollowButtonDropdownLink: ButtonDropdownProps['onItemFollow'] = (event) => { + event.preventDefault(); + + if (event.detail.href) { + navigate(event.detail.href); + } + }; + return ( { variant="awsui-h1-sticky" actions={ + + {t('common.new')} + + diff --git a/frontend/src/pages/Runs/index.ts b/frontend/src/pages/Runs/index.ts index ef767068b6..5e30508fed 100644 --- a/frontend/src/pages/Runs/index.ts +++ b/frontend/src/pages/Runs/index.ts @@ -4,3 +4,4 @@ export { RunDetails } from './Details/RunDetails'; export { JobMetrics } from './Details/Jobs/Metrics'; export { JobLogs } from './Details/Logs'; export { Artifacts } from './Details/Artifacts'; +export { CreateDevEnvironment } from './CreateDevEnvironment'; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 13798ca737..63ed84117d 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -17,7 +17,7 @@ import { ModelDetails } from 'pages/Models/Details'; 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'; +import { CreateDevEnvironment, JobLogs, JobMetrics, RunDetails, RunDetailsPage, RunList } from 'pages/Runs'; import { JobDetailsPage } from 'pages/Runs/Details/Jobs/Details'; import { CreditsHistoryAdd, UserAdd, UserDetails, UserEdit, UserList } from 'pages/User'; import { UserBilling, UserProjects, UserSettings } from 'pages/User/Details'; @@ -144,6 +144,11 @@ export const router = createBrowserRouter([ element: , }, + { + 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 71f77066c0..b420fcec97 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -96,6 +96,7 @@ export const ROUTES = { RUNS: { LIST: '/runs', + CREATE_DEV_ENV: '/runs/create-dev-environment', }, OFFERS: { diff --git a/frontend/src/services/run.ts b/frontend/src/services/run.ts index 3dc00ac10e..600f0e5e62 100644 --- a/frontend/src/services/run.ts +++ b/frontend/src/services/run.ts @@ -57,6 +57,18 @@ export const runApi = createApi({ providesTags: (result) => (result ? [{ type: 'Runs' as const, id: result?.id }] : []), }), + applyRun: builder.mutation<{ id: string; project_name: string }, TRunApplyRequestParams>({ + query: ({ project_name, ...body }) => { + return { + url: API.PROJECTS.RUNS_APPLY(project_name ?? ''), + method: 'POST', + body, + }; + }, + + // providesTags: (result) => (result ? [{ type: 'Runs' as const, id: result?.id }] : []), + }), + stopRuns: builder.mutation({ query: ({ project_name, ...body }) => ({ url: API.PROJECTS.RUNS_STOP(project_name), @@ -169,6 +181,7 @@ export const { useGetRunsQuery, useLazyGetRunsQuery, useGetRunQuery, + useApplyRunMutation, useStopRunsMutation, useDeleteRunsMutation, useLazyGetModelsQuery, diff --git a/frontend/src/types/gpu.ts b/frontend/src/types/gpu.d.ts similarity index 100% rename from frontend/src/types/gpu.ts rename to frontend/src/types/gpu.d.ts diff --git a/frontend/src/types/run.d.ts b/frontend/src/types/run.d.ts index 2e613defb4..f43c5e1562 100644 --- a/frontend/src/types/run.d.ts +++ b/frontend/src/types/run.d.ts @@ -10,6 +10,118 @@ declare type TRunsRequestParams = { job_submissions_limit?: number; }; +declare type TGPUResources = IGPUSpecRequest & { + name?: string | string[]; +}; + +declare type TIde = 'cursor' | 'vscode'; + +declare type TVolumeMountPointRequest = { + name: string | string[]; + path: string; +}; + +declare type TInstanceMountPointRequest = { + instance_path: string; + path: string; + optional?: boolean; +}; + +declare type TEnvironmentConfigurationRepo = { + instance_path?: string; + url?: string; + path?: string; + branch?: string; + hash?: string; +}; + +declare type TFilePathMappingRequest = { + local_path: string; + path: string; +}; + +declare type ProfileRetryRequest = { + on_events?: string[]; + duration?: string | number; +}; + +declare type TDevEnvironmentConfiguration = { + type?: 'dev-environment'; + ide: TIde; + version?: string; + init?: string[]; + inactivity_duration?: string | number | boolean | 'off'; + ports?: number[] | string[]; + name?: string; + image?: string; + user?: string; + privileged?: boolean; + entrypoint?: string; + working_dir?: string; + home_dir?: string; + registry_auth?: { + username: string; + password: string; + }; + python?: string; + nvcc?: boolean; + single_branch?: boolean; + env?: string[]; + shell?: string; + resources?: { + gpu?: TGPUResources | string | number; + cpu?: string | number | { min?: number; max?: number }; + memory?: string | number | { min?: number; max?: number }; + shm_size?: string | number; + disk?: + | string + | number + | { + size?: string | number | { min?: number; max?: number }; + }; + }; + priority?: number; + volumes?: Array; + docker?: boolean; + repos?: TEnvironmentConfigurationRepo[]; + files?: Array; + setup?: string[]; + backends?: string[]; + regions?: string[]; + availability_zones?: string[]; + instance_types?: string[]; + reservation?: string; + spot_policy?: TSpotPolicy; + retry?: ProfileRetryRequest | string; + max_duration?: number | string | boolean; + stop_duration?: number | string | boolean; + max_price?: number; + creation_policy?: 'reuse' | 'reuse-or-create'; + idle_duration?: number | string; + utilization_policy?: { + min_gpu_utilization: number; + time_window: string | number; + }; + startup_order?: string; + stop_criteria?: string; + schedule?: { cron: string | string[] }; + fleets?: string[]; + tags?: object; +}; + +declare type TRunSpec = { + run_name: string; + configuration: TDevEnvironmentConfiguration; + ssh_key_pub?: string; +}; +declare type TRunApplyRequestParams = { + project_name: string; + plan: { + run_spec: TRunSpec; + }; + force: boolean; +}; + declare type TDeleteRunsRequestParams = { project_name: IProject['project_name']; runs_names: IRun['run_name'][]; @@ -127,23 +239,18 @@ declare interface IJob { job_submissions: IJobSubmission[]; } -declare interface IDevEnvironmentConfiguration { - type: 'dev-environment'; - priority?: number | null -} - declare interface ITaskConfiguration { type: 'task'; - priority?: number | null + priority?: number | null; } declare interface IServiceConfiguration { type: 'service'; gateway: string | null; - priority?: number | null + priority?: number | null; } declare interface IRunSpec { - configuration: IDevEnvironmentConfiguration | ITaskConfiguration | IServiceConfiguration; + configuration: TDevEnvironmentConfiguration | ITaskConfiguration | IServiceConfiguration; configuration_path: string; repo_code_hash?: string; repo_id: string;