Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Global Config Policies UI [CM-522] #10022

Merged
merged 4 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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: 1 addition & 1 deletion webui/react/src/components/ConfigPolicies.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const setup = () => {
<UIProvider theme={DefaultTheme.Light}>
<ThemeProvider>
<ConfirmationProvider>
<ConfigPolicies />
<ConfigPolicies workspaceId={1} />
</ConfirmationProvider>
</ThemeProvider>
</UIProvider>,
Expand Down
143 changes: 100 additions & 43 deletions webui/react/src/components/ConfigPolicies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,17 @@ import { useState } from 'react';
import { useAsync } from 'hooks/useAsync';
import usePermissions from 'hooks/usePermissions';
import {
deleteGlobalConfigPolicies,
deleteWorkspaceConfigPolicies,
getGlobalConfigPolicies,
getWorkspaceConfigPolicies,
updateGlobalConfigPolicies,
updateWorkspaceConfigPolicies,
} from 'services/api';
import { XOR } from 'types';
import handleError from 'utils/error';

interface Props {
workspaceId?: number;
}
type Props = XOR<{ workspaceId: number }, { global: true }>;

type ConfigPoliciesType = 'experiments' | 'tasks';

Expand All @@ -44,55 +46,104 @@ const ConfigPoliciesValues: Record<ConfigPoliciesType, ConfigPoliciesValues> = {
},
};

interface TabProps {
workspaceId?: number;
type TabProps = Props & {
type: ConfigPoliciesType;
}
};

type FormInputs = {
configPolicies: string;
[YAML_FORM_ITEM_NAME]: string;
};

