diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b0266e74e..aace68a15 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -70,6 +70,8 @@ export const API = { // Repos REPOS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/repos`, REPOS_LIST: (projectName: IProject['project_name']) => `${API.PROJECTS.REPOS(projectName)}/list`, + GET_REPO: (projectName: IProject['project_name']) => `${API.PROJECTS.REPOS(projectName)}/get`, + INIT_REPO: (projectName: IProject['project_name']) => `${API.PROJECTS.REPOS(projectName)}/init`, // Runs RUNS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/runs`, diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index bfe834152..70d240a25 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -46,6 +46,7 @@ export { default as HelpPanel } from '@cloudscape-design/components/help-panel'; export type { HelpPanelProps } from '@cloudscape-design/components/help-panel'; export { default as TextContent } from '@cloudscape-design/components/text-content'; export { default as Toggle } from '@cloudscape-design/components/toggle'; +export type { ToggleProps } from '@cloudscape-design/components/toggle'; export { default as Modal } from '@cloudscape-design/components/modal'; export { default as TutorialPanel } from '@cloudscape-design/components/tutorial-panel'; export type { TutorialPanelProps } from '@cloudscape-design/components/tutorial-panel'; diff --git a/frontend/src/libs/repo.ts b/frontend/src/libs/repo.ts new file mode 100644 index 000000000..8f82f6f37 --- /dev/null +++ b/frontend/src/libs/repo.ts @@ -0,0 +1,39 @@ +function bufferToHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +export async function slugify(prefix: string, unique_key: string, hash_size: number = 8): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(unique_key); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const fullHash = bufferToHex(hashBuffer); + return `${prefix}-${fullHash.substring(0, hash_size)}`; +} + +export function getRepoName(url: string): string { + const cleaned = url + .replace(/^https?:\/\//i, '') + .replace(/:\/(\S*)/, '') + .replace(/\/+$/, '') + .replace(/\.git$/, ''); + const parts = cleaned.split('/').filter(Boolean); + return parts.length ? parts[parts.length - 1] : ''; +} + +export function getPathWithoutProtocol(url: string): string { + return url.replace(/^https?:\/\//i, ''); +} + +export function getRepoUrlWithOutDir(url: string): string { + const parsedUrl = url.match(/^([^:]+(?::[^:]+)?)/)?.[1]; + + return parsedUrl ?? url; +} + +export function getRepoDirFromUrl(url: string): string | undefined { + const dirName = url.replace(/^https?:\/\//i, '').match(/:\/(\S*)/)?.[1]; + + return dirName ? `/${dirName}` : undefined; +} diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 7d32a7545..d4334bbfc 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -474,10 +474,32 @@ "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_description": "The name of the run, e.g. 'my-dev-env'", + "name_constraint": "If not specified, generated automatically", "name_placeholder": "Optional", "ide": "IDE", "ide_description": "Select which IDE would you like to use with the dev environment.", + "docker": "Docker", + "docker_image": "Image", + "docker_image_description": "A Docker image name, e.g. 'lmsysorg/sglang:latest'", + "docker_image_constraint": "The image must be public", + "docker_image_placeholder": "Required", + "python": "Python", + "python_description": "The version of Python, e.g. '3.12'", + "python_placeholder": "Optional", + "repo": "Repo", + "working_dir": "Working dir", + "working_dir_description": "The absolute path to the working directory inside the container, e.g. '/home/user/project'", + "working_dir_placeholder": "Optional", + "working_dir_constraint": "By default, set to '/workflow'", + "repo_url": "URL", + "repo_url_description": "A URL of a Git repository, e.g. 'https://github.com/user/repo'", + "repo_url_constraint": "The repo must be public", + "repo_url_placeholder": "Required", + "repo_path": "Path", + "repo_path_description": "The path inside the container to clone the repository, e.g. '/home/user/project'", + "repo_path_placeholder": "Optional", + "repo_path_constraint": "By default, set to '/workflow'", "config": "Configuration file", "config_description": "Review the configuration file and adjust it if needed. Click Info for examples.", "success_notification": "The run is submitted!" diff --git a/frontend/src/pages/Offers/List/index.tsx b/frontend/src/pages/Offers/List/index.tsx index a0814b91a..462562569 100644 --- a/frontend/src/pages/Offers/List/index.tsx +++ b/frontend/src/pages/Offers/List/index.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Cards, CardsProps, Link, MultiselectCSD, PropertyFilter, StatusIndicator } from 'components'; +import { Cards, CardsProps, MultiselectCSD, PropertyFilter, StatusIndicator } from 'components'; import { useCollection } from 'hooks'; import { useGetGpusListQuery } from 'services/gpu'; @@ -132,31 +132,31 @@ export const OfferList: React.FC = ({ withSearchParams, onChange const sections = [ { id: 'memory_mib', - header: t('offer.memory_mib'), - content: (gpu: IGpu) => `${round(convertMiBToGB(gpu.memory_mib))}GB`, + // header: t('offer.memory_mib'), + content: (gpu: IGpu) => `${round(convertMiBToGB(gpu.memory_mib))}GB:${renderRange(gpu.count)}`, width: 50, }, { id: 'price', - header: t('offer.price'), - content: (gpu: IGpu) => {renderRange(gpu.price) ?? '-'}, - width: 50, - }, - { - id: 'count', - header: t('offer.count'), - content: (gpu: IGpu) => renderRange(gpu.count) ?? '-', + // header: t('offer.price'), + content: (gpu: IGpu) => ${renderRange(gpu.price) ?? '-'}, width: 50, }, + // { + // id: 'count', + // header: t('offer.count'), + // content: (gpu: IGpu) => renderRange(gpu.count) ?? '-', + // width: 50, + // }, !groupByBackend && { id: 'backends', - header: t('offer.backend_plural'), + // header: t('offer.backend_plural'), content: (gpu: IGpu) => gpu.backends?.join(', ') ?? '-', width: 50, }, groupByBackend && { id: 'backend', - header: t('offer.backend'), + // header: t('offer.backend'), content: (gpu: IGpu) => gpu.backend ?? '-', width: 50, }, @@ -168,7 +168,7 @@ export const OfferList: React.FC = ({ withSearchParams, onChange // }, { id: 'spot', - header: t('offer.spot'), + // header: t('offer.spot'), content: (gpu: IGpu) => gpu.spot.join(', ') ?? '-', width: 50, }, @@ -189,9 +189,10 @@ export const OfferList: React.FC = ({ withSearchParams, onChange {gpu.name}, + header: (gpu) => gpu.name, sections, }} loading={isLoading || isFetching} diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx b/frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx index f8ae58895..15593b6df 100644 --- a/frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx +++ b/frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx @@ -1,4 +1,6 @@ import React from 'react'; + +import { IRunEnvironmentFormKeys } from './types'; export const CONFIG_INFO = { header:

Credits history

, body: ( @@ -7,3 +9,17 @@ export const CONFIG_INFO = { ), }; + +export const FORM_FIELD_NAMES = { + offer: 'offer', + name: 'name', + ide: 'ide', + config_yaml: 'config_yaml', + docker: 'docker', + image: 'image', + python: 'python', + repo_enabled: 'repo_enabled', + repo_url: 'repo_url', + repo_path: 'repo_path', + working_dir: 'working_dir', +} as const satisfies Record; diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/helpers/getRunSpecFromYaml.ts b/frontend/src/pages/Runs/CreateDevEnvironment/helpers/getRunSpecFromYaml.ts deleted file mode 100644 index a4d029c44..000000000 --- a/frontend/src/pages/Runs/CreateDevEnvironment/helpers/getRunSpecFromYaml.ts +++ /dev/null @@ -1,71 +0,0 @@ -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/hooks/useGenerateYaml.ts b/frontend/src/pages/Runs/CreateDevEnvironment/hooks/useGenerateYaml.ts new file mode 100644 index 000000000..a693985fc --- /dev/null +++ b/frontend/src/pages/Runs/CreateDevEnvironment/hooks/useGenerateYaml.ts @@ -0,0 +1,52 @@ +import { useMemo } from 'react'; +import jsYaml from 'js-yaml'; + +import { convertMiBToGB, renderRange, round } from 'pages/Offers/List/helpers'; + +import { IRunEnvironmentFormValues } from '../types'; + +export type UseGenerateYamlArgs = { + formValues: IRunEnvironmentFormValues; +}; + +export const useGenerateYaml = ({ formValues }: UseGenerateYamlArgs) => { + return useMemo(() => { + if (!formValues.offer || !formValues.ide) { + return ''; + } + + const { name, ide, image, python, offer, docker, repo_url, repo_path, working_dir } = formValues; + + return jsYaml.dump({ + type: 'dev-environment', + ...(name ? { name } : {}), + ide, + ...(docker ? { docker } : {}), + ...(image ? { image } : {}), + ...(python ? { python } : {}), + + resources: { + gpu: `${offer.name}:${round(convertMiBToGB(offer.memory_mib))}GB:${renderRange(offer.count)}`, + }, + + ...(repo_url || repo_path + ? { + repos: [[repo_url?.trim(), repo_path?.trim()].filter(Boolean).join(':')], + } + : {}), + + ...(working_dir ? { working_dir } : {}), + backends: offer.backends, + spot_policy: 'auto', + }); + }, [ + formValues.name, + formValues.ide, + formValues.offer, + formValues.python, + formValues.image, + formValues.repo_url, + formValues.repo_path, + formValues.working_dir, + ]); +}; diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/hooks/useGetRunSpecFromYaml.ts b/frontend/src/pages/Runs/CreateDevEnvironment/hooks/useGetRunSpecFromYaml.ts new file mode 100644 index 000000000..649db570b --- /dev/null +++ b/frontend/src/pages/Runs/CreateDevEnvironment/hooks/useGetRunSpecFromYaml.ts @@ -0,0 +1,147 @@ +import { useCallback } from 'react'; +import jsYaml from 'js-yaml'; + +import { useNotifications } from 'hooks'; +import { useInitRepoMutation, useLazyGetRepoQuery } from 'services/repo'; + +import { getPathWithoutProtocol, getRepoDirFromUrl, getRepoName, getRepoUrlWithOutDir, slugify } from '../../../../libs/repo'; +import { getRunSpecConfigurationResources } from '../helpers/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', + 'repos', +]; + +export const useGetRunSpecFromYaml = ({ projectName = '' }) => { + const [pushNotification] = useNotifications(); + const [getRepo] = useLazyGetRepoQuery(); + const [initRepo] = useInitRepoMutation(); + + const getRepoData = useCallback( + async (repos: string[]) => { + const [firstRepo] = repos; + + if (!firstRepo) { + return {}; + } + + const repoUrlWithoutDir = getRepoUrlWithOutDir(firstRepo); + const prefix = getRepoName(repoUrlWithoutDir); + const uniqKey = getPathWithoutProtocol(repoUrlWithoutDir); + const repoId = await slugify(prefix, uniqKey); + const repoDir = getRepoDirFromUrl(firstRepo); + + try { + await getRepo({ project_name: projectName, repo_id: repoId, include_creds: true }).unwrap(); + } catch (_) { + initRepo({ + project_name: projectName, + repo_id: repoId, + repo_info: { + repo_type: 'remote', + repo_name: prefix, + }, + repo_creds: { clone_url: repoUrlWithoutDir, private_key: null, oauth_token: null }, + }) + .unwrap() + .catch(console.error); + } + + return { + repo_id: repoId, + repo_data: { + repo_type: 'remote', + repo_name: prefix, + repo_branch: null, + repo_hash: null, + repo_config_name: null, + repo_config_email: null, + }, + repo_code_hash: null, + repo_dir: repoDir ?? null, + }; + }, + [projectName, getRepo, initRepo], + ); + + const getRunSpecFromYaml = useCallback( + 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 (_) { + pushNotification({ + type: 'error', + content: 'Invalid YAML', + }); + + window.scrollTo(0, 0); + + throw new Error('Invalid YAML'); + } + + const { name, ...otherFields } = parsedYaml; + + const runSpec: TRunSpec = { + run_name: name as string, + configuration: {} as TDevEnvironmentConfiguration, + }; + + for (const fieldName of Object.keys(otherFields)) { + switch (fieldName) { + case 'ide': + runSpec.configuration.ide = otherFields[fieldName] as TIde; + break; + case 'resources': + runSpec.configuration.resources = getRunSpecConfigurationResources(otherFields[fieldName]); + break; + case 'repos': { + const repoData = await getRepoData(otherFields['repos'] as TEnvironmentConfigurationRepo[]); + Object.assign(runSpec, repoData); + 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]; + break; + } + } + + return runSpec; + }, + [pushNotification, getRepoData], + ); + + return [getRunSpecFromYaml]; +}; diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx index bab507f8a..567304675 100644 --- a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx +++ b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx @@ -7,7 +7,8 @@ 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 type { TabsProps, ToggleProps } from 'components'; +import { Container, FormCodeEditor, FormField, FormInput, FormSelect, SpaceBetween, Tabs, Toggle, Wizard } from 'components'; import { useBreadcrumbs, useNotifications } from 'hooks'; import { getServerError } from 'libs'; @@ -15,16 +16,19 @@ 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 { useGenerateYaml } from './hooks/useGenerateYaml'; +import { useGetRunSpecFromYaml } from './hooks/useGetRunSpecFromYaml'; +import { FORM_FIELD_NAMES } from './constants'; -import { IRunEnvironmentFormValues } from './types'; +import { IRunEnvironmentFormKeys, IRunEnvironmentFormValues } from './types'; import styles from './styles.module.scss'; -const requiredFieldError = 'This is required field'; +const requiredFieldError = 'This is a required field'; const namesFieldError = 'Only latin characters, dashes, and digits'; +const urlFormatError = 'Only URLs'; +const workingDirFormatError = 'Must be an absolute path'; const ideOptions = [ { @@ -37,11 +41,30 @@ const ideOptions = [ }, ]; +enum DockerPythonTabs { + DOCKER = 'docker', + PYTHON = 'python', +} + 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), + working_dir: yup.string().matches(/^\//, workingDirFormatError), + + image: yup.string().when('docker', { + is: true, + then: yup.string().required(requiredFieldError), + }), + + repo_url: yup.string().when('repo_enabled', { + is: true, + then: yup + .string() + .matches(/^(https?):\/\/([^\s\/?#]+)((?:\/[^\s?#]*)*)(?::\/(.*))?$/i, urlFormatError) + .required(requiredFieldError), + }), }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -92,6 +115,8 @@ export const CreateDevEnvironment: React.FC = () => { () => searchParams.get('project_name') ?? null, ); + const [getRunSpecFromYaml] = useGetRunSpecFromYaml({ projectName: selectedProject }); + const [applyRun, { isLoading: isApplying }] = useApplyRunMutation(); const loading = isApplying; @@ -112,6 +137,8 @@ export const CreateDevEnvironment: React.FC = () => { resolver, defaultValues: { ide: 'cursor', + docker: false, + repo_enabled: false, }, }); const { handleSubmit, control, trigger, setValue, watch, formState, getValues } = formMethods; @@ -125,16 +152,18 @@ export const CreateDevEnvironment: React.FC = () => { return await trigger(['offer']); }; - const validateName = async () => { - return await trigger(['name', 'ide']); + const validateSecondStep = async () => { + const secondStepFields = Object.keys(FORM_FIELD_NAMES).filter( + (fieldName) => !['offer', 'config_yaml'].includes(fieldName), + ) as IRunEnvironmentFormKeys[]; + + return await trigger(secondStepFields); }; const validateConfig = async () => { return await trigger(['config_yaml']); }; - const emptyValidator = async () => Promise.resolve(true); - const onNavigate = ({ requestedStepIndex, reason, @@ -142,7 +171,7 @@ export const CreateDevEnvironment: React.FC = () => { requestedStepIndex: number; reason: WizardProps.NavigationReason; }) => { - const stepValidators = [validateOffer, validateName, validateConfig, emptyValidator]; + const stepValidators = [validateOffer, validateSecondStep, validateConfig]; if (reason === 'next') { stepValidators[activeStepIndex]?.().then((isValid) => { @@ -161,6 +190,27 @@ export const CreateDevEnvironment: React.FC = () => { onNavigate({ requestedStepIndex, reason }); }; + const toggleRepo: ToggleProps['onChange'] = ({ detail }) => { + setValue('repo_enabled', detail.checked); + + if (!detail.checked) { + setValue('repo_url', ''); + setValue('repo_path', ''); + } + }; + + const onChangeTab: TabsProps['onChange'] = ({ detail }) => { + if (detail.activeTabId === DockerPythonTabs.DOCKER) { + setValue('python', ''); + } + + if (detail.activeTabId === DockerPythonTabs.PYTHON) { + setValue('image', ''); + } + + setValue('docker', detail.activeTabId === DockerPythonTabs.DOCKER); + }; + const onChangeOffer: CardsProps['onSelectionChange'] = ({ detail }) => { const newSelectedOffers = detail?.selectedItems ?? []; setSelectedOffers(newSelectedOffers); @@ -180,16 +230,8 @@ export const CreateDevEnvironment: React.FC = () => { 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); - + } catch (e) { + console.log('parse transaction error:', e); return; } @@ -227,31 +269,11 @@ export const CreateDevEnvironment: React.FC = () => { } }; - useEffect(() => { - if (!formValues.offer || !formValues.ide) { - return; - } - - setValue( - 'config_yaml', - `type: dev-environment -${`${ - formValues.name - ? `name: ${formValues.name} - -` - : '' -}`}ide: ${formValues.ide} + const yaml = useGenerateYaml({ formValues }); -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]); + useEffect(() => { + setValue('config_yaml', yaml); + }, [yaml]); return (
@@ -299,11 +321,13 @@ spot_policy: auto + + + + + + ), + }, + { + label: t('runs.dev_env.wizard.docker'), + id: DockerPythonTabs.DOCKER, + content: ( +
+ +
+ ), + }, + ]} + /> + + + + + {t('runs.dev_env.wizard.repo')} + + + {formValues.repo_enabled && ( + <> + + + + + )} ), diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/types.ts b/frontend/src/pages/Runs/CreateDevEnvironment/types.ts index 74e5da6bb..020d40e59 100644 --- a/frontend/src/pages/Runs/CreateDevEnvironment/types.ts +++ b/frontend/src/pages/Runs/CreateDevEnvironment/types.ts @@ -3,4 +3,13 @@ export interface IRunEnvironmentFormValues { name: string; ide: 'cursor' | 'vscode'; config_yaml: string; + docker: boolean; + image?: string; + python?: string; + repo_enabled?: boolean; + repo_url?: string; + repo_path?: string; + working_dir?: string; } + +export type IRunEnvironmentFormKeys = keyof Required; diff --git a/frontend/src/pages/Runs/Details/RunDetails/ConnectToRunWithDevEnvConfiguration/index.tsx b/frontend/src/pages/Runs/Details/RunDetails/ConnectToRunWithDevEnvConfiguration/index.tsx index 36bfa2379..4176996ea 100644 --- a/frontend/src/pages/Runs/Details/RunDetails/ConnectToRunWithDevEnvConfiguration/index.tsx +++ b/frontend/src/pages/Runs/Details/RunDetails/ConnectToRunWithDevEnvConfiguration/index.tsx @@ -27,7 +27,7 @@ export const ConnectToRunWithDevEnvConfiguration: FC<{ run: IRun }> = ({ run }) const { t } = useTranslation(); const getAttachCommand = (runData: IRun) => { - const attachCommand = `dstack attach ${runData.run_spec.run_name}`; + const attachCommand = `dstack attach ${runData.run_spec.run_name} --logs`; const copyAttachCommand = () => { copyToClipboard(attachCommand); @@ -233,7 +233,7 @@ export const ConnectToRunWithDevEnvConfiguration: FC<{ run: IRun }> = ({ run }) /> )} - {run.status === 'running' && ( + {run.status !== 'running' && ( Waiting for the run to start. diff --git a/frontend/src/services/repo.ts b/frontend/src/services/repo.ts new file mode 100644 index 000000000..47a68bd6d --- /dev/null +++ b/frontend/src/services/repo.ts @@ -0,0 +1,41 @@ +import { API } from 'api'; +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; + +import fetchBaseQueryHeaders from 'libs/fetchBaseQueryHeaders'; + +import { TGetRepoResponse, TInitRepoRequestParams } from '../types/repo'; + +export const repoApi = createApi({ + reducerPath: 'repoApi', + baseQuery: fetchBaseQuery({ + prepareHeaders: fetchBaseQueryHeaders, + }), + + tagTypes: ['Repos'], + + endpoints: (builder) => ({ + getRepo: builder.query({ + query: ({ project_name, ...body }) => { + return { + url: API.PROJECTS.GET_REPO(project_name), + body, + method: 'POST', + }; + }, + + providesTags: (result) => (result ? [{ type: 'Secrets' as const, id: result.repo_id }, 'Repos'] : ['Repos']), + }), + + initRepo: builder.mutation({ + query: ({ project_name, ...body }) => ({ + url: API.PROJECTS.INIT_REPO(project_name), + method: 'POST', + body, + }), + + invalidatesTags: () => ['Secrets'], + }), + }), +}); + +export const { useLazyGetRepoQuery, useInitRepoMutation } = repoApi; diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 326f113ad..8465544d4 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -9,6 +9,7 @@ import { gatewayApi } from 'services/gateway'; import { instanceApi } from 'services/instance'; import { mainApi } from 'services/mainApi'; import { projectApi } from 'services/project'; +import { repoApi } from 'services/repo'; import { runApi } from 'services/run'; import { secretApi } from 'services/secrets'; import { serverApi } from 'services/server'; @@ -35,6 +36,7 @@ export const store = configureStore({ [volumeApi.reducerPath]: volumeApi.reducer, [secretApi.reducerPath]: secretApi.reducer, [gpuApi.reducerPath]: gpuApi.reducer, + [repoApi.reducerPath]: repoApi.reducer, [mainApi.reducerPath]: mainApi.reducer, }, @@ -54,6 +56,7 @@ export const store = configureStore({ .concat(volumeApi.middleware) .concat(secretApi.middleware) .concat(gpuApi.middleware) + .concat(repoApi.middleware) .concat(mainApi.middleware), }); diff --git a/frontend/src/types/repo.d.ts b/frontend/src/types/repo.d.ts index f90a7f8d7..3108d7e0a 100644 --- a/frontend/src/types/repo.d.ts +++ b/frontend/src/types/repo.d.ts @@ -4,25 +4,39 @@ enum RepoTypeEnum { } declare interface IRemoteRunRepoData { - repo_type: 'remote' - repo_name: string - repo_branch?: string - repo_hash?: string - repo_diff?: string - repo_config_name?: string - repo_config_email?: string + repo_type: 'remote'; + repo_name: string; + repo_branch?: string; + repo_hash?: string; + repo_diff?: string; + repo_config_name?: string; + repo_config_email?: string; } declare interface ILocalRunRepoData { - repo_type: 'local' - repo_dir: string + repo_type: 'local'; + repo_dir: string; } declare interface VirtualRunRepoData { - repo_type: 'virtual' + repo_type: 'virtual'; +} + +declare interface RepoCreds { + clone_url: string; + private_key: string | null; + oauth_token: string | null; } declare interface IRepo { - repo_id: string, - repo_info: IRemoteRunRepoData | ILocalRunRepoData | VirtualRunRepoData + repo_id: string; + repo_info: IRemoteRunRepoData | ILocalRunRepoData | VirtualRunRepoData; } + +declare type TGetRepoResponse = IRepo & RepoCreds; + +declare type TInitRepoRequestParams = { + repo_id: string; + repo_info: IRemoteRunRepoData; + repo_creds: RepoCreds; +};