Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/libs/repo.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;
}
17 changes: 17 additions & 0 deletions frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,23 @@
"name_placeholder": "Optional",
"ide": "IDE",
"ide_description": "Select which IDE would you like to use with the dev environment.",
"docker": "Docker",
"docker_image": "Docker image",
"docker_image_description": "The Docker image",
"docker_image_placeholder": "Required",
"python": "Python",
"python_description": "Version of python",
"python_placeholder": "Optional",
"repo": "Repo",
"working_dir": "Working dir",
"working_dir_description": "Working dir",
"working_dir_placeholder": "Optional",
"repo_url": "URL",
"repo_url_description": "Repository URL",
"repo_url_placeholder": "Required",
"repo_path": "Repo path",
"repo_path_description": "Repo path name",
"repo_path_placeholder": "Optional",
"config": "Configuration file",
"config_description": "Review the configuration file and adjust it if needed. Click Info for examples.",
"success_notification": "The run is submitted!"
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/pages/Runs/CreateDevEnvironment/constants.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import React from 'react';

import { IRunEnvironmentFormKeys } from './types';
export const CONFIG_INFO = {
header: <h2>Credits history</h2>,
body: (
Expand All @@ -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<IRunEnvironmentFormKeys, IRunEnvironmentFormKeys>;

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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,
]);
};
Original file line number Diff line number Diff line change
@@ -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];
};
Loading