Skip to content

Commit

Permalink
feat: workspace task config policies UI [CM-478] (#9950)
Browse files Browse the repository at this point in the history
  • Loading branch information
johnkim-det authored Sep 24, 2024
1 parent 924f663 commit cf9bdc8
Show file tree
Hide file tree
Showing 9 changed files with 310 additions and 6 deletions.
8 changes: 4 additions & 4 deletions webui/react/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion webui/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"fp-ts": "^2.16.5",
"fuse.js": "^7.0.0",
"hermes-parallel-coordinates": "^0.6.17",
"hew": "npm:@hpe.com/hew@^0.6.46",
"hew": "npm:@hpe.com/hew@^0.6.47",
"humanize-duration": "^3.28.0",
"immutable": "^4.3.0",
"io-ts": "^2.2.21",
Expand Down
58 changes: 58 additions & 0 deletions webui/react/src/components/ConfigPolicies.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react';
import UIProvider, { DefaultTheme } from 'hew/Theme';
import { ConfirmationProvider } from 'hew/useConfirm';

import ConfigPolicies from './ConfigPolicies';
import { ThemeProvider } from './ThemeProvider';

const mocks = vi.hoisted(() => {
return {
canModifyWorkspaceConfigPolicies: false,
};
});

vi.mock('hooks/usePermissions', () => {
const usePermissions = vi.fn(() => {
return {
canModifyWorkspaceConfigPolicies: mocks.canModifyWorkspaceConfigPolicies,
};
});
return {
default: usePermissions,
};
});

vi.mock('@uiw/react-codemirror', () => ({
__esModule: true,
default: () => <></>,
}));

vi.mock('services/api', () => ({
getWorkspaceConfigPolicies: () => Promise.resolve({ configPolicies: {} }),
}));

const setup = () => {
render(
<UIProvider theme={DefaultTheme.Light}>
<ThemeProvider>
<ConfirmationProvider>
<ConfigPolicies />
</ConfirmationProvider>
</ThemeProvider>
</UIProvider>,
);
};

describe('Config Policies', () => {
it('allows changes with permissions', async () => {
mocks.canModifyWorkspaceConfigPolicies = true;
setup();
expect(await screen.findByRole('button', { name: 'Apply' })).toBeInTheDocument();
});

it('prevents changes without permissions', () => {
mocks.canModifyWorkspaceConfigPolicies = false;
setup();
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
});
});
146 changes: 146 additions & 0 deletions webui/react/src/components/ConfigPolicies.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import Alert from 'hew/Alert';
import Button from 'hew/Button';
import CodeEditor from 'hew/CodeEditor';
import Column from 'hew/Column';
import Form, { hasErrors } from 'hew/Form';
import Row from 'hew/Row';
import Spinner from 'hew/Spinner';
import { useToast } from 'hew/Toast';
import useConfirm from 'hew/useConfirm';
import { Loadable, NotLoaded } from 'hew/utils/loadable';
import yaml from 'js-yaml';
import { useState } from 'react';

import { useAsync } from 'hooks/useAsync';
import usePermissions from 'hooks/usePermissions';
import { getWorkspaceConfigPolicies, updateWorkspaceConfigPolicies } from 'services/api';
import handleError from 'utils/error';

interface Props {
workspaceId?: number;
}

type FormInputs = {
task: string;
};

const ConfigPolicies: React.FC<Props> = ({ workspaceId }: Props) => {
const confirm = useConfirm();
const { openToast } = useToast();
const { canModifyWorkspaceConfigPolicies, loading: rbacLoading } = usePermissions();
const [form] = Form.useForm<FormInputs>();

const [disabled, setDisabled] = useState(true);

const APPLY_MESSAGE = "You're about to apply these config policies to this workspace.";
const VIEW_MESSAGE = 'An admin applied these config policies to this workspace.';

const updatePolicies = async () => {
if (workspaceId) {
try {
await updateWorkspaceConfigPolicies({
configPolicies: form.getFieldValue('task'),
workloadType: 'NTSC',
workspaceId,
});
openToast({ title: 'Config policies updated' });
} catch (error) {
handleError(error);
}
}
};

const confirmApply = () => {
confirm({
content: (
<span>
This will impact{' '}
<strong>
<u>all</u>
</strong>{' '}
underlying projects and their experiments in this workspace.
</span>
),
okText: 'Apply',
onConfirm: updatePolicies,
onError: handleError,
size: 'medium',
title: APPLY_MESSAGE,
});
};

const loadableTaskConfigPolicies: Loadable<string | undefined> = useAsync(async () => {
if (workspaceId) {
const response = await getWorkspaceConfigPolicies({
workloadType: 'NTSC',
workspaceId,
});
return response.configPolicies;
}
return NotLoaded;
}, [workspaceId]);

const initialTaskYAML = yaml.dump(loadableTaskConfigPolicies.getOrElse(undefined));

const handleChange = () => {
setDisabled(hasErrors(form) || form.getFieldValue('task') === initialTaskYAML);
};

if (rbacLoading) return <Spinner spinning />;

return (
<Column>
<Row width="fill">
<div style={{ width: '100%' }}>
{canModifyWorkspaceConfigPolicies ? (
<Alert
action={
<Button disabled={disabled} onClick={confirmApply}>
Apply
</Button>
}
message={APPLY_MESSAGE}
showIcon
/>
) : (
<Alert message={VIEW_MESSAGE} showIcon />
)}
</div>
</Row>
<Row width="fill">
<div style={{ width: '100%' }}>
<Form form={form} onFieldsChange={handleChange}>
<Form.Item
name="task"
rules={[
{
validator: (_, 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={initialTaskYAML}
files={[{ key: 'task', title: 'Task Config Policies' }]}
onError={(error) => {
handleError(error);
}}
/>
</Form.Item>
</Form>
</div>
</Row>
</Column>
);
};

export default ConfigPolicies;
26 changes: 26 additions & 0 deletions webui/react/src/hooks/usePermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export interface PermissionsHook {
canViewWorkspace: (arg0: WorkspacePermissionsArgs) => boolean;
canViewWorkspaces: boolean;
canViewResourceQuotas: boolean;
canViewWorkspaceConfigPolicies: boolean;
canModifyWorkspaceConfigPolicies: boolean;
loading: boolean;
}

Expand Down Expand Up @@ -195,6 +197,7 @@ const usePermissions = (): PermissionsHook => {
canModifyWorkspaceAgentUserGroup(rbacOpts, args.workspace),
canModifyWorkspaceCheckpointStorage: (args: WorkspacePermissionsArgs) =>
canModifyWorkspaceCheckpointStorage(rbacOpts, args.workspace),
canModifyWorkspaceConfigPolicies: canModifyWorkspaceConfigPolicies(rbacOpts),
canModifyWorkspaceNSC: (args: WorkspacePermissionsArgs) =>
canModifyWorkspaceNSC(rbacOpts, args.workspace),
canMoveExperiment: (args: ExperimentPermissionsArgs) =>
Expand All @@ -218,6 +221,7 @@ const usePermissions = (): PermissionsHook => {
canViewResourceQuotas: canViewResourceQuotas(rbacOpts),
canViewWorkspace: (args: WorkspacePermissionsArgs) =>
canViewWorkspace(rbacOpts, args.workspace),
canViewWorkspaceConfigPolicies: canViewWorkspaceConfigPolicies(rbacOpts),
canViewWorkspaces: canViewWorkspaces(rbacOpts),
loading:
rbacOpts.rbacEnabled &&
Expand Down Expand Up @@ -811,4 +815,26 @@ const canMoveFlatRun = (
);
};

// Config Policies:
const canViewWorkspaceConfigPolicies = ({
rbacEnabled,
userAssignments,
userRoles,
}: RbacOptsProps): boolean => {
const permitted = relevantPermissions(userAssignments, userRoles);
return !rbacEnabled || permitted.has(V1PermissionType.VIEWWORKSPACECONFIGPOLICIES);
};

const canModifyWorkspaceConfigPolicies = ({
currentUser,
rbacEnabled,
userAssignments,
userRoles,
}: RbacOptsProps): boolean => {
const permitted = relevantPermissions(userAssignments, userRoles);
return rbacEnabled
? permitted.has(V1PermissionType.MODIFYWORKSPACECONFIGPOLICIES)
: !!currentUser && currentUser.isAdmin;
};

export default usePermissions;
21 changes: 20 additions & 1 deletion webui/react/src/pages/WorkspaceDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BrandingType } from 'hew/internal/types';
import Message from 'hew/Message';
import Pivot, { PivotProps } from 'hew/Pivot';
import Spinner from 'hew/Spinner';
Expand All @@ -6,6 +7,7 @@ import _ from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';

import ConfigPolicies from 'components/ConfigPolicies';
import ModelRegistry from 'components/ModelRegistry';
import Page from 'components/Page';
import PageNotFound from 'components/PageNotFound';
Expand Down Expand Up @@ -35,6 +37,7 @@ type Params = {
};

export const WorkspaceDetailsTab = {
ConfigPolicies: 'policies',
Members: 'members',
ModelRegistry: 'models',
Projects: 'projects',
Expand Down Expand Up @@ -75,7 +78,13 @@ const WorkspaceDetails: React.FC = () => {
const workspaceId = workspaceID ?? '';
const id = Number(workspaceId);
const navigate = useNavigate();
const { canViewWorkspace, canViewModelRegistry, loading: rbacLoading } = usePermissions();
const {
canViewWorkspaceConfigPolicies,
canViewWorkspace,
canViewModelRegistry,
loading: rbacLoading,
} = usePermissions();
const info = useObservable(determinedStore.info);

const loadableWorkspace = useObservable(workspaceStore.getWorkspace(id));
const workspace = Loadable.getOrElse(undefined, loadableWorkspace);
Expand Down Expand Up @@ -223,6 +232,14 @@ const WorkspaceDetails: React.FC = () => {
});
}

if (info.branding === BrandingType.HPE && canViewWorkspaceConfigPolicies) {
items.push({
children: <ConfigPolicies workspaceId={workspace.id} />,
key: WorkspaceDetailsTab.ConfigPolicies,
label: 'Config Policies',
});
}

return items;
}, [
addableUsersAndGroups,
Expand All @@ -238,6 +255,8 @@ const WorkspaceDetails: React.FC = () => {
workspaceAssignments,
rpBindingFlagOn,
templatesOn,
info.branding,
canViewWorkspaceConfigPolicies,
]);

const canViewWorkspaceFlag = canViewWorkspace({ workspace: { id } });
Expand Down
12 changes: 12 additions & 0 deletions webui/react/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,3 +994,15 @@ export const killTask = async (task: Pick<Type.CommandTask, 'id' | 'type'>): Pro
return await killTensorBoard({ commandId: task.id });
}
};

export const getWorkspaceConfigPolicies = generateDetApi<
Service.GetWorkspaceConfigPolicies,
Api.V1GetWorkspaceConfigPoliciesResponse,
Api.V1GetWorkspaceConfigPoliciesResponse
>(Config.getWorkspaceConfigPolicies);

export const updateWorkspaceConfigPolicies = generateDetApi<
Service.UpdateWorkspaceConfigPolicies,
Api.V1PutWorkspaceConfigPoliciesResponse,
Api.V1PutWorkspaceConfigPoliciesResponse
>(Config.updateWorkspaceConfigPolicies);
Loading

0 comments on commit cf9bdc8

Please sign in to comment.