Browse Source

feat: new role page (#795)

* part1

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* part2

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* bug fix

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

* adjust color

Signed-off-by: ryjiang <jiangruiyi@gmail.com>

---------

Signed-off-by: ryjiang <jiangruiyi@gmail.com>
ryjiang 3 months ago
parent
commit
583cf99386

+ 1 - 0
client/src/i18n/cn/button.ts

@@ -43,6 +43,7 @@ const btnTrans = {
   close: '关闭',
   modify: '修改',
   downloadSchema: '下载 Schema',
+  downloadChart: '下载图表',
   editDefaultValue: '编辑默认值',
   viewData: '查看数据',
 

+ 1 - 0
client/src/i18n/en/button.ts

@@ -43,6 +43,7 @@ const btnTrans = {
   close: 'Close',
   modify: 'Modify',
   downloadSchema: 'Download Schema',
+  downloadChart: 'Download Chart',
   EditDefaultValue: 'Edit Default Value',
   viewData: 'View Data',
 

+ 286 - 0
client/src/pages/user/D3PrivilegeTree.tsx

@@ -0,0 +1,286 @@
+import React, { useEffect, useRef } from 'react';
+import * as d3 from 'd3';
+import { useTranslation } from 'react-i18next';
+import { useTheme, Box } from '@mui/material';
+import CustomButton from '@/components/customButton/CustomButton';
+import type { DBCollectionsPrivileges, RBACOptions } from '@server/types';
+
+interface Props {
+  privileges: DBCollectionsPrivileges;
+  role: string;
+  margin?: { top: number; right: number; bottom: number; left: number };
+  rbacOptions: RBACOptions;
+}
+
+interface TreeNode {
+  name: string;
+  children?: TreeNode[];
+  value?: boolean;
+  type?: string;
+}
+
+const D3PrivilegeTree: React.FC<Props> = ({
+  role,
+  privileges,
+  margin = { top: 20, right: 20, bottom: 20, left: 60 },
+  rbacOptions,
+}) => {
+  // i18n
+  const { t: btnTrans } = useTranslation('btn');
+  const { t: userTrans } = useTranslation('user');
+
+  // theme
+  const theme = useTheme();
+
+  // Refs for SVG and G elements
+  const svgRef = useRef<SVGSVGElement | null>(null);
+  const gRef = useRef<SVGGElement | null>(null);
+
+  // Derive the options arrays as in DBCollectionSelector.
+  const rbacEntries = Object.entries(rbacOptions) as [
+    string,
+    Record<string, string>
+  ][];
+
+  // privileges of the privilege groups
+  const privilegeGroups = rbacEntries.filter(([key]) =>
+    key.endsWith('PrivilegeGroups')
+  );
+  const groupPrivileges = new Set(
+    privilegeGroups.reduce(
+      (acc, [_, group]) => acc.concat(Object.values(group)),
+      [] as string[]
+    )
+  );
+
+  // Transform privileges data into d3.hierarchy structure
+  const transformData = (
+    privileges: DBCollectionsPrivileges
+  ): { nodeCount: number; treeNode: TreeNode } => {
+    let nodeCount = 0;
+
+    const res = {
+      name: role,
+      type: 'role',
+      children: Object.entries(privileges).map(([dbKey, dbValue]) => ({
+        name: dbKey === '*' ? 'All Databases(*)' : dbKey,
+        type: 'database',
+        children: Object.entries(dbValue.collections).map(
+          ([colKey, colValue]) => {
+            const children = Object.entries(colValue).map(([priv, val]) => {
+              nodeCount += 1;
+              return {
+                name: priv,
+                value: val,
+              };
+            });
+
+            children.sort((a, b) => {
+              const aInGroup = groupPrivileges.has(a.name);
+              const bInGroup = groupPrivileges.has(b.name);
+              return aInGroup === bInGroup ? 0 : aInGroup ? -1 : 1;
+            });
+
+            return {
+              name: colKey === '*' ? 'All Collections(*)' : colKey,
+              children,
+              type: 'collection',
+            };
+          }
+        ),
+      })),
+    };
+
+    return {
+      nodeCount,
+      treeNode: res,
+    };
+  };
+
+  useEffect(() => {
+    if (!privileges) return;
+
+    const transformedData = transformData(privileges);
+
+    const width = 1000; // Fixed SVG width
+    const defaultHeight = 580; // Default SVG height
+    // calculate height based on number of nodes
+    let height = transformedData.nodeCount * 15 + margin.top + margin.bottom;
+    if (height < 500) height = defaultHeight; // Set a minimum height
+
+    const fontSize = 12; // Font size for text labels
+    const marginLeft = role.length * fontSize; // Adjust margin left based on role length
+
+    // Clear SVG
+    d3.select(svgRef.current).selectAll('*').remove();
+
+    // Create hierarchy and layout
+    const root = d3.hierarchy<TreeNode>(transformedData.treeNode);
+    const treeLayout = d3
+      .tree<TreeNode>()
+      .size([height, width / 2])
+      .separation((a: any, b: any) => {
+        return a.parent === b.parent ? 3 : 4;
+      }); // Swap width and height for vertical layout
+    treeLayout(root);
+
+    // Calculate the bounds of the tree
+    let x0 = Infinity;
+    let x1 = -Infinity;
+    let y0 = Infinity;
+    let y1 = -Infinity;
+    root.each((d: any) => {
+      if (d.x > x1) x1 = d.x;
+      if (d.x < x0) x0 = d.x;
+      if (d.y > y1) y1 = d.y;
+      if (d.y < y0) y0 = d.y;
+    });
+
+    // Calculate translateY to center the tree vertically
+    const treeHeight = x1 - x0;
+    const translateY = (height - treeHeight) / 2 - x0;
+
+    // Create SVG container
+    const svg = d3
+      .select(svgRef.current)
+      .attr('width', width)
+      .attr('height', height)
+      .attr('viewBox', [0, 0, width, height].join(' ')) // Add viewBox for scaling
+      .attr('style', 'max-width: 100%; height: auto;'); // Make SVG responsive
+
+    // Add a group for zoomable content
+    const g = svg
+      .append('g')
+      .attr('transform', `translate(${marginLeft}, ${translateY})`);
+    gRef.current = g.node();
+
+    const colorMap: { [key: string]: any } = {
+      role: theme.palette.primary.dark,
+      database: theme.palette.primary.dark,
+      collection: theme.palette.primary.dark,
+    };
+
+    // Create links (connections between nodes)
+    g.selectAll('.link')
+      .data(root.links())
+      .enter()
+      .append('path')
+      .attr('class', 'link')
+      .attr('fill', 'none')
+      .attr('stroke', d => colorMap[d.source.data.type!])
+      .attr('stroke-width', 1)
+      .attr(
+        'd',
+        (d: any) =>
+          `M${d.source.y},${d.source.x} C${(d.source.y + d.target.y) / 2},${
+            d.source.x
+          } ` +
+          `${(d.source.y + d.target.y) / 2},${d.target.x} ${d.target.y},${
+            d.target.x
+          }`
+      );
+
+    // Create nodes
+    const nodes = g
+      .selectAll('.node')
+      .data(root.descendants())
+      .enter()
+      .append('g')
+      .attr('class', 'node')
+      .attr('transform', d => `translate(${d.y! + 3},${d.x})`);
+
+    // Add circles for nodes
+    nodes
+      .append('circle')
+      .attr('r', 3)
+      .attr('stroke', theme.palette.primary.main)
+      .attr('fill', (d, index) =>
+        d.children || index == 0 || groupPrivileges.has(d.data.name)
+          ? `${theme.palette.primary.main}`
+          : `transparent`
+      );
+
+    // Add text labels
+    nodes
+      .append('text')
+      .attr('dy', d => (d.children && d.data.name !== role ? -3 : 3))
+      .attr('x', d => (d.children ? -10 : 10))
+      .attr('text-anchor', d => (d.children ? 'end' : 'start'))
+      .attr('font-family', 'Inter')
+      .attr('font-style', 'Italic')
+      .attr('font-size', fontSize)
+      .attr('fill', d =>
+        groupPrivileges.has(d.data.name)
+          ? `${theme.palette.primary.dark}`
+          : `${theme.palette.text.primary}`
+      )
+      .text(d =>
+        groupPrivileges.has(d.data.name) && d.data.type !== 'role'
+          ? `${userTrans('privilegeGroup')}: ${d.data.name}`
+          : d.data.name
+      );
+
+    // Add zoom functionality
+    const zoom: any = d3
+      .zoom()
+      .scaleExtent([0.5, 3]) // Set zoom limits
+      .on('zoom', event => {
+        g.attr(
+          'transform',
+          `translate(${marginLeft}, ${translateY}) ${event.transform}`
+        );
+      });
+
+    svg.call(zoom); // Apply zoom to the SVG
+
+    svg.transition().duration(0).call(zoom.transform, d3.zoomIdentity);
+  }, [privileges, margin, theme, groupPrivileges, role]);
+
+  // UI handler
+  const handleDownload = () => {
+    if (!svgRef.current) return;
+
+    const svgElement = svgRef.current;
+    const serializer = new XMLSerializer();
+    const source = serializer.serializeToString(svgElement);
+
+    const blob = new Blob([source], { type: 'image/svg+xml;charset=utf-8' });
+    const url = URL.createObjectURL(blob);
+
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = `privilege_tree_${role}.svg`;
+    document.body.appendChild(a);
+    a.click();
+
+    document.body.removeChild(a);
+    URL.revokeObjectURL(url);
+  };
+
+  return (
+    <Box
+      sx={{
+        display: 'flex',
+        flexDirection: 'column',
+        gap: '16px',
+        position: 'relative',
+      }}
+    >
+      <CustomButton
+        sx={{
+          alignSelf: 'left',
+          width: 'fit-content',
+          position: 'absolute',
+          top: 0,
+          left: 0,
+        }}
+        onClick={handleDownload}
+      >
+        {btnTrans('downloadChart')}
+      </CustomButton>
+      <svg ref={svgRef}></svg>
+    </Box>
+  );
+};
+
+export default D3PrivilegeTree;

+ 82 - 148
client/src/pages/user/Roles.tsx

@@ -1,33 +1,48 @@
 import { useContext, useEffect, useState } from 'react';
-import { Theme, Chip } from '@mui/material';
+import { Theme } from '@mui/material';
+import { List, ListItemButton, ListItemText, Box } from '@mui/material';
 import { useTranslation } from 'react-i18next';
 import { UserService } from '@/http';
 import { rootContext, dataContext } from '@/context';
-import { useNavigationHook, usePaginationHook } from '@/hooks';
-import AttuGrid from '@/components/grid/Grid';
+import { useNavigationHook } from '@/hooks';
 import DeleteTemplate from '@/components/customDialog/DeleteDialogTemplate';
 import UpdateRoleDialog from './dialogs/UpdateRoleDialog';
 import { ALL_ROUTER_TYPES } from '@/router/consts';
+import CustomToolBar from '@/components/grid/ToolBar';
 import { makeStyles } from '@mui/styles';
-import type {
-  ColDefinitionsType,
-  ToolBarConfig,
-} from '@/components/grid/Types';
+import type { ToolBarConfig } from '@/components/grid/Types';
 import Wrapper from '@/components/layout/Wrapper';
-import type { DeleteRoleParams } from './Types';
-import { getLabelDisplayedRows } from '@/pages/search/Utils';
-import type {
-  RolesWithPrivileges,
-  RBACOptions,
-  DBCollectionsPrivileges,
-} from '@server/types';
+import type { DeleteRoleParams, CreateRoleParams } from './Types';
+import type { RolesWithPrivileges, RBACOptions } from '@server/types';
+import D3PrivilegeTree from './D3PrivilegeTree';
 
 const useStyles = makeStyles((theme: Theme) => ({
   wrapper: {
-    height: `calc(100vh - 160px)`,
+    display: 'flex',
+    flexDirection: 'column',
+    overflow: 'auto',
+  },
+  list: {
+    border: `1px solid ${theme.palette.divider}`,
+    borderRadius: '8px',
+    backgroundColor: theme.palette.background.light,
+    width: '16%',
+    height: 'calc(100vh - 200px)',
+    overflow: 'auto',
+    color: theme.palette.text.primary,
+    boxShadow: theme.shadows[1],
+    minWidth: '200px',
+  },
+  tree: {
+    overflow: 'auto',
   },
   chip: {
-    marginRight: theme.spacing(0.5),
+    marginBottom: theme.spacing(0.5),
+  },
+  groupChip: {
+    marginBottom: theme.spacing(0.5),
+    backgroundColor: theme.palette.primary.dark,
+    color: theme.palette.primary.light,
   },
 }));
 
@@ -63,10 +78,12 @@ const Roles = () => {
         UserService.getRBAC(),
       ]);
 
-      setSelectedRole([]);
+      setSelectedRole([roles[0]]);
       setRbacOptions(rbacs);
 
       setRoles(roles as any);
+
+      return roles;
     } catch (error) {
       setHasPermission(false);
     } finally {
@@ -74,14 +91,23 @@ const Roles = () => {
     }
   };
 
-  const onUpdate = async (data: { isEditing: boolean }) => {
-    fetchRoles();
+  const onUpdate = async (data: {
+    isEditing: boolean;
+    data: CreateRoleParams;
+  }) => {
+    const newRoles = await fetchRoles();
     openSnackBar(
       successTrans(data.isEditing ? 'update' : 'create', {
         name: userTrans('role'),
       })
     );
     handleCloseDialog();
+
+    const roleName = data.data.roleName;
+    const role = newRoles!.find(role => role.roleName === roleName);
+    if (role) {
+      setSelectedRole([role]);
+    }
   };
 
   const handleDelete = async (force?: boolean) => {
@@ -90,12 +116,18 @@ const Roles = () => {
         roleName: role.roleName,
         force,
       };
-      await UserService.deleteRole(param);
+      const d: any = await UserService.deleteRole(param);
+
+      if (d.error_code !== 'Success') {
+        openSnackBar(d.data.reason, 'error');
+        return;
+      }
     }
 
     openSnackBar(successTrans('delete', { name: userTrans('role') }));
-    fetchRoles();
+    await fetchRoles();
     handleCloseDialog();
+    setSelectedRole(roles[0] ? [roles[0]] : []);
   };
 
   const toolbarConfigs: ToolBarConfig[] = [
@@ -210,141 +242,43 @@ const Roles = () => {
     },
   ];
 
-  const colDefinitions: ColDefinitionsType[] = [
-    {
-      id: 'roleName',
-      align: 'left',
-      disablePadding: false,
-      label: userTrans('role'),
-      sortType: 'string',
-    },
-
-    {
-      id: 'privileges',
-      align: 'left',
-      disablePadding: false,
-      formatter({ privileges, roleName }) {
-        const isAdmin = roleName === 'admin';
-
-        // Derive the options arrays as in DBCollectionSelector.
-        const rbacEntries = Object.entries(rbacOptions) as [
-          string,
-          Record<string, string>
-        ][];
-
-        // privileges of the privilege groups
-        const privilegeGroups = rbacEntries.filter(([key]) =>
-          key.endsWith('PrivilegeGroups')
-        );
-
-        const groupPrivileges = new Set(
-          privilegeGroups.reduce(
-            (acc, [_, group]) => acc.concat(Object.values(group)),
-            [] as string[]
-          )
-        );
-
-        let groupCount = 0;
-        let privilegeCount = 0;
-
-        Object.values(privileges as DBCollectionsPrivileges).forEach(
-          dbPrivileges => {
-            Object.values(dbPrivileges.collections).forEach(
-              collectionPrivileges => {
-                Object.keys(collectionPrivileges).forEach(privilege => {
-                  if (groupPrivileges.has(privilege)) {
-                    groupCount++;
-                  } else {
-                    privilegeCount++;
-                  }
-                });
-              }
-            );
-          }
-        );
-
-        return (
-          <div>
-            {
-              <>
-                <div style={{ marginBottom: 2 }}>
-                  <Chip
-                    label={`${userTrans('Group')} (${
-                      isAdmin ? '*' : groupCount
-                    })`}
-                    size="small"
-                    style={{ marginRight: 2 }}
-                  />
-                </div>
-                <div style={{ marginBottom: 2 }}>
-                  <Chip
-                    label={`${userTrans('privileges')} (${
-                      isAdmin ? '*' : privilegeCount
-                    })`}
-                    size="small"
-                    style={{ marginRight: 2 }}
-                  />
-                </div>
-              </>
-            }
-          </div>
-        );
-      },
-      label: userTrans('privileges'),
-      getStyle: () => {
-        return {
-          width: '80%',
-        };
-      },
-    },
-  ];
-
-  const handleSelectChange = (value: any[]) => {
-    setSelectedRole(value);
-  };
-
   useEffect(() => {
     fetchRoles();
   }, [database]);
 
-  const {
-    pageSize,
-    handlePageSize,
-    currentPage,
-    handleCurrentPage,
-    total,
-    data: result,
-    order,
-    orderBy,
-    handleGridSort,
-  } = usePaginationHook(roles || []);
-
-  const handlePageChange = (e: any, page: number) => {
-    handleCurrentPage(page);
+  const handleRoleClick = (role: RolesWithPrivileges) => {
+    setSelectedRole([role]);
   };
 
   return (
     <Wrapper className={classes.wrapper} hasPermission={hasPermission}>
-      <AttuGrid
-        toolbarConfigs={toolbarConfigs}
-        colDefinitions={colDefinitions}
-        rows={result}
-        rowCount={total}
-        primaryKey="roleName"
-        showPagination={true}
-        selected={selectedRole}
-        setSelected={handleSelectChange}
-        page={currentPage}
-        onPageChange={handlePageChange}
-        rowsPerPage={pageSize}
-        rowHeight={69}
-        setRowsPerPage={handlePageSize}
-        isLoading={loading}
-        order={order}
-        orderBy={orderBy}
-        handleSort={handleGridSort}
-        labelDisplayedRows={getLabelDisplayedRows(userTrans('roles'))}
-      />
+      <CustomToolBar toolbarConfigs={toolbarConfigs} />
+
+      <Box sx={{ display: 'flex', flexDirection: 'row', gap: '16px' }}>
+        <Box className={classes.list}>
+          <List>
+            {roles.map(role => (
+              <ListItemButton
+                key={role.roleName}
+                selected={
+                  selectedRole.length > 0 &&
+                  selectedRole[0].roleName === role.roleName
+                }
+                onClick={() => handleRoleClick(role)}
+              >
+                <ListItemText primary={role.roleName} />
+              </ListItemButton>
+            ))}
+          </List>
+        </Box>
+        <div className={classes.tree}>
+          <D3PrivilegeTree
+            privileges={selectedRole[0]?.privileges}
+            role={selectedRole[0]?.roleName}
+            rbacOptions={rbacOptions}
+          />
+        </div>
+      </Box>
     </Wrapper>
   );
 };

+ 7 - 1
server/src/users/users.controller.ts

@@ -24,6 +24,7 @@ import {
   ResourceManagementPrivileges,
   RBACPrivileges,
 } from '../utils';
+import { ErrorCode } from '@zilliz/milvus2-sdk-node';
 
 export class UserController {
   private router: Router;
@@ -440,7 +441,12 @@ export class UserController {
       });
       // if role does not exist, create it
       if (hasRole.hasRole === false) {
-        await this.userService.createRole(clientId, { roleName });
+        const res = await this.userService.createRole(clientId, { roleName });
+
+        // If the role creation fails, propagate the error
+        if (res.error_code !== ErrorCode.SUCCESS) {
+          throw new Error(res.reason);
+        }
       }
 
       // Iterate over each database