github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/ui/dashboard/src/components/dashboards/graphs/Graph/index.tsx (about) 1 import AssetNode from "./AssetNode"; 2 import dagre from "dagre"; 3 import ErrorPanel from "../../Error"; 4 import FloatingEdge from "./FloatingEdge"; 5 import NodeAndEdgePanelInformation from "../../common/NodeAndEdgePanelInformation"; 6 import ReactFlow, { 7 ControlButton, 8 Controls, 9 Edge, 10 MarkerType, 11 Node, 12 Position, 13 ReactFlowProvider, 14 useNodesState, 15 useEdgesState, 16 useReactFlow, 17 } from "reactflow"; 18 import sortBy from "lodash/sortBy"; 19 import useChartThemeColors from "../../../../hooks/useChartThemeColors"; 20 import useNodeAndEdgeData from "../../common/useNodeAndEdgeData"; 21 import { 22 buildNodesAndEdges, 23 foldNodesAndEdges, 24 LeafNodeData, 25 } from "../../common"; 26 import { 27 Category, 28 CategoryMap, 29 Edge as EdgeType, 30 Node as NodeType, 31 } from "../../common/types"; 32 import { 33 DagreRankDir, 34 EdgeStatus, 35 GraphDirection, 36 GraphProperties, 37 GraphProps, 38 GraphStatuses, 39 NodeAndEdgeDataFormat, 40 NodeAndEdgeStatus, 41 NodeStatus, 42 WithStatus, 43 } from "../types"; 44 import { DashboardRunState } from "../../../../types"; 45 import { ExpandedNodes, GraphProvider, useGraph } from "../common/useGraph"; 46 import { getGraphComponent } from ".."; 47 import { registerComponent } from "../../index"; 48 import { 49 ResetLayoutIcon, 50 ZoomIcon, 51 ZoomInIcon, 52 ZoomOutIcon, 53 } from "../../../../constants/icons"; 54 import { useDashboard } from "../../../../hooks/useDashboard"; 55 import { useEffect, useMemo } from "react"; 56 import { usePanel } from "../../../../hooks/usePanel"; 57 import "reactflow/dist/style.css"; 58 59 const nodeWidth = 100; 60 const nodeHeight = 100; 61 62 const getGraphDirection = (direction?: GraphDirection | null): DagreRankDir => { 63 if (!direction) { 64 return "TB"; 65 } 66 67 switch (direction) { 68 case "left_right": 69 case "LR": 70 return "LR"; 71 case "top_down": 72 case "TB": 73 return "TB"; 74 default: 75 return "TB"; 76 } 77 }; 78 79 const getNodeOrEdgeLabel = ( 80 item: NodeType | EdgeType, 81 category: Category | null 82 ) => { 83 if (item.isFolded) { 84 if (item.title) { 85 return item.title; 86 } else if (category?.fold?.title) { 87 return category.fold.title; 88 } else if (category?.title) { 89 return category.title; 90 } else { 91 return category?.name; 92 } 93 } else { 94 if (item.title) { 95 return item.title; 96 } else if (category?.title) { 97 return category.title; 98 } else { 99 return category?.name; 100 } 101 } 102 }; 103 104 const buildGraphNodesAndEdges = ( 105 categories: CategoryMap, 106 data: LeafNodeData | undefined, 107 properties: GraphProperties | undefined, 108 themeColors: any, 109 expandedNodes: ExpandedNodes, 110 status: DashboardRunState 111 ) => { 112 if (!data) { 113 return { 114 nodes: [], 115 edges: [], 116 }; 117 } 118 let nodesAndEdges = buildNodesAndEdges( 119 categories, 120 data, 121 properties, 122 themeColors, 123 false 124 ); 125 126 nodesAndEdges = foldNodesAndEdges(nodesAndEdges, expandedNodes); 127 const direction = getGraphDirection(properties?.direction); 128 const dagreGraph = new dagre.graphlib.Graph(); 129 dagreGraph.setGraph({ 130 rankdir: direction, 131 nodesep: direction === "LR" ? 15 : 110, 132 ranksep: direction === "LR" ? 200 : 60, 133 }); 134 dagreGraph.setDefaultEdgeLabel(() => ({})); 135 nodesAndEdges.edges.forEach((edge) => { 136 dagreGraph.setEdge(edge.from_id, edge.to_id); 137 }); 138 const finalNodes: NodeType[] = []; 139 nodesAndEdges.nodes.forEach((node) => { 140 const nodeEdges = dagreGraph.nodeEdges(node.id); 141 if ( 142 status === "complete" || 143 status === "error" || 144 (nodeEdges && nodeEdges.length > 0) 145 ) { 146 dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }); 147 finalNodes.push(node); 148 } 149 }); 150 dagre.layout(dagreGraph); 151 const innerGraph = dagreGraph.graph(); 152 const nodes: Node[] = []; 153 const edges: Edge[] = []; 154 for (const node of finalNodes) { 155 const matchingNode = dagreGraph.node(node.id); 156 const matchingCategory = node.category 157 ? nodesAndEdges.categories[node.category] 158 : null; 159 let categoryColor = matchingCategory ? matchingCategory.color : null; 160 if (categoryColor === "auto") { 161 categoryColor = null; 162 } 163 nodes.push({ 164 type: "asset", 165 id: node.id, 166 dragHandle: ".custom-drag-handle", 167 position: { x: matchingNode.x, y: matchingNode.y }, 168 // height: 70, 169 data: { 170 category: 171 node.category && categories[node.category] 172 ? categories[node.category] 173 : null, 174 color: categoryColor, 175 properties: matchingCategory ? matchingCategory.properties : null, 176 href: matchingCategory ? matchingCategory.href : null, 177 icon: matchingCategory ? matchingCategory.icon : null, 178 fold: matchingCategory ? matchingCategory.fold : null, 179 isFolded: node.isFolded, 180 foldedNodes: node.foldedNodes, 181 label: getNodeOrEdgeLabel(node, matchingCategory), 182 row_data: node.row_data, 183 themeColors, 184 }, 185 }); 186 } 187 for (const edge of nodesAndEdges.edges) { 188 // The color rules are: 189 // 1) If the target node of the edge specifies a category and that 190 // category specifies a colour of "auto", refer to rule 3). 191 // 2) Else if the edge specifies a category and that category specifies a colour, 192 // that colour is used at 100% opacity for both the edge and the label. 193 // 3) Else if the target node of the edge specifies a category and that 194 // category specifies a colour, that colour is used at 50% opacity for the 195 // edge and 70% opacity for the label. 196 // 4) Else use black scale 4 at 100% opacity for both the edge and the label. 197 198 const matchingCategory = edge.category 199 ? nodesAndEdges.categories[edge.category] 200 : null; 201 let categoryColor = matchingCategory ? matchingCategory.color : null; 202 if (categoryColor === "auto") { 203 categoryColor = null; 204 } 205 206 let targetNodeColor; 207 const targetNode = nodesAndEdges.nodeMap[edge.to_id]; 208 if (targetNode) { 209 const targetCategory = nodesAndEdges.categories[targetNode.category]; 210 if (targetCategory) { 211 targetNodeColor = targetCategory.color; 212 } 213 } 214 const color = categoryColor 215 ? categoryColor 216 : targetNodeColor 217 ? targetNodeColor 218 : themeColors.blackScale4; 219 const labelOpacity = categoryColor ? 1 : targetNodeColor ? 0.7 : 1; 220 const lineOpacity = categoryColor ? 1 : targetNodeColor ? 0.7 : 1; 221 edges.push({ 222 type: "floating", 223 id: edge.id, 224 source: edge.from_id, 225 target: edge.to_id, 226 label: edge.title, 227 labelBgPadding: [11, 0], 228 markerEnd: { 229 color, 230 width: 20, 231 height: 20, 232 strokeWidth: 1, 233 type: MarkerType.Arrow, 234 }, 235 data: { 236 category: 237 edge.category && categories[edge.category] 238 ? categories[edge.category] 239 : null, 240 color, 241 properties: matchingCategory ? matchingCategory.properties : null, 242 labelOpacity, 243 lineOpacity, 244 row_data: edge.row_data, 245 label: getNodeOrEdgeLabel(edge, matchingCategory), 246 themeColors, 247 }, 248 }); 249 } 250 251 nodes.forEach((node) => { 252 const nodeWithPosition = dagreGraph.node(node.id); 253 node.targetPosition = 254 direction === "LR" ? ("left" as Position) : ("top" as Position); 255 node.sourcePosition = 256 direction === "LR" ? ("right" as Position) : ("bottom" as Position); 257 258 // We are shifting the dagre node position (anchor=center center) to the top left 259 // so it matches the React Flow node anchor point (top left). 260 node.position = { 261 x: nodeWithPosition.x - nodeWidth / 2, 262 y: nodeWithPosition.y - nodeHeight / 2, 263 }; 264 265 return node; 266 }); 267 268 return { 269 nodes, 270 edges, 271 width: innerGraph.width < 0 ? 0 : innerGraph.width, 272 height: innerGraph.height < 0 ? 0 : innerGraph.height, 273 }; 274 }; 275 276 const useGraphOptions = (props: GraphProps) => { 277 const { nodesAndEdges } = useGraphNodesAndEdges( 278 props.categories, 279 props.data, 280 props.properties, 281 props.status 282 ); 283 const { setGraphEdges, setGraphNodes } = useGraph(); 284 const [nodes, setNodes, onNodesChange] = useNodesState(nodesAndEdges.nodes); 285 const [edges, setEdges, onEdgesChange] = useEdgesState(nodesAndEdges.edges); 286 287 useEffect(() => { 288 setGraphEdges(edges); 289 setGraphNodes(nodes); 290 }, [nodes, edges, setGraphNodes, setGraphEdges]); 291 292 useEffect(() => { 293 setNodes(nodesAndEdges.nodes); 294 }, [nodesAndEdges.nodes, setNodes]); 295 296 useEffect(() => { 297 setEdges(nodesAndEdges.edges); 298 }, [nodesAndEdges.edges, setEdges]); 299 300 return { 301 nodes, 302 edges, 303 width: nodesAndEdges.width, 304 height: nodesAndEdges.height, 305 setEdges, 306 onNodesChange, 307 onEdgesChange, 308 }; 309 }; 310 311 const useGraphNodesAndEdges = ( 312 categories: CategoryMap, 313 data: LeafNodeData | undefined, 314 properties: GraphProperties | undefined, 315 status: DashboardRunState 316 ) => { 317 const { expandedNodes } = useGraph(); 318 const themeColors = useChartThemeColors(); 319 const nodesAndEdges = useMemo( 320 () => 321 buildGraphNodesAndEdges( 322 categories, 323 data, 324 properties, 325 themeColors, 326 expandedNodes, 327 status 328 ), 329 [categories, data, expandedNodes, properties, status, themeColors] 330 ); 331 332 return { 333 nodesAndEdges, 334 }; 335 }; 336 337 const ZoomInControl = () => { 338 const { zoomIn } = useReactFlow(); 339 return ( 340 <ControlButton 341 className="bg-dashboard hover:bg-black-scale-2 text-foreground border-0" 342 onClick={() => zoomIn()} 343 title="Zoom In" 344 > 345 <ZoomInIcon className="w-5 h-5" /> 346 </ControlButton> 347 ); 348 }; 349 350 const ZoomOutControl = () => { 351 const { zoomOut } = useReactFlow(); 352 return ( 353 <ControlButton 354 className="bg-dashboard hover:bg-black-scale-2 text-foreground border-0" 355 onClick={() => zoomOut()} 356 title="Zoom Out" 357 > 358 <ZoomOutIcon className="w-5 h-5" /> 359 </ControlButton> 360 ); 361 }; 362 363 const ResetZoomControl = () => { 364 const { fitView } = useReactFlow(); 365 366 return ( 367 <ControlButton 368 className="bg-dashboard hover:bg-black-scale-2 text-foreground border-0" 369 onClick={() => fitView()} 370 title="Fit View" 371 > 372 <ZoomIcon className="w-5 h-5" /> 373 </ControlButton> 374 ); 375 }; 376 377 const RecalcLayoutControl = () => { 378 const { recalcLayout } = useGraph(); 379 380 return ( 381 <ControlButton 382 className="bg-dashboard hover:bg-black-scale-2 text-foreground border-0" 383 onClick={() => { 384 recalcLayout(); 385 }} 386 title="Reset Layout" 387 > 388 <ResetLayoutIcon className="w-5 h-5" /> 389 </ControlButton> 390 ); 391 }; 392 393 const CustomControls = () => { 394 return ( 395 <Controls 396 className="flex flex-col space-y-px border-0 shadow-0" 397 showFitView={false} 398 showInteractive={false} 399 showZoom={false} 400 > 401 <ZoomInControl /> 402 <ZoomOutControl /> 403 <ResetZoomControl /> 404 <RecalcLayoutControl /> 405 </Controls> 406 ); 407 }; 408 409 const useNodeAndEdgePanelInformation = ( 410 nodeAndEdgeStatus: NodeAndEdgeStatus, 411 dataFormat: NodeAndEdgeDataFormat, 412 nodes: Node[], 413 status: DashboardRunState 414 ) => { 415 const { setShowPanelInformation, setPanelInformation } = usePanel(); 416 417 const statuses = useMemo<GraphStatuses>(() => { 418 const initializedWiths: WithStatus[] = []; 419 const initializedNodes: NodeStatus[] = []; 420 const initializedEdges: EdgeStatus[] = []; 421 const blockedWiths: WithStatus[] = []; 422 const blockedNodes: NodeStatus[] = []; 423 const blockedEdges: EdgeStatus[] = []; 424 const runningWiths: WithStatus[] = []; 425 const runningNodes: NodeStatus[] = []; 426 const runningEdges: EdgeStatus[] = []; 427 const cancelledWiths: WithStatus[] = []; 428 const cancelledNodes: NodeStatus[] = []; 429 const cancelledEdges: EdgeStatus[] = []; 430 const errorWiths: WithStatus[] = []; 431 const errorNodes: NodeStatus[] = []; 432 const errorEdges: EdgeStatus[] = []; 433 const completeWiths: WithStatus[] = []; 434 const completeNodes: NodeStatus[] = []; 435 const completeEdges: EdgeStatus[] = []; 436 if (nodeAndEdgeStatus) { 437 for (const withStatus of sortBy(Object.values(nodeAndEdgeStatus.withs), [ 438 "title", 439 "id", 440 ])) { 441 if (withStatus.state === "initialized") { 442 initializedWiths.push(withStatus); 443 } else if (withStatus.state === "blocked") { 444 blockedWiths.push(withStatus); 445 } else if (withStatus.state === "running") { 446 runningWiths.push(withStatus); 447 } else if (withStatus.state === "cancelled") { 448 cancelledWiths.push(withStatus); 449 } else if (withStatus.state === "error") { 450 errorWiths.push(withStatus); 451 } else { 452 completeWiths.push(withStatus); 453 } 454 } 455 456 const sortedNodes = sortBy(nodeAndEdgeStatus.nodes, [ 457 "title", 458 "category.title", 459 "category.name", 460 "id", 461 ]); 462 for (let idx = 0; idx < sortedNodes.length; idx++) { 463 const node = sortedNodes[idx] as NodeStatus; 464 if (node.state === "initialized") { 465 initializedNodes.push(node); 466 } else if (node.state === "blocked") { 467 blockedNodes.push(node); 468 } else if (node.state === "running") { 469 runningNodes.push(node); 470 } else if (node.state === "cancelled") { 471 cancelledNodes.push(node); 472 } else if (node.state === "error") { 473 errorNodes.push(node); 474 } else { 475 completeNodes.push(node); 476 } 477 } 478 479 const sortedEdges = sortBy(nodeAndEdgeStatus.edges, [ 480 "title", 481 "category.title", 482 "category.name", 483 "id", 484 ]); 485 for (let idx = 0; idx < sortedEdges.length; idx++) { 486 const edge = sortedEdges[idx] as EdgeStatus; 487 if (edge.state === "initialized") { 488 initializedEdges.push(edge); 489 } else if (edge.state === "blocked") { 490 blockedEdges.push(edge); 491 } else if (edge.state === "running") { 492 runningEdges.push(edge); 493 } else if (edge.state === "cancelled") { 494 cancelledEdges.push(edge); 495 } else if (edge.state === "error") { 496 errorEdges.push(edge); 497 } else { 498 completeEdges.push(edge); 499 } 500 } 501 } 502 return { 503 initialized: { 504 total: 505 initializedWiths.length + 506 initializedNodes.length + 507 initializedEdges.length, 508 withs: initializedWiths, 509 nodes: initializedNodes, 510 edges: initializedEdges, 511 }, 512 blocked: { 513 total: blockedWiths.length + blockedNodes.length + blockedEdges.length, 514 withs: blockedWiths, 515 nodes: blockedNodes, 516 edges: blockedEdges, 517 }, 518 running: { 519 total: runningWiths.length + runningNodes.length + runningEdges.length, 520 withs: runningWiths, 521 nodes: runningNodes, 522 edges: runningEdges, 523 }, 524 cancelled: { 525 total: 526 cancelledWiths.length + cancelledNodes.length + cancelledEdges.length, 527 withs: cancelledWiths, 528 nodes: cancelledNodes, 529 edges: cancelledEdges, 530 }, 531 error: { 532 total: errorWiths.length + errorNodes.length + errorEdges.length, 533 withs: errorWiths, 534 nodes: errorNodes, 535 edges: errorEdges, 536 }, 537 complete: { 538 total: 539 completeWiths.length + completeNodes.length + completeEdges.length, 540 withs: completeWiths, 541 nodes: completeNodes, 542 edges: completeEdges, 543 }, 544 }; 545 }, [nodeAndEdgeStatus]); 546 547 useEffect(() => { 548 if ( 549 !nodeAndEdgeStatus || 550 dataFormat === "LEGACY" || 551 (statuses.initialized.total === 0 && 552 statuses.blocked.total === 0 && 553 statuses.running.total === 0 && 554 statuses.error.total === 0 && 555 status === "complete" && 556 nodes.length > 0) 557 ) { 558 setShowPanelInformation(false); 559 setPanelInformation(null); 560 return; 561 } 562 // @ts-ignore 563 setPanelInformation(() => ( 564 <NodeAndEdgePanelInformation 565 nodes={nodes} 566 status={status} 567 statuses={statuses} 568 /> 569 )); 570 setShowPanelInformation(true); 571 }, [ 572 dataFormat, 573 nodeAndEdgeStatus, 574 nodes, 575 status, 576 statuses, 577 setPanelInformation, 578 setShowPanelInformation, 579 ]); 580 }; 581 582 const Graph = (props) => { 583 const { selectedPanel } = useDashboard(); 584 const graphOptions = useGraphOptions(props); 585 useNodeAndEdgePanelInformation( 586 props.nodeAndEdgeStatus, 587 props.dataFormat, 588 graphOptions.nodes, 589 props.status 590 ); 591 592 const nodeTypes = useMemo( 593 () => ({ 594 asset: AssetNode, 595 }), 596 [] 597 ); 598 599 const edgeTypes = useMemo( 600 () => ({ 601 floating: FloatingEdge, 602 }), 603 [] 604 ); 605 606 return ( 607 <div 608 style={{ 609 height: graphOptions.height, 610 maxHeight: selectedPanel ? undefined : 600, 611 minHeight: 175, 612 }} 613 > 614 <ReactFlow 615 // @ts-ignore 616 edgeTypes={edgeTypes} 617 edges={graphOptions.edges} 618 fitView 619 nodes={graphOptions.nodes} 620 nodeTypes={nodeTypes} 621 onEdgesChange={graphOptions.onEdgesChange} 622 onNodesChange={graphOptions.onNodesChange} 623 preventScrolling={false} 624 proOptions={{ 625 account: "paid-pro", 626 hideAttribution: true, 627 }} 628 zoomOnScroll={false} 629 > 630 <CustomControls /> 631 </ReactFlow> 632 </div> 633 ); 634 }; 635 636 const GraphWrapper = (props: GraphProps) => { 637 const nodeAndEdgeData = useNodeAndEdgeData( 638 props.data, 639 props.properties, 640 props.status 641 ); 642 643 if (!nodeAndEdgeData) { 644 return null; 645 } 646 647 return ( 648 <ReactFlowProvider> 649 <GraphProvider> 650 <Graph 651 {...props} 652 categories={nodeAndEdgeData.categories} 653 data={nodeAndEdgeData.data} 654 dataFormat={nodeAndEdgeData.dataFormat} 655 properties={nodeAndEdgeData.properties} 656 nodeAndEdgeStatus={nodeAndEdgeData.status} 657 /> 658 </GraphProvider> 659 </ReactFlowProvider> 660 ); 661 }; 662 663 const renderGraph = (definition: GraphProps) => { 664 // We default to sankey diagram if not specified 665 const { display_type = "graph" } = definition; 666 667 const graph = getGraphComponent(display_type); 668 669 if (!graph) { 670 return <ErrorPanel error={`Unknown graph type ${display_type}`} />; 671 } 672 673 const Component = graph.component; 674 return <Component {...definition} />; 675 }; 676 677 const RenderGraph = (props: GraphProps) => { 678 return renderGraph(props); 679 }; 680 681 registerComponent("graph", RenderGraph); 682 683 export default GraphWrapper;