const ConfigPolicies: React.FC<Props> = ({ workspaceId }: Props) => {
const tabItems: PivotProps['items'] = [
{
children: <ConfigPoliciesTab type="experiments" workspaceId={workspaceId} />,
key: 'experiments',
label: ConfigPoliciesValues.experiments.label,
},
{
children: <ConfigPoliciesTab type="tasks" workspaceId={workspaceId} />,
key: 'tasks',
label: ConfigPoliciesValues.tasks.label,
},
];
const SUCCESS_MESSAGE = 'Config policies updated.';
johnkim-det marked this conversation as resolved.
Show resolved Hide resolved
const YAML_FORM_ITEM_NAME = 'configPolicies';

const ConfigPolicies: React.FC<Props> = ({ workspaceId, global }: Props) => {
let tabItems: PivotProps['items'];
if (global) {
tabItems = [
{
children: <ConfigPoliciesTab global type="experiments" />,
key: 'experiments',
label: ConfigPoliciesValues.experiments.label,
},
{
children: <ConfigPoliciesTab global type="tasks" />,
key: 'tasks',
label: ConfigPoliciesValues.tasks.label,
},
];
} else if (workspaceId) {
tabItems = [
{
children: <ConfigPoliciesTab type="experiments" workspaceId={workspaceId} />,
key: 'experiments',
label: ConfigPoliciesValues.experiments.label,
},
{
children: <ConfigPoliciesTab type="tasks" workspaceId={workspaceId} />,
key: 'tasks',
label: ConfigPoliciesValues.tasks.label,
},
];
}
johnkim-det marked this conversation as resolved.
Show resolved Hide resolved

return <Pivot items={tabItems} type="secondary" />;
};

const ConfigPoliciesTab: React.FC<TabProps> = ({ workspaceId, type }: TabProps) => {
const ConfigPoliciesTab: React.FC<TabProps> = ({ workspaceId, global, type }: TabProps) => {
const confirm = useConfirm();
const { openToast } = useToast();
const { canModifyWorkspaceConfigPolicies, loading: rbacLoading } = usePermissions();
const {
canModifyWorkspaceConfigPolicies,
canModifyGlobalConfigPolicies,
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 CONFIRMATION_MESSAGE = 'Config policies updated';
const applyMessage = global
? "You're about to apply these config policies to this cluster."
johnkim-det marked this conversation as resolved.
Show resolved Hide resolved
: "You're about to apply these config policies to this workspace.";
johnkim-det marked this conversation as resolved.
Show resolved Hide resolved
const viewMessage = global
? 'These global config policies are being applied to this cluster.'
johnkim-det marked this conversation as resolved.
Show resolved Hide resolved
: 'These global config policies are being applied to this workspace.';
johnkim-det marked this conversation as resolved.
Show resolved Hide resolved
const confirmMessageEnding = global
? 'underlying workspaces, projects, and submitted experiments in this cluster.'
johnkim-det marked this conversation as resolved.
Show resolved Hide resolved
: 'underlying projects and their experiments in this workspace.';
johnkim-det marked this conversation as resolved.
Show resolved Hide resolved

const updatePolicies = async () => {
if (workspaceId) {
const configPolicies = form.getFieldValue('configPolicies');
const configPolicies = form.getFieldValue(YAML_FORM_ITEM_NAME);
if (global) {
johnkim-det marked this conversation as resolved.
Show resolved Hide resolved
if (configPolicies.length) {
try {
await updateGlobalConfigPolicies({
configPolicies,
workloadType: ConfigPoliciesValues[type].workloadType,
});
openToast({ title: SUCCESS_MESSAGE });
} catch (error) {
handleError(error);
}
} else {
try {
await deleteGlobalConfigPolicies({
workloadType: ConfigPoliciesValues[type].workloadType,
});
openToast({ title: SUCCESS_MESSAGE });
} catch (error) {
handleError(error);
}
}
} else if (workspaceId) {
if (configPolicies.length) {
try {
await updateWorkspaceConfigPolicies({
configPolicies,
workloadType: ConfigPoliciesValues[type].workloadType,
workspaceId,
});
openToast({ title: CONFIRMATION_MESSAGE });
openToast({ title: SUCCESS_MESSAGE });
} catch (error) {
handleError(error);
}
Expand All @@ -102,7 +153,7 @@ const ConfigPoliciesTab: React.FC<TabProps> = ({ workspaceId, type }: TabProps)
workloadType: ConfigPoliciesValues[type].workloadType,
workspaceId,
});
openToast({ title: CONFIRMATION_MESSAGE });
openToast({ title: SUCCESS_MESSAGE });
} catch (error) {
handleError(error);
}
Expand All @@ -118,19 +169,25 @@ const ConfigPoliciesTab: React.FC<TabProps> = ({ workspaceId, type }: TabProps)
<strong>
<u>all</u>
</strong>{' '}
underlying projects and their experiments in this workspace.
{confirmMessageEnding}
</span>
),
okText: 'Apply',
onConfirm: updatePolicies,
onError: handleError,
size: 'medium',
title: APPLY_MESSAGE,
title: applyMessage,
});
};

const loadableConfigPolicies: Loadable<string | undefined> = useAsync(async () => {
if (workspaceId) {
if (global) {
const response = await getGlobalConfigPolicies({
workloadType: ConfigPoliciesValues[type].workloadType,
});
if (isEmpty(response.configPolicies)) return undefined;
return response.configPolicies;
} else if (workspaceId) {
const response = await getWorkspaceConfigPolicies({
workloadType: ConfigPoliciesValues[type].workloadType,
workspaceId,
Expand All @@ -139,14 +196,14 @@ const ConfigPoliciesTab: React.FC<TabProps> = ({ workspaceId, type }: TabProps)
return response.configPolicies;
}
return NotLoaded;
}, [workspaceId, type]);
}, [workspaceId, type, global]);

const initialYAML = yaml.dump(loadableConfigPolicies.getOrElse(undefined));

const initialConfigPoliciesYAML = yaml.dump(loadableConfigPolicies.getOrElse(undefined));
const canModify = global ? canModifyGlobalConfigPolicies : canModifyWorkspaceConfigPolicies;

const handleChange = () => {
setDisabled(
hasErrors(form) || form.getFieldValue('configPolicies') === initialConfigPoliciesYAML,
);
setDisabled(hasErrors(form) || form.getFieldValue(YAML_FORM_ITEM_NAME) === initialYAML);
};

if (rbacLoading) return <Spinner spinning />;
Expand All @@ -155,26 +212,26 @@ const ConfigPoliciesTab: React.FC<TabProps> = ({ workspaceId, type }: TabProps)
<Column>
<Row width="fill">
<div style={{ width: '100%' }}>
{canModifyWorkspaceConfigPolicies ? (
{canModify ? (
<Alert
action={
<Button disabled={disabled} onClick={confirmApply}>
Apply
</Button>
}
message={APPLY_MESSAGE}
message={applyMessage}
showIcon
/>
) : (
<Alert message={VIEW_MESSAGE} showIcon />
<Alert message={viewMessage} showIcon />
)}
</div>
</Row>
<Row width="fill">
<div style={{ width: '100%' }}>
<Form form={form} onFieldsChange={handleChange}>
<Form.Item
name="configPolicies"
name={YAML_FORM_ITEM_NAME}
rules={[
{
validator: (_, value) => {
Expand All @@ -192,9 +249,9 @@ const ConfigPoliciesTab: React.FC<TabProps> = ({ workspaceId, type }: TabProps)
},
]}>
<CodeEditor
file={initialConfigPoliciesYAML}
file={initialYAML}
files={[{ key: type, title: `${type}-config-policies.yaml` }]}
readonly={!canModifyWorkspaceConfigPolicies}
readonly={!canModify}
onError={(error) => {
handleError(error);
}}
Expand Down
10 changes: 9 additions & 1 deletion webui/react/src/components/NavigationSideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ const NavigationSideBar: React.FC = () => {
const shortVersion = version.replace(/^(\d+\.\d+\.\d+).*?$/i, '$1');
const isVersionLong = version !== shortVersion;

const { canCreateWorkspace, canViewWorkspace } = usePermissions();
const { canCreateWorkspace, canViewWorkspace, canViewGlobalConfigPolicies } = usePermissions();

const canAccessUncategorized = canViewWorkspace({ workspace: { id: 1 } });

Expand Down Expand Up @@ -175,6 +175,13 @@ const NavigationSideBar: React.FC = () => {
path: paths.templates(),
});
}
if (info.branding === BrandingType.HPE && canViewGlobalConfigPolicies) {
topItems.splice(topItems.length - 1, 0, {
icon: 'options',
label: 'Config Policies',
path: paths.configPolicies(),
});
}
if (currentUser?.isAdmin || f_webhook) {
topItems.splice(topItems.length - 1, 0, {
icon: 'webhooks',
Expand Down Expand Up @@ -216,6 +223,7 @@ const NavigationSideBar: React.FC = () => {
};
}, [
canAccessUncategorized,
canViewGlobalConfigPolicies,
info.branding,
gasLinkOn,
templatesOn,
Expand Down
25 changes: 25 additions & 0 deletions webui/react/src/hooks/usePermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ export interface PermissionsHook {
canViewResourceQuotas: boolean;
canViewWorkspaceConfigPolicies: boolean;
canModifyWorkspaceConfigPolicies: boolean;
canViewGlobalConfigPolicies: boolean;
canModifyGlobalConfigPolicies: boolean;
loading: boolean;
}

Expand Down Expand Up @@ -181,6 +183,7 @@ const usePermissions = (): PermissionsHook => {
canModifyExperimentMetadata(rbacOpts, args.workspace),
canModifyFlatRun: (args: WorkspacePermissionsArgs) =>
canModifyFlatRun(rbacOpts, args.workspace),
canModifyGlobalConfigPolicies: canModifyGlobalConfigPolicies(rbacOpts),
canModifyGroups: canModifyGroups(rbacOpts),
canModifyModel: (args: ModelPermissionsArgs) => canModifyModel(rbacOpts, args.model),
canModifyModelVersion: (args: ModelVersionPermissionsArgs) =>
Expand Down Expand Up @@ -215,6 +218,7 @@ const usePermissions = (): PermissionsHook => {
canUpdateRoles: (args: WorkspacePermissionsArgs) => canUpdateRoles(rbacOpts, args.workspace),
canViewExperimentArtifacts: (args: WorkspacePermissionsArgs) =>
canViewExperimentArtifacts(rbacOpts, args.workspace),
canViewGlobalConfigPolicies: canViewGlobalConfigPolicies(rbacOpts),
canViewGroups: canViewGroups(rbacOpts),
canViewModelRegistry: (args: WorkspacePermissionsArgs) =>
canViewModelRegistry(rbacOpts, args.workspace),
Expand Down Expand Up @@ -837,4 +841,25 @@ const canModifyWorkspaceConfigPolicies = ({
: !!currentUser && currentUser.isAdmin;
};

const canViewGlobalConfigPolicies = ({
rbacEnabled,
userAssignments,
userRoles,
}: RbacOptsProps): boolean => {
const permitted = relevantPermissions(userAssignments, userRoles);
return !rbacEnabled || permitted.has(V1PermissionType.VIEWGLOBALCONFIGPOLICIES);
};

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

export default usePermissions;
26 changes: 26 additions & 0 deletions webui/react/src/pages/ConfigPoliciesPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { useRef } from 'react';

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

const TemplatesPage: React.FC = () => {
const pageRef = useRef<HTMLElement>(null);

return (
<Page
breadcrumb={[
{
breadcrumbName: 'Config Policies',
path: paths.configPolicies(),
},
]}
containerRef={pageRef}
id="configPolicies"
title="Config Policies">
<ConfigPolicies global />
</Page>
);
};

export default TemplatesPage;
2 changes: 2 additions & 0 deletions webui/react/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const Wait = React.lazy(() => import('pages/Wait'));
const Webhooks = React.lazy(() => import('pages/WebhookList'));
const WorkspaceDetails = React.lazy(() => import('pages/WorkspaceDetails'));
const WorkspaceList = React.lazy(() => import('pages/WorkspaceList'));
const ConfigPoliciesPage = React.lazy(() => import('pages/ConfigPoliciesPage'));
import { RouteConfig } from 'types';

import Routes from './routes';
Expand All @@ -37,6 +38,7 @@ const routeComponentMap: Record<string, React.ReactNode> = {
clusterHistorical: <Deprecated />,
clusterLogs: <ClusterLogs />,
clusters: <Cluster />,
configPolicies: <ConfigPoliciesPage />,
dashboard: <Dashboard />,
default: <DefaultRoute />,
experimentDetails: <ExperimentDetails />,
Expand Down
7 changes: 7 additions & 0 deletions webui/react/src/routes/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ const routes: RouteConfig[] = [
path: '/templates',
title: 'Manage Templates',
},
{
icon: 'options',
id: 'configPolicies',
needAuth: true,
path: '/policies',
title: 'Config Policies',
},
{
icon: 'cluster',
id: 'clusterHistorical',
Expand Down
Loading
Loading