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;