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 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: 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
116 changes: 64 additions & 52 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,24 +46,26 @@ 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 SUCCESS_MESSAGE = 'Configuration policies updated.';
const YAML_FORM_ITEM_NAME = 'configPolicies';

const ConfigPolicies: React.FC<Props> = (props: Props) => {
const tabItems: PivotProps['items'] = [
{
children: <ConfigPoliciesTab type="experiments" workspaceId={workspaceId} />,
children: <ConfigPoliciesTab type="experiments" {...props} />,
key: 'experiments',
label: ConfigPoliciesValues.experiments.label,
},
{
children: <ConfigPoliciesTab type="tasks" workspaceId={workspaceId} />,
children: <ConfigPoliciesTab type="tasks" {...props} />,
key: 'tasks',
label: ConfigPoliciesValues.tasks.label,
},
Expand All @@ -70,43 +74,45 @@ const ConfigPolicies: React.FC<Props> = ({ workspaceId }: Props) => {
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 configuration policies to the cluster."
: "You're about to apply these configuration policies to the workspace.";
const viewMessage = global
? 'Global configuration policies are being applied to the cluster.'
: 'Global configuration policies are being applied to the workspace.';
const confirmMessageEnding = global
? 'underlying workspaces, projects, and submitted experiments in the cluster.'
: 'underlying projects and their experiments in this workspace.';

const updatePolicies = async () => {
if (workspaceId) {
const configPolicies = form.getFieldValue('configPolicies');
if (configPolicies.length) {
try {
await updateWorkspaceConfigPolicies({
configPolicies,
workloadType: ConfigPoliciesValues[type].workloadType,
workspaceId,
});
openToast({ title: CONFIRMATION_MESSAGE });
} catch (error) {
handleError(error);
}
} else {
try {
await deleteWorkspaceConfigPolicies({
workloadType: ConfigPoliciesValues[type].workloadType,
workspaceId,
});
openToast({ title: CONFIRMATION_MESSAGE });
} catch (error) {
handleError(error);
}
const configPolicies = form.getFieldValue(YAML_FORM_ITEM_NAME);
const workloadType = ConfigPoliciesValues[type].workloadType;

try {
if (global) {
configPolicies.length
? await updateGlobalConfigPolicies({ configPolicies, workloadType })
: await deleteGlobalConfigPolicies({ workloadType });
} else if (workspaceId) {
configPolicies.length
? await updateWorkspaceConfigPolicies({ configPolicies, workloadType, workspaceId })
: await deleteWorkspaceConfigPolicies({ workloadType, workspaceId });
}
openToast({ title: SUCCESS_MESSAGE });
} catch (error) {
handleError(error);
}
};

Expand All @@ -118,19 +124,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 +151,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 +167,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 +204,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
3 changes: 3 additions & 0 deletions webui/react/src/routes/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ export const paths = {
clusters: (): string => {
return '/clusters';
},
configPolicies: (): string => {
return '/policies';
},
customerAsset: (
name: 'logo',
orientation: 'vertical' | 'horizontal',
Expand Down
Loading
Loading