D3PrivilegeTree.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import React, { useEffect, useRef } from 'react';
  2. import * as d3 from 'd3';
  3. import { useTranslation } from 'react-i18next';
  4. import { useTheme, Box } from '@mui/material';
  5. import CustomButton from '@/components/customButton/CustomButton';
  6. import type { DBCollectionsPrivileges, RBACOptions } from '@server/types';
  7. interface Props {
  8. privileges: DBCollectionsPrivileges;
  9. role: string;
  10. margin?: { top: number; right: number; bottom: number; left: number };
  11. rbacOptions: RBACOptions;
  12. }
  13. interface TreeNode {
  14. name: string;
  15. children?: TreeNode[];
  16. value?: boolean;
  17. type?: string;
  18. }
  19. const D3PrivilegeTree: React.FC<Props> = ({
  20. role,
  21. privileges,
  22. margin = { top: 20, right: 20, bottom: 20, left: 60 },
  23. rbacOptions,
  24. }) => {
  25. // i18n
  26. const { t: btnTrans } = useTranslation('btn');
  27. const { t: userTrans } = useTranslation('user');
  28. // theme
  29. const theme = useTheme();
  30. // Refs for SVG and G elements
  31. const svgRef = useRef<SVGSVGElement | null>(null);
  32. const gRef = useRef<SVGGElement | null>(null);
  33. // Derive the options arrays as in DBCollectionSelector.
  34. const rbacEntries = Object.entries(rbacOptions) as [
  35. string,
  36. Record<string, string>
  37. ][];
  38. // privileges of the privilege groups
  39. const privilegeGroups = rbacEntries.filter(([key]) =>
  40. key.endsWith('PrivilegeGroups')
  41. );
  42. const groupPrivileges = new Set(
  43. privilegeGroups.reduce(
  44. (acc, [_, group]) => acc.concat(Object.values(group)),
  45. [] as string[]
  46. )
  47. );
  48. // Transform privileges data into d3.hierarchy structure
  49. const transformData = (privileges: DBCollectionsPrivileges): TreeNode => {
  50. let nodeCount = 0;
  51. const res = {
  52. name: role,
  53. type: 'role',
  54. children: Object.entries(privileges).map(([dbKey, dbValue]) => ({
  55. name: dbKey === '*' ? 'All Databases(*)' : dbKey,
  56. type: 'database',
  57. children: Object.entries(dbValue.collections).map(
  58. ([colKey, colValue]) => {
  59. const children = Object.entries(colValue).map(([priv, val]) => {
  60. nodeCount += 1;
  61. return {
  62. name: priv,
  63. value: val,
  64. };
  65. });
  66. children.sort((a, b) => {
  67. const aInGroup = groupPrivileges.has(a.name);
  68. const bInGroup = groupPrivileges.has(b.name);
  69. return aInGroup === bInGroup ? 0 : aInGroup ? -1 : 1;
  70. });
  71. return {
  72. name: colKey === '*' ? 'All Collections(*)' : colKey,
  73. children,
  74. type: 'collection',
  75. };
  76. }
  77. ),
  78. })),
  79. };
  80. return res;
  81. };
  82. useEffect(() => {
  83. if (!privileges) return;
  84. const treeNode = transformData(privileges);
  85. // get svg width and height by accessing dom element
  86. const svgWidth = svgRef.current?.clientWidth || 0;
  87. const svgHeight = svgRef.current?.clientHeight || 0;
  88. // Calculate height based on tree structure rather than total node count
  89. // Max nodes at any level would be a better indication for vertical space needed
  90. const maxNodesAtLevel = Math.max(
  91. 1, // Role level
  92. Object.keys(privileges).length, // Database level
  93. ...Object.values(privileges).map(
  94. db => Object.keys(db.collections).length
  95. ), // Collection level
  96. ...Object.values(privileges).reduce(
  97. (acc, db) =>
  98. acc.concat(
  99. Object.values(db.collections).map(col => Object.keys(col).length)
  100. ),
  101. [] as number[] // Privilege level
  102. )
  103. );
  104. // Increase the multiplier to provide more space between nodes
  105. const nodeSpacing = 30;
  106. let height =
  107. maxNodesAtLevel * nodeSpacing + margin.top + margin.bottom + 120; // Added extra padding
  108. // Ensure minimum height for better visualization
  109. height = Math.max(height, svgHeight);
  110. // Add additional padding for large datasets
  111. if (maxNodesAtLevel > 15) {
  112. height += maxNodesAtLevel * 10; // Extra space for very large datasets
  113. }
  114. const fontSize = 12; // Font size for text labels
  115. const marginLeft = role.length * fontSize; // Adjust margin left based on role length
  116. // Clear SVG
  117. d3.select(svgRef.current).selectAll('*').remove();
  118. // Create hierarchy and layout
  119. const root = d3.hierarchy<TreeNode>(treeNode);
  120. const treeLayout = d3
  121. .tree<TreeNode>()
  122. .size([height - margin.top - margin.bottom, svgWidth / 2])
  123. .separation((a: any, b: any) => {
  124. return a.parent === b.parent ? 3 : 4;
  125. }); // Swap width and height for vertical layout
  126. treeLayout(root);
  127. // Calculate the bounds of the tree
  128. let x0 = Infinity;
  129. let x1 = -Infinity;
  130. let y0 = Infinity;
  131. let y1 = -Infinity;
  132. root.each((d: any) => {
  133. if (d.x > x1) x1 = d.x;
  134. if (d.x < x0) x0 = d.x;
  135. if (d.y > y1) y1 = d.y;
  136. if (d.y < y0) y0 = d.y;
  137. });
  138. // Create SVG container with expanded height
  139. const svg = d3.select(svgRef.current);
  140. // Add a group for zoomable content
  141. const g = svg.append('g').attr('transform', `translate(0, ${margin.top})`); // Add top margin
  142. gRef.current = g.node();
  143. const colorMap: { [key: string]: any } = {
  144. role: theme.palette.primary.dark,
  145. database: theme.palette.primary.dark,
  146. collection: theme.palette.primary.dark,
  147. };
  148. // Create links (connections between nodes)
  149. g.selectAll('.link')
  150. .data(root.links())
  151. .enter()
  152. .append('path')
  153. .attr('class', 'link')
  154. .attr('fill', 'none')
  155. .attr('stroke', d => colorMap[d.source.data.type!])
  156. .attr('stroke-width', 1)
  157. .attr(
  158. 'd',
  159. (d: any) =>
  160. `M${d.source.y},${d.source.x} C${(d.source.y + d.target.y) / 2},${
  161. d.source.x
  162. } ` +
  163. `${(d.source.y + d.target.y) / 2},${d.target.x} ${d.target.y},${
  164. d.target.x
  165. }`
  166. );
  167. // Create nodes
  168. const nodes = g
  169. .selectAll('.node')
  170. .data(root.descendants())
  171. .enter()
  172. .append('g')
  173. .attr('class', 'node')
  174. .attr('transform', d => `translate(${d.y! + 3},${d.x})`);
  175. // Add circles for nodes
  176. nodes
  177. .append('circle')
  178. .attr('r', 3)
  179. .attr('stroke', theme.palette.primary.main)
  180. .attr('fill', (d, index) =>
  181. d.children || index == 0 || groupPrivileges.has(d.data.name)
  182. ? `${theme.palette.primary.main}`
  183. : `transparent`
  184. );
  185. // Add text labels
  186. nodes
  187. .append('text')
  188. .attr('dy', d => (d.children && d.data.name !== role ? -3 : 3))
  189. .attr('x', d => (d.children ? -10 : 10))
  190. .attr('text-anchor', d => (d.children ? 'end' : 'start'))
  191. .attr('font-family', 'Inter')
  192. .attr('font-style', 'Italic')
  193. .attr('font-size', fontSize)
  194. .attr('fill', d =>
  195. groupPrivileges.has(d.data.name)
  196. ? `${theme.palette.primary.dark}`
  197. : `${theme.palette.text.primary}`
  198. )
  199. .text(d =>
  200. groupPrivileges.has(d.data.name) && d.data.type !== 'role'
  201. ? `${userTrans('privilegeGroup')}: ${d.data.name}`
  202. : d.data.name
  203. );
  204. // Calculate scale to fit the entire tree
  205. const treeWidth = y1 - y0 + marginLeft + 50; // Add some padding
  206. const treeHeight = x1 - x0 + 60; // Increased padding
  207. const scaleX = svgWidth / treeWidth;
  208. const scaleY = svgHeight / treeHeight;
  209. const scale = Math.min(scaleX, scaleY, 0.95); // Slightly reduce to ensure visibility
  210. // Calculate translation to center the tree
  211. const centerX = (svgWidth - treeWidth * scale) / 2 - 60;
  212. const centerY =
  213. (svgHeight - treeHeight * scale) / 2 - x0 * scale + margin.top;
  214. // Add zoom functionality
  215. const zoom: any = d3
  216. .zoom()
  217. .scaleExtent([scale, 4]) // Set minimum zoom to fit entire tree
  218. .on('zoom', event => {
  219. g.attr('transform', event.transform);
  220. });
  221. svg.call(zoom); // Apply zoom to the SVG
  222. // Apply initial transform to show the entire tree
  223. svg
  224. .transition()
  225. .duration(250)
  226. .call(
  227. zoom.transform,
  228. d3.zoomIdentity.translate(centerX, centerY).scale(scale)
  229. );
  230. }, [JSON.stringify({ privileges, margin, groupPrivileges, role })]);
  231. // Update colors on theme change
  232. useEffect(() => {
  233. const updateColors = () => {
  234. const colorMap: { [key: string]: any } = {
  235. role: theme.palette.primary.dark,
  236. database: theme.palette.primary.dark,
  237. collection: theme.palette.primary.dark,
  238. };
  239. // Update link colors
  240. d3.select(svgRef.current)
  241. .selectAll('.link')
  242. .attr('stroke', (d: any) => colorMap[d.source.data.type!]);
  243. // Update node colors
  244. d3.select(svgRef.current)
  245. .selectAll('.node circle')
  246. .attr('stroke', theme.palette.primary.main)
  247. .attr('fill', (d: any, index) =>
  248. d.children || index == 0 || groupPrivileges.has(d.data.name)
  249. ? `${theme.palette.primary.main}`
  250. : `transparent`
  251. );
  252. // Update text colors
  253. d3.select(svgRef.current)
  254. .selectAll('.node text')
  255. .attr('fill', (d: any) =>
  256. groupPrivileges.has(d.data.name)
  257. ? `${theme.palette.primary.dark}`
  258. : `${theme.palette.text.primary}`
  259. );
  260. };
  261. updateColors();
  262. }, [theme]);
  263. // UI handler
  264. const handleDownload = () => {
  265. if (!svgRef.current) return;
  266. const svgElement = svgRef.current;
  267. const serializer = new XMLSerializer();
  268. const source = serializer.serializeToString(svgElement);
  269. const blob = new Blob([source], { type: 'image/svg+xml;charset=utf-8' });
  270. const url = URL.createObjectURL(blob);
  271. const a = document.createElement('a');
  272. a.href = url;
  273. a.download = `privilege_tree_${role}.svg`;
  274. document.body.appendChild(a);
  275. a.click();
  276. document.body.removeChild(a);
  277. URL.revokeObjectURL(url);
  278. };
  279. return (
  280. <Box
  281. sx={{
  282. display: 'flex',
  283. flexDirection: 'column',
  284. gap: '16px',
  285. position: 'relative',
  286. width: '100%',
  287. height: '100%',
  288. }}
  289. >
  290. <CustomButton
  291. sx={{
  292. alignSelf: 'left',
  293. width: 'fit-content',
  294. position: 'absolute',
  295. top: 0,
  296. left: 0,
  297. }}
  298. onClick={handleDownload}
  299. >
  300. {btnTrans('downloadChart')}
  301. </CustomButton>
  302. <svg ref={svgRef} style={{ width: '100%', height: '100%' }}></svg>
  303. </Box>
  304. );
  305. };
  306. export default D3PrivilegeTree;