mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat: Integrate ELK for layout management in ServiceDependencyGraph and add typings for elkjs
This commit is contained in:
30
Common/Typings/elkjs.d.ts
vendored
Normal file
30
Common/Typings/elkjs.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
declare module "elkjs/lib/elk.bundled.js" {
|
||||
export interface ElkNode {
|
||||
id?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number | undefined;
|
||||
height?: number | undefined;
|
||||
layoutOptions?: Record<string, string>;
|
||||
children?: ElkNode[];
|
||||
edges?: Array<ElkPrimitiveEdge | ElkExtendedEdge>;
|
||||
}
|
||||
|
||||
export interface ElkPrimitiveEdge {
|
||||
id: string;
|
||||
sources: string[];
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
export interface ElkExtendedEdge extends ElkPrimitiveEdge {
|
||||
sections?: Array<{
|
||||
startPoint?: { x: number; y: number };
|
||||
endPoint?: { x: number; y: number };
|
||||
bendPoints?: Array<{ x: number; y: number }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default class ELK {
|
||||
layout(graph: ElkNode): Promise<ElkNode>;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import React, { FunctionComponent, ReactElement, useMemo } from "react";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
@@ -8,6 +13,8 @@ import ReactFlow, {
|
||||
Node,
|
||||
} from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
import ELK from "elkjs/lib/elk.bundled.js";
|
||||
import type { ElkExtendedEdge, ElkNode } from "elkjs/lib/elk-api";
|
||||
|
||||
export interface ServiceNodeData {
|
||||
id: string;
|
||||
@@ -78,43 +85,132 @@ const ServiceDependencyGraph: FunctionComponent<ServiceDependencyGraphProps> = (
|
||||
return luminance > 0.5 ? "#111827" : "#ffffff";
|
||||
};
|
||||
|
||||
const nodes: Node[] = useMemo(() => {
|
||||
return props.services.map((svc: ServiceNodeData) => {
|
||||
const background: string = svc.color || "#ffffff";
|
||||
const textColor: string = getContrastText(background);
|
||||
return {
|
||||
id: svc.id,
|
||||
data: { label: svc.name },
|
||||
position: { x: Math.random() * 600, y: Math.random() * 400 },
|
||||
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)",
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [props.services]);
|
||||
const [rfNodes, setRfNodes] = useState<Node[]>([]);
|
||||
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
|
||||
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
return props.dependencies.map((dep: ServiceEdgeData, idx: number) => {
|
||||
const stroke = "#94a3b8"; // slate-400
|
||||
return {
|
||||
id: `e-${idx}`,
|
||||
source: dep.fromServiceId,
|
||||
target: dep.toServiceId,
|
||||
animated: false,
|
||||
style: { stroke, strokeWidth: 2 },
|
||||
markerEnd: {
|
||||
type: MarkerType.Arrow,
|
||||
color: stroke,
|
||||
},
|
||||
type: "smoothstep",
|
||||
};
|
||||
useEffect(() => {
|
||||
const elk = new ELK();
|
||||
// fixed node dimensions for layout (px)
|
||||
const NODE_WIDTH = 220;
|
||||
const NODE_HEIGHT = 56;
|
||||
|
||||
const sortedServices = [...props.services].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
||||
const sortedDeps = [...props.dependencies].sort((a, b) => {
|
||||
if (a.fromServiceId === b.fromServiceId) {
|
||||
return a.toServiceId.localeCompare(b.toServiceId);
|
||||
}
|
||||
return a.fromServiceId.localeCompare(b.fromServiceId);
|
||||
});
|
||||
}, [props.dependencies]);
|
||||
|
||||
const elkGraph: ElkNode = {
|
||||
id: "root",
|
||||
layoutOptions: {
|
||||
algorithm: "layered",
|
||||
"elk.direction": "RIGHT",
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": "120",
|
||||
"elk.spacing.nodeNode": "60",
|
||||
"elk.edgeRouting": "POLYLINE",
|
||||
},
|
||||
children: sortedServices.map((svc: ServiceNodeData) => {
|
||||
return {
|
||||
id: svc.id,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
} as ElkNode;
|
||||
}),
|
||||
edges: sortedDeps.map((dep: ServiceEdgeData): ElkExtendedEdge => ({
|
||||
id: `e-${dep.fromServiceId}-${dep.toServiceId}`,
|
||||
sources: [dep.fromServiceId],
|
||||
targets: [dep.toServiceId],
|
||||
})),
|
||||
};
|
||||
|
||||
const layout = async (): Promise<void> => {
|
||||
try {
|
||||
const res: any = await elk.layout(elkGraph as any);
|
||||
const placedNodes: Node[] = (res.children || []).map((child: any) => {
|
||||
const svc: ServiceNodeData | undefined = sortedServices.find((s) => 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 },
|
||||
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 = "#94a3b8"; // slate-400
|
||||
const placedEdges: Edge[] = sortedDeps.map(
|
||||
(dep: ServiceEdgeData): Edge => ({
|
||||
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 (e) {
|
||||
// Fallback: deterministic grid by name
|
||||
const sorted = sortedServices;
|
||||
const COLS = 4;
|
||||
const GAP_X = 260;
|
||||
const GAP_Y = 120;
|
||||
const nodes: Node[] = sorted.map((svc: ServiceNodeData, i: number) => {
|
||||
const col = i % COLS;
|
||||
const row = Math.floor(i / COLS);
|
||||
const x = col * GAP_X;
|
||||
const y = 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 },
|
||||
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 = "#94a3b8";
|
||||
const edges: Edge[] = sortedDeps.map((dep: ServiceEdgeData) => ({
|
||||
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();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.services, props.dependencies]);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: 600 }}>
|
||||
@@ -127,8 +223,8 @@ const ServiceDependencyGraph: FunctionComponent<ServiceDependencyGraphProps> = (
|
||||
`}</style>
|
||||
<ReactFlow
|
||||
className="service-dependency-graph"
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
fitView
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
|
||||
7
Common/package-lock.json
generated
7
Common/package-lock.json
generated
@@ -50,6 +50,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^16.4.4",
|
||||
"ejs": "^3.1.10",
|
||||
"elkjs": "^0.10.0",
|
||||
"esbuild": "^0.25.5",
|
||||
"express": "^4.21.1",
|
||||
"formik": "^2.4.6",
|
||||
@@ -6685,6 +6686,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/elkjs": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.10.0.tgz",
|
||||
"integrity": "sha512-v/3r+3Bl2NMrWmVoRTMBtHtWvRISTix/s9EfnsfEWApNrsmNjqgqJOispCGg46BPwIFdkag3N/HYSxJczvCm6w==",
|
||||
"license": "EPL-2.0"
|
||||
},
|
||||
"node_modules/emittery": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^16.4.4",
|
||||
"ejs": "^3.1.10",
|
||||
"elkjs": "^0.10.0",
|
||||
"esbuild": "^0.25.5",
|
||||
"express": "^4.21.1",
|
||||
"formik": "^2.4.6",
|
||||
|
||||
Reference in New Issue
Block a user