Skip to content

Commit

Permalink
feat: Create template through WebUI (#9263)
Browse files Browse the repository at this point in the history
  • Loading branch information
gt2345 authored May 1, 2024
1 parent abcc7b4 commit b78020d
Show file tree
Hide file tree
Showing 6 changed files with 222 additions and 11 deletions.
24 changes: 24 additions & 0 deletions webui/react/src/hooks/usePermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ interface PermissionsHook {
canCreateModels: boolean;
canCreateNSC: boolean;
canCreateProject: (arg0: WorkspacePermissionsArgs) => boolean;
canCreateTemplate: boolean;
canCreateTemplateWorkspace: (args0: WorkspacePermissionsArgs) => boolean;
canCreateWorkspace: boolean;
canCreateWorkspaceNSC(arg0: WorkspacePermissionsArgs): boolean;
canDeleteExperiment: (arg0: ExperimentPermissionsArgs) => boolean;
Expand Down Expand Up @@ -127,6 +129,9 @@ const usePermissions = (): PermissionsHook => {
canCreateNSC: canCreateNSC(rbacOpts),
canCreateProject: (args: WorkspacePermissionsArgs) =>
canCreateProject(rbacOpts, args.workspace),
canCreateTemplate: canCreateTemplate(rbacOpts),
canCreateTemplateWorkspace: (args: WorkspacePermissionsArgs) =>
canCreateTemplateWorkspace(rbacOpts, args.workspace!.id),
canCreateWorkspace: canCreateWorkspace(rbacOpts),
canCreateWorkspaceNSC: (args: WorkspacePermissionsArgs) =>
canCreateWorkspaceNSC(rbacOpts, args.workspace),
Expand Down Expand Up @@ -399,6 +404,25 @@ const canModifyModelVersion = (
return !rbacEnabled || permitted.has(V1PermissionType.EDITMODELREGISTRY);
};

// Template actions
const canCreateTemplate = ({ rbacEnabled, userRoles }: RbacOptsProps): boolean => {
return (
!rbacEnabled ||
(!!userRoles &&
!!userRoles.find(
(r) => !!r.permissions.find((p) => p.id === V1PermissionType.CREATETEMPLATES),
))
);
};

const canCreateTemplateWorkspace = (
{ rbacEnabled, userAssignments, userRoles }: RbacOptsProps,
workspaceId: number,
): boolean => {
const permitted = relevantPermissions(userAssignments, userRoles, workspaceId);
return !rbacEnabled || permitted.has(V1PermissionType.CREATETEMPLATES);
};

// Project actions
// Currently the smallest scope is workspace
const canCreateProject = (
Expand Down
152 changes: 152 additions & 0 deletions webui/react/src/pages/Templates/TemplateCreateModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import Alert from 'hew/Alert';
import CodeEditor from 'hew/CodeEditor';
import Form from 'hew/Form';
import Input from 'hew/Input';
import { Modal } from 'hew/Modal';
import Select, { Option } from 'hew/Select';
import { useToast } from 'hew/Toast';
import { Loadable } from 'hew/utils/loadable';
import yaml from 'js-yaml';
import { useObservable } from 'micro-observables';
import React, { useCallback, useId, useState } from 'react';

import { createTaskTemplate } from 'services/api';
import workspaceStore from 'stores/workspaces';
import { Workspace } from 'types';
import handleError, { DetError, ErrorLevel, ErrorType } from 'utils/error';

const FORM_ID = 'create-template-form';

interface FormInputs {
name: string;
workspaceId: number;
config: string;
}

interface Props {
workspaceId?: number;
}

const TemplateCreateModalComponent: React.FC<Props> = ({ workspaceId }) => {
const idPrefix = useId();
const { openToast } = useToast();
const [form] = Form.useForm<FormInputs>();
const [disabled, setDisabled] = useState<boolean>(true);
const workspaces = Loadable.getOrElse([], useObservable(workspaceStore.workspaces));

const onChange = useCallback(() => {
const fields = form.getFieldsError();
const hasError = fields.some((f) => f.errors.length);
setDisabled(hasError);
}, [form]);

const handleSubmit = useCallback(async () => {
const values = await form.validateFields();

try {
if (values) {
await createTaskTemplate({
...values,
config: yaml.load(values.config),
});
form.resetFields();
openToast({
description: `Template ${values.name} has been created`,
severity: 'Info',
title: 'Template Created',
});
}
} catch (e) {
if (e instanceof DetError) {
handleError(e, {
level: e.level,
publicMessage: e.publicMessage,
publicSubject: 'Unable to create template.',
silent: false,
type: e.type,
});
} else {
handleError(e, {
level: ErrorLevel.Error,
publicMessage: 'Please try again later.',
publicSubject: 'Unable to create template.',
silent: false,
type: ErrorType.Server,
});
}
}
}, [form, openToast]);

return (
<Modal
cancel
size="small"
submit={{
disabled,
form: idPrefix + FORM_ID,
handleError,
handler: handleSubmit,
text: 'Create Template',
}}
title="New Template">
<Form
autoComplete="off"
form={form}
id={idPrefix + FORM_ID}
layout="vertical"
onFieldsChange={onChange}>
<Form.Item
label="Name"
name="name"
rules={[{ message: 'Name is required.', required: true }]}>
<Input />
</Form.Item>
<Form.Item
initialValue={workspaceId}
label="Workspace"
name="workspaceId"
rules={[{ message: 'Workspace is required', required: true, type: 'number' }]}>
<Select allowClear disabled={!!workspaceId} placeholder="Workspace (required)">
{workspaces.map((workspace: Workspace) => (
<Option key={workspace.id} value={workspace.id}>
{workspace.name}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label="Config"
name="config"
rules={[
{ message: 'Template content is required', required: true },
{
validator: (_rule, value) => {
try {
yaml.load(value);
return Promise.resolve();
} catch (err: unknown) {
return Promise.reject(
new Error(
`Invalid YAML on line ${(err as { mark: { line: string } }).mark.line}.`,
),
);
}
},
},
]}>
<CodeEditor
file={''}
files={[{ key: 'template.yaml' }]}
height="40vh"
onError={handleError}
/>
</Form.Item>
{form.getFieldError('config').length > 0 && (
<Alert message={form.getFieldError('config').join('\n')} type="error" />
)}
</Form>
</Modal>
);
};

export default TemplateCreateModalComponent;
41 changes: 31 additions & 10 deletions webui/react/src/pages/Templates/TemplatesPage.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,44 @@
import Button from 'hew/Button';
import { useModal } from 'hew/Modal';
import Row from 'hew/Row';
import React, { useRef } from 'react';

import Page from 'components/Page';
import usePermissions from 'hooks/usePermissions';
import { paths } from 'routes/utils';

const TemplatesPage: React.FC = () => {
const pageRef = useRef<HTMLElement>(null);
import TemplateCreateModalComponent from './TemplateCreateModal';

interface Props {
workspaceId?: number;
}

const TemplatesPage: React.FC<Props> = ({ workspaceId }) => {
const pageRef = useRef<HTMLElement>(null);
const { canCreateTemplate } = usePermissions();
const TemplateCreateModal = useModal(TemplateCreateModalComponent);
return (
<Page
breadcrumb={[
{
breadcrumbName: 'Manage Templates',
path: paths.templates(),
},
]}
breadcrumb={
workspaceId
? []
: [
{
breadcrumbName: 'Manage Templates',
path: paths.templates(),
},
]
}
containerRef={pageRef}
id="templates"
title="Manage Templates"
/>
options={
<Row>
{canCreateTemplate && <Button onClick={TemplateCreateModal.open}>New Template</Button>}
</Row>
}
title="Manage Templates">
<TemplateCreateModal.Component workspaceId={workspaceId} />
</Page>
);
};

Expand Down
3 changes: 2 additions & 1 deletion webui/react/src/pages/WorkspaceDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { User, ValueOf } from 'types';
import handleError from 'utils/error';
import { useObservable } from 'utils/observable';

import TemplatesPage from './Templates/TemplatesPage';
import ResourcePoolsBound from './WorkspaceDetails/ResourcePoolsBound';
import WorkspaceMembers from './WorkspaceDetails/WorkspaceMembers';
import WorkspaceProjects from './WorkspaceDetails/WorkspaceProjects';
Expand Down Expand Up @@ -216,7 +217,7 @@ const WorkspaceDetails: React.FC = () => {

if (templatesOn) {
items.push({
children: <div />,
children: <TemplatesPage workspaceId={workspace.id} />,
key: WorkspaceDetailsTab.Templates,
label: 'Templates',
});
Expand Down
6 changes: 6 additions & 0 deletions webui/react/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,12 @@ export const getTaskTemplates = generateDetApi<
Type.Template[]
>(Config.getTemplates);

export const createTaskTemplate = generateDetApi<
Api.V1Template,
Api.V1PostTemplateResponse,
Type.Template
>(Config.createTaskTemplate);

export const launchJupyterLab = generateDetApi<
Service.LaunchJupyterLabParams,
Api.V1LaunchNotebookResponse,
Expand Down
7 changes: 7 additions & 0 deletions webui/react/src/services/apiConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1863,6 +1863,13 @@ export const getTemplates: DetApi<
),
};

export const createTaskTemplate: DetApi<Api.V1Template, Api.V1PostTemplateResponse, Type.Template> =
{
name: 'createTaskTemplate',
postProcess: (response) => decoder.mapV1Template(response.template),
request: (params: Api.V1Template) => detApi.Templates.postTemplate(params.name, params),
};

export const launchJupyterLab: DetApi<
Service.LaunchJupyterLabParams,
Api.V1LaunchNotebookResponse,
Expand Down

0 comments on commit b78020d

Please sign in to comment.