123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353 |
- 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): 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 res;
- };
- useEffect(() => {
- if (!privileges) return;
- const treeNode = transformData(privileges);
- // get svg width and height by accessing dom element
- const svgWidth = svgRef.current?.clientWidth || 0;
- const svgHeight = svgRef.current?.clientHeight || 0;
- // Calculate height based on tree structure rather than total node count
- // Max nodes at any level would be a better indication for vertical space needed
- const maxNodesAtLevel = Math.max(
- 1, // Role level
- Object.keys(privileges).length, // Database level
- ...Object.values(privileges).map(
- db => Object.keys(db.collections).length
- ), // Collection level
- ...Object.values(privileges).reduce(
- (acc, db) =>
- acc.concat(
- Object.values(db.collections).map(col => Object.keys(col).length)
- ),
- [] as number[] // Privilege level
- )
- );
- // Increase the multiplier to provide more space between nodes
- const nodeSpacing = 30;
- let height =
- maxNodesAtLevel * nodeSpacing + margin.top + margin.bottom + 120; // Added extra padding
- // Ensure minimum height for better visualization
- height = Math.max(height, svgHeight);
- // Add additional padding for large datasets
- if (maxNodesAtLevel > 15) {
- height += maxNodesAtLevel * 10; // Extra space for very large datasets
- }
- 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>(treeNode);
- const treeLayout = d3
- .tree<TreeNode>()
- .size([height - margin.top - margin.bottom, svgWidth / 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;
- });
- // Create SVG container with expanded height
- const svg = d3.select(svgRef.current);
- // Add a group for zoomable content
- const g = svg.append('g').attr('transform', `translate(0, ${margin.top})`); // Add top margin
- 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
- );
- // Calculate scale to fit the entire tree
- const treeWidth = y1 - y0 + marginLeft + 50; // Add some padding
- const treeHeight = x1 - x0 + 60; // Increased padding
- const scaleX = svgWidth / treeWidth;
- const scaleY = svgHeight / treeHeight;
- const scale = Math.min(scaleX, scaleY, 0.95); // Slightly reduce to ensure visibility
- // Calculate translation to center the tree
- const centerX = (svgWidth - treeWidth * scale) / 2 - 60;
- const centerY =
- (svgHeight - treeHeight * scale) / 2 - x0 * scale + margin.top;
- // Add zoom functionality
- const zoom: any = d3
- .zoom()
- .scaleExtent([scale, 4]) // Set minimum zoom to fit entire tree
- .on('zoom', event => {
- g.attr('transform', event.transform);
- });
- svg.call(zoom); // Apply zoom to the SVG
- // Apply initial transform to show the entire tree
- svg
- .transition()
- .duration(250)
- .call(
- zoom.transform,
- d3.zoomIdentity.translate(centerX, centerY).scale(scale)
- );
- }, [JSON.stringify({ privileges, margin, groupPrivileges, role })]);
- // Update colors on theme change
- useEffect(() => {
- const updateColors = () => {
- const colorMap: { [key: string]: any } = {
- role: theme.palette.primary.dark,
- database: theme.palette.primary.dark,
- collection: theme.palette.primary.dark,
- };
- // Update link colors
- d3.select(svgRef.current)
- .selectAll('.link')
- .attr('stroke', (d: any) => colorMap[d.source.data.type!]);
- // Update node colors
- d3.select(svgRef.current)
- .selectAll('.node circle')
- .attr('stroke', theme.palette.primary.main)
- .attr('fill', (d: any, index) =>
- d.children || index == 0 || groupPrivileges.has(d.data.name)
- ? `${theme.palette.primary.main}`
- : `transparent`
- );
- // Update text colors
- d3.select(svgRef.current)
- .selectAll('.node text')
- .attr('fill', (d: any) =>
- groupPrivileges.has(d.data.name)
- ? `${theme.palette.primary.dark}`
- : `${theme.palette.text.primary}`
- );
- };
- updateColors();
- }, [theme]);
- // 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',
- width: '100%',
- height: '100%',
- }}
- >
- <CustomButton
- sx={{
- alignSelf: 'left',
- width: 'fit-content',
- position: 'absolute',
- top: 0,
- left: 0,
- }}
- onClick={handleDownload}
- >
- {btnTrans('downloadChart')}
- </CustomButton>
- <svg ref={svgRef} style={{ width: '100%', height: '100%' }}></svg>
- </Box>
- );
- };
- export default D3PrivilegeTree;
|