oneuptime/Common/UI/Components/Graphs/ServiceDependencyGraph.tsx

286 lines
8.8 KiB
TypeScript

import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ReactFlow, {
Background,
Controls,
Edge,
MarkerType,
MiniMap,
Node,
Position,
} from "reactflow";
import "reactflow/dist/style.css";
import type { ElkExtendedEdge, ElkNode, LayoutOptions } from "elkjs";
import ELK from "elkjs/lib/elk.bundled.js";
// Minimal interface for the ELK layout engine we rely on.
interface ElkLayoutEngine {
layout: (graph: ElkNode) => Promise<ElkNode>;
}
export interface ServiceNodeData {
id: string;
name: string;
color?: string;
}
export interface ServiceEdgeData {
fromServiceId: string;
toServiceId: string;
}
export interface ServiceDependencyGraphProps {
services: Array<ServiceNodeData>;
dependencies: Array<ServiceEdgeData>;
}
const ServiceDependencyGraph: FunctionComponent<ServiceDependencyGraphProps> = (
props: ServiceDependencyGraphProps,
): ReactElement => {
const computeLuminance: (r: number, g: number, b: number) => number = (
r: number,
g: number,
b: number,
): number => {
const transform: (v: number) => number = (v: number): number => {
const c: number = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
const R: number = transform(r);
const G: number = transform(g);
const B: number = transform(b);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
};
const getContrastText: (bg?: string) => string = (bg?: string): string => {
if (!bg) {
return "#111827"; // gray-900
}
// normalize to hex like #rrggbb
let hex: string = bg.trim();
if (hex.startsWith("rgb")) {
// basic rgb(a) parser
const m: RegExpMatchArray | null = hex
.replace(/\s+/g, "")
.match(/rgba?\((\d+),(\d+),(\d+)/i);
if (m) {
const r: number = parseInt(m[1] as string, 10);
const g: number = parseInt(m[2] as string, 10);
const b: number = parseInt(m[3] as string, 10);
const luminance: number = computeLuminance(r, g, b);
return luminance > 0.5 ? "#111827" : "#ffffff";
}
return "#111827";
}
if (hex[0] === "#") {
hex = hex.slice(1);
}
if (hex.length === 3) {
hex = hex
.split("")
.map((c: string): string => {
return c + c;
})
.join("");
}
if (hex.length !== 6) {
return "#111827";
}
const r: number = parseInt(hex.slice(0, 2), 16);
const g: number = parseInt(hex.slice(2, 4), 16);
const b: number = parseInt(hex.slice(4, 6), 16);
const luminance: number = computeLuminance(r, g, b);
return luminance > 0.5 ? "#111827" : "#ffffff";
};
const [rfNodes, setRfNodes] = useState<Node[]>([]);
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
useEffect((): void => {
const elk: ElkLayoutEngine = new ELK() as unknown as ElkLayoutEngine;
// fixed node dimensions for layout (px)
const NODE_WIDTH: number = 220;
const NODE_HEIGHT: number = 56;
const sortedServices: Array<ServiceNodeData> = [...props.services].sort(
(a: ServiceNodeData, b: ServiceNodeData): number => {
return a.name.localeCompare(b.name) || a.id.localeCompare(b.id);
},
);
const sortedDeps: Array<ServiceEdgeData> = [...props.dependencies].sort(
(a: ServiceEdgeData, b: ServiceEdgeData): number => {
if (a.fromServiceId === b.fromServiceId) {
return a.toServiceId.localeCompare(b.toServiceId);
}
return a.fromServiceId.localeCompare(b.fromServiceId);
},
);
const elkGraph: ElkNode = {
id: "root",
layoutOptions: {
algorithm: "layered",
"elk.direction": "RIGHT",
"elk.layered.spacing.nodeNodeBetweenLayers": "120",
"elk.spacing.nodeNode": "60",
"elk.edgeRouting": "POLYLINE",
} as LayoutOptions,
children: sortedServices.map((svc: ServiceNodeData): ElkNode => {
return {
id: svc.id,
width: NODE_WIDTH,
height: NODE_HEIGHT,
} as ElkNode;
}),
edges: sortedDeps.map((dep: ServiceEdgeData): ElkExtendedEdge => {
return {
id: `e-${dep.fromServiceId}-${dep.toServiceId}`,
sources: [dep.fromServiceId],
targets: [dep.toServiceId],
};
}),
};
const layout: () => Promise<void> = async (): Promise<void> => {
try {
const res: ElkNode = (await elk.layout(elkGraph)) as ElkNode; // casting to bundled ElkNode shape
const placedNodes: Node[] = (res.children || []).map(
(child: ElkNode): Node => {
const svc: ServiceNodeData | undefined = sortedServices.find(
(s: ServiceNodeData): boolean => {
return s.id === child.id;
},
);
const background: string = svc?.color || "#ffffff";
const textColor: string = getContrastText(background);
return {
id: child.id || "",
data: { label: svc?.name || "" },
position: { x: child.x || 0, y: child.y || 0 },
sourcePosition: Position.Right,
targetPosition: Position.Left,
style: {
borderRadius: 8,
padding: 8,
border: "1px solid rgba(0,0,0,0.08)",
background,
color: textColor,
boxShadow: "0 1px 2px rgba(16,24,40,.05)",
width: NODE_WIDTH,
height: NODE_HEIGHT,
},
} as Node;
},
);
const stroke: string = "#94a3b8"; // slate-400
const placedEdges: Edge[] = sortedDeps.map(
(dep: ServiceEdgeData): Edge => {
return {
id: `e-${dep.fromServiceId}-${dep.toServiceId}`,
source: dep.fromServiceId,
target: dep.toServiceId,
animated: false,
style: { stroke, strokeWidth: 2 },
markerEnd: { type: MarkerType.Arrow, color: stroke },
type: "smoothstep",
};
},
);
setRfNodes(placedNodes);
setRfEdges(placedEdges);
} catch {
// Fallback: deterministic grid by name
const sorted: Array<ServiceNodeData> = sortedServices;
const COLS: number = 4;
const GAP_X: number = 260;
const GAP_Y: number = 120;
const nodes: Node[] = sorted.map(
(svc: ServiceNodeData, i: number): Node => {
const col: number = i % COLS;
const row: number = Math.floor(i / COLS);
const x: number = col * GAP_X;
const y: number = row * GAP_Y;
const background: string = svc.color || "#ffffff";
const textColor: string = getContrastText(background);
return {
id: svc.id,
data: { label: svc.name },
position: { x, y },
sourcePosition: Position.Right,
targetPosition: Position.Left,
style: {
borderRadius: 8,
padding: 8,
border: "1px solid rgba(0,0,0,0.08)",
background,
color: textColor,
boxShadow: "0 1px 2px rgba(16,24,40,.05)",
width: NODE_WIDTH,
height: NODE_HEIGHT,
},
};
},
);
const stroke: string = "#94a3b8";
const edges: Edge[] = sortedDeps.map((dep: ServiceEdgeData): Edge => {
return {
id: `e-${dep.fromServiceId}-${dep.toServiceId}`,
source: dep.fromServiceId,
target: dep.toServiceId,
animated: false,
style: { stroke, strokeWidth: 2 },
markerEnd: { type: MarkerType.Arrow, color: stroke },
type: "smoothstep",
};
});
setRfNodes(nodes);
setRfEdges(edges);
}
};
layout();
}, [props.services, props.dependencies]);
return (
<div style={{ width: "100%", height: 600 }}>
<style>{`
/* Hide/transparentize connection handles (ports) for read-only view */
.service-dependency-graph .react-flow__handle {
background: transparent !important;
border-color: transparent !important;
}
`}</style>
<ReactFlow
className="service-dependency-graph"
nodes={rfNodes}
edges={rfEdges}
fitView
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
edgesUpdatable={false}
connectOnClick={false}
>
<MiniMap
nodeColor={(n: Node): string => {
return (
(n.style as any)?.background ||
(n.data as any)?.color ||
"#ffffff"
);
}}
/>
<Controls />
<Background gap={12} size={1} />
</ReactFlow>
</div>
);
};
export default ServiceDependencyGraph;