mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-01-16 23:00:51 +00:00
286 lines
8.8 KiB
TypeScript
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;
|