@checkstack/dependency-frontend 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/package.json +34 -0
- package/src/components/DependencyAlert.tsx +144 -0
- package/src/components/DependencyBadge.tsx +78 -0
- package/src/components/DependencyEdgeForm.tsx +84 -0
- package/src/components/DependencyEditor.tsx +561 -0
- package/src/components/DependencyMapPage.tsx +631 -0
- package/src/components/DependencyMenuItems.tsx +20 -0
- package/src/components/HealthCheckRulesEditor.tsx +194 -0
- package/src/components/canvas/DependencyEdge.tsx +89 -0
- package/src/components/canvas/SystemNode.tsx +142 -0
- package/src/index.tsx +51 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ReactFlow,
|
|
4
|
+
Background,
|
|
5
|
+
Controls,
|
|
6
|
+
MiniMap,
|
|
7
|
+
useNodesState,
|
|
8
|
+
useEdgesState,
|
|
9
|
+
useReactFlow,
|
|
10
|
+
ReactFlowProvider,
|
|
11
|
+
type NodeChange,
|
|
12
|
+
type Connection,
|
|
13
|
+
MarkerType,
|
|
14
|
+
Panel,
|
|
15
|
+
} from "@xyflow/react";
|
|
16
|
+
import "@xyflow/react/dist/style.css";
|
|
17
|
+
|
|
18
|
+
import { usePluginClient, wrapInSuspense } from "@checkstack/frontend-api";
|
|
19
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
20
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
21
|
+
import { HealthCheckApi, SYSTEM_STATUS_CHANGED } from "@checkstack/healthcheck-common";
|
|
22
|
+
import {
|
|
23
|
+
DependencyApi,
|
|
24
|
+
DEPENDENCY_CHANGED,
|
|
25
|
+
DEPENDENCY_WARNINGS_CHANGED,
|
|
26
|
+
type Dependency,
|
|
27
|
+
type NodePosition,
|
|
28
|
+
} from "@checkstack/dependency-common";
|
|
29
|
+
import {
|
|
30
|
+
Button,
|
|
31
|
+
Badge,
|
|
32
|
+
LoadingSpinner,
|
|
33
|
+
useToast,
|
|
34
|
+
} from "@checkstack/ui";
|
|
35
|
+
import { Maximize2, Save, RefreshCw, Trash2 } from "lucide-react";
|
|
36
|
+
import type { ImpactType } from "@checkstack/dependency-common";
|
|
37
|
+
import { DependencyEdgeForm } from "./DependencyEdgeForm";
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
SystemNodeComponent,
|
|
41
|
+
type SystemNode,
|
|
42
|
+
type SystemNodeData,
|
|
43
|
+
} from "./canvas/SystemNode";
|
|
44
|
+
import {
|
|
45
|
+
DependencyEdgeComponent,
|
|
46
|
+
type DependencyEdge,
|
|
47
|
+
type DependencyEdgeData,
|
|
48
|
+
} from "./canvas/DependencyEdge";
|
|
49
|
+
|
|
50
|
+
const nodeTypes = { system: SystemNodeComponent };
|
|
51
|
+
const edgeTypes = { dependency: DependencyEdgeComponent };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Auto-layout for nodes without saved positions.
|
|
55
|
+
* Places nodes in a grid pattern with reasonable spacing.
|
|
56
|
+
*/
|
|
57
|
+
function autoLayout(
|
|
58
|
+
systemIds: string[],
|
|
59
|
+
savedPositions: NodePosition[],
|
|
60
|
+
): Map<string, { x: number; y: number }> {
|
|
61
|
+
const posMap = new Map<string, { x: number; y: number }>();
|
|
62
|
+
const savedMap = new Map(savedPositions.map((p) => [p.systemId, p]));
|
|
63
|
+
|
|
64
|
+
const unpositioned = systemIds.filter((id) => !savedMap.has(id));
|
|
65
|
+
const cols = Math.ceil(Math.sqrt(unpositioned.length));
|
|
66
|
+
const spacingX = 250;
|
|
67
|
+
const spacingY = 120;
|
|
68
|
+
|
|
69
|
+
// Apply saved positions
|
|
70
|
+
for (const pos of savedPositions) {
|
|
71
|
+
posMap.set(pos.systemId, { x: pos.x, y: pos.y });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Auto-position remaining systems
|
|
75
|
+
for (const [index, id] of unpositioned.entries()) {
|
|
76
|
+
const col = index % cols;
|
|
77
|
+
const row = Math.floor(index / cols);
|
|
78
|
+
posMap.set(id, {
|
|
79
|
+
x: col * spacingX + 100,
|
|
80
|
+
y: row * spacingY + 100,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return posMap;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function DependencyMapContent() {
|
|
88
|
+
const depClient = usePluginClient(DependencyApi);
|
|
89
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
90
|
+
const healthCheckClient = usePluginClient(HealthCheckApi);
|
|
91
|
+
const { fitView } = useReactFlow();
|
|
92
|
+
const [nodes, setNodes, onNodesChange] = useNodesState<SystemNode>([]);
|
|
93
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState<DependencyEdge>([]);
|
|
94
|
+
const [hasUnsaved, setHasUnsaved] = useState(false);
|
|
95
|
+
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
|
96
|
+
|
|
97
|
+
// Edge editor state
|
|
98
|
+
const [selectedEdge, setSelectedEdge] = useState<
|
|
99
|
+
| {
|
|
100
|
+
id: string;
|
|
101
|
+
sourceSystemId: string;
|
|
102
|
+
targetSystemId: string;
|
|
103
|
+
impactType: ImpactType;
|
|
104
|
+
transitive: boolean;
|
|
105
|
+
healthCheckRules: { healthCheckId: string; overrideImpactType: ImpactType }[];
|
|
106
|
+
}
|
|
107
|
+
| undefined
|
|
108
|
+
>();
|
|
109
|
+
|
|
110
|
+
// Fetch systems
|
|
111
|
+
const { data: systemsData, isLoading: systemsLoading } =
|
|
112
|
+
catalogClient.getSystems.useQuery({});
|
|
113
|
+
|
|
114
|
+
// Fetch all dependencies
|
|
115
|
+
const {
|
|
116
|
+
data: depsData,
|
|
117
|
+
isLoading: depsLoading,
|
|
118
|
+
refetch: refetchDeps,
|
|
119
|
+
} = depClient.getAllDependencies.useQuery();
|
|
120
|
+
|
|
121
|
+
// Fetch saved positions
|
|
122
|
+
const { data: posData, refetch: refetchPositions } =
|
|
123
|
+
depClient.getNodePositions.useQuery();
|
|
124
|
+
|
|
125
|
+
// Fetch warnings for all systems
|
|
126
|
+
const systemIds = useMemo(
|
|
127
|
+
() => systemsData?.systems.map((s) => s.id) ?? [],
|
|
128
|
+
[systemsData],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const { data: warningsData, refetch: refetchWarnings } =
|
|
132
|
+
depClient.getWarnings.useQuery(
|
|
133
|
+
{ systemIds },
|
|
134
|
+
{ enabled: systemIds.length > 0 },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Fetch real health statuses for all systems
|
|
138
|
+
const { data: healthData, refetch: refetchHealth } =
|
|
139
|
+
healthCheckClient.getBulkSystemHealthStatus.useQuery(
|
|
140
|
+
{ systemIds },
|
|
141
|
+
{ enabled: systemIds.length > 0 },
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Save positions mutation
|
|
145
|
+
const saveMutation = depClient.saveNodePositions.useMutation({
|
|
146
|
+
onSuccess: () => {
|
|
147
|
+
setHasUnsaved(false);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// System name lookup
|
|
152
|
+
const systemNameMap = useMemo(() => {
|
|
153
|
+
const map = new Map<string, string>();
|
|
154
|
+
for (const s of systemsData?.systems ?? []) {
|
|
155
|
+
map.set(s.id, s.name);
|
|
156
|
+
}
|
|
157
|
+
return map;
|
|
158
|
+
}, [systemsData]);
|
|
159
|
+
|
|
160
|
+
// UUID regex for parsing cycle errors
|
|
161
|
+
const UUID_REGEX =
|
|
162
|
+
/[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}/gi;
|
|
163
|
+
|
|
164
|
+
// Create dependency mutation (for drag-to-connect)
|
|
165
|
+
const toast = useToast();
|
|
166
|
+
const createMutation = depClient.createDependency.useMutation({
|
|
167
|
+
onSuccess: () => {
|
|
168
|
+
toast.success("Dependency created");
|
|
169
|
+
void refetchDeps();
|
|
170
|
+
void refetchWarnings();
|
|
171
|
+
},
|
|
172
|
+
onError: (error) => {
|
|
173
|
+
const message =
|
|
174
|
+
error instanceof Error ? error.message : "Failed to create dependency";
|
|
175
|
+
|
|
176
|
+
// Check for cycle error and resolve names
|
|
177
|
+
if (message.includes("circular chain")) {
|
|
178
|
+
const ids = message.match(UUID_REGEX) ?? [];
|
|
179
|
+
const names = ids.map((id) => systemNameMap.get(id) ?? id.slice(0, 8));
|
|
180
|
+
toast.error(`Circular dependency: ${names.join(" → ")}`);
|
|
181
|
+
} else {
|
|
182
|
+
toast.error(message);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Update dependency mutation (for edge editor)
|
|
188
|
+
const updateMutation = depClient.updateDependency.useMutation({
|
|
189
|
+
onSuccess: () => {
|
|
190
|
+
toast.success("Dependency updated");
|
|
191
|
+
setSelectedEdge(undefined);
|
|
192
|
+
void refetchDeps();
|
|
193
|
+
void refetchWarnings();
|
|
194
|
+
},
|
|
195
|
+
onError: (error) => {
|
|
196
|
+
toast.error(error instanceof Error ? error.message : "Failed to update");
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Delete dependency mutation (for edge editor)
|
|
201
|
+
const deleteMutation = depClient.deleteDependency.useMutation({
|
|
202
|
+
onSuccess: () => {
|
|
203
|
+
toast.success("Dependency deleted");
|
|
204
|
+
setSelectedEdge(undefined);
|
|
205
|
+
void refetchDeps();
|
|
206
|
+
void refetchWarnings();
|
|
207
|
+
},
|
|
208
|
+
onError: (error) => {
|
|
209
|
+
toast.error(error instanceof Error ? error.message : "Failed to delete");
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Handle edge connection (drag from source handle to target handle)
|
|
214
|
+
const { mutate: createDependency } = createMutation;
|
|
215
|
+
const onConnect = useCallback(
|
|
216
|
+
(connection: Connection) => {
|
|
217
|
+
if (!connection.source || !connection.target) return;
|
|
218
|
+
if (connection.source === connection.target) return;
|
|
219
|
+
|
|
220
|
+
createDependency({
|
|
221
|
+
sourceSystemId: connection.source,
|
|
222
|
+
targetSystemId: connection.target,
|
|
223
|
+
impactType: "degraded",
|
|
224
|
+
transitive: false,
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
[createDependency],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Build graph from data
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (!systemsData?.systems || !depsData?.dependencies) return;
|
|
233
|
+
|
|
234
|
+
const savedPositions = posData?.positions ?? [];
|
|
235
|
+
const positions = autoLayout(
|
|
236
|
+
systemsData.systems.map((s) => s.id),
|
|
237
|
+
savedPositions,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const warnings = warningsData?.warnings ?? {};
|
|
241
|
+
const healthStatuses = healthData?.statuses ?? {};
|
|
242
|
+
|
|
243
|
+
const newNodes: SystemNode[] = systemsData.systems.map((system) => {
|
|
244
|
+
const pos = positions.get(system.id) ?? { x: 0, y: 0 };
|
|
245
|
+
const warning = warnings[system.id];
|
|
246
|
+
|
|
247
|
+
// Map real health status to node status
|
|
248
|
+
const healthStatus = healthStatuses[system.id];
|
|
249
|
+
let selfStatus: "operational" | "degraded" | "down" = "operational";
|
|
250
|
+
if (healthStatus) {
|
|
251
|
+
if (healthStatus.status === "unhealthy") {
|
|
252
|
+
selfStatus = "down";
|
|
253
|
+
} else if (healthStatus.status === "degraded") {
|
|
254
|
+
selfStatus = "degraded";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const nodeData: SystemNodeData = {
|
|
259
|
+
label: system.name,
|
|
260
|
+
systemId: system.id,
|
|
261
|
+
status: selfStatus,
|
|
262
|
+
derivedState: warning?.derivedState,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
id: system.id,
|
|
267
|
+
type: "system" as const,
|
|
268
|
+
position: pos,
|
|
269
|
+
data: nodeData,
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const newEdges: DependencyEdge[] = depsData.dependencies.map(
|
|
274
|
+
(dep: Dependency) => {
|
|
275
|
+
const edgeData: DependencyEdgeData = {
|
|
276
|
+
impactType: dep.impactType,
|
|
277
|
+
transitive: dep.transitive,
|
|
278
|
+
label: dep.label,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
id: dep.id,
|
|
283
|
+
source: dep.sourceSystemId,
|
|
284
|
+
target: dep.targetSystemId,
|
|
285
|
+
type: "dependency" as const,
|
|
286
|
+
animated: dep.transitive,
|
|
287
|
+
markerEnd: {
|
|
288
|
+
type: MarkerType.ArrowClosed,
|
|
289
|
+
width: 16,
|
|
290
|
+
height: 16,
|
|
291
|
+
},
|
|
292
|
+
data: edgeData,
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
setNodes(newNodes);
|
|
298
|
+
setEdges(newEdges);
|
|
299
|
+
}, [systemsData, depsData, posData, warningsData, healthData, setNodes, setEdges]);
|
|
300
|
+
|
|
301
|
+
// Track node position changes for saving
|
|
302
|
+
const nodesRef = useRef(nodes);
|
|
303
|
+
nodesRef.current = nodes;
|
|
304
|
+
|
|
305
|
+
const handleNodesChange = useCallback(
|
|
306
|
+
(changes: NodeChange<SystemNode>[]) => {
|
|
307
|
+
onNodesChange(changes);
|
|
308
|
+
|
|
309
|
+
// Check if any position changes happened
|
|
310
|
+
const hasPositionChange = changes.some(
|
|
311
|
+
(c) => c.type === "position" && c.dragging === false,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
if (hasPositionChange) {
|
|
315
|
+
setHasUnsaved(true);
|
|
316
|
+
|
|
317
|
+
// Auto-save after 2 seconds of inactivity
|
|
318
|
+
if (saveTimeoutRef.current) {
|
|
319
|
+
clearTimeout(saveTimeoutRef.current);
|
|
320
|
+
}
|
|
321
|
+
saveTimeoutRef.current = setTimeout(() => {
|
|
322
|
+
handleSave();
|
|
323
|
+
}, 2000);
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
[onNodesChange],
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
// Save positions — reads from nodesRef to avoid stale closure
|
|
330
|
+
const { mutate: savePositions } = saveMutation;
|
|
331
|
+
const handleSave = useCallback(() => {
|
|
332
|
+
const positions: NodePosition[] = nodesRef.current.map((n) => ({
|
|
333
|
+
systemId: n.id,
|
|
334
|
+
x: Math.round(n.position.x),
|
|
335
|
+
y: Math.round(n.position.y),
|
|
336
|
+
}));
|
|
337
|
+
|
|
338
|
+
savePositions({ positions });
|
|
339
|
+
}, [savePositions]);
|
|
340
|
+
|
|
341
|
+
// Listen for realtime dependency changes
|
|
342
|
+
useSignal(DEPENDENCY_CHANGED, () => {
|
|
343
|
+
void refetchDeps();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
useSignal(DEPENDENCY_WARNINGS_CHANGED, () => {
|
|
347
|
+
void refetchWarnings();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
useSignal(SYSTEM_STATUS_CHANGED, () => {
|
|
351
|
+
void refetchHealth();
|
|
352
|
+
void refetchWarnings();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Cleanup timeout on unmount
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
return () => {
|
|
358
|
+
if (saveTimeoutRef.current) {
|
|
359
|
+
clearTimeout(saveTimeoutRef.current);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
}, []);
|
|
363
|
+
|
|
364
|
+
const loading = systemsLoading || depsLoading;
|
|
365
|
+
|
|
366
|
+
if (loading) {
|
|
367
|
+
return (
|
|
368
|
+
<div className="h-[calc(100vh-12rem)] flex items-center justify-center">
|
|
369
|
+
<LoadingSpinner />
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<div className="h-[calc(100vh-12rem)] rounded-xl border border-border overflow-hidden bg-background/50">
|
|
376
|
+
<ReactFlow<SystemNode, DependencyEdge>
|
|
377
|
+
nodes={nodes}
|
|
378
|
+
edges={edges}
|
|
379
|
+
onNodesChange={handleNodesChange}
|
|
380
|
+
onEdgesChange={onEdgesChange}
|
|
381
|
+
onConnect={onConnect}
|
|
382
|
+
onEdgeClick={(_event, edge) => {
|
|
383
|
+
const dep = depsData?.dependencies.find((d) => d.id === edge.id);
|
|
384
|
+
if (dep) {
|
|
385
|
+
setSelectedEdge({
|
|
386
|
+
id: dep.id,
|
|
387
|
+
sourceSystemId: dep.sourceSystemId,
|
|
388
|
+
targetSystemId: dep.targetSystemId,
|
|
389
|
+
impactType: dep.impactType,
|
|
390
|
+
transitive: dep.transitive,
|
|
391
|
+
healthCheckRules:
|
|
392
|
+
dep.healthCheckRules?.map((r) => ({
|
|
393
|
+
healthCheckId: r.healthCheckId,
|
|
394
|
+
overrideImpactType: r.overrideImpactType,
|
|
395
|
+
})) ?? [],
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}}
|
|
399
|
+
onPaneClick={() => setSelectedEdge(undefined)}
|
|
400
|
+
nodeTypes={nodeTypes}
|
|
401
|
+
edgeTypes={edgeTypes}
|
|
402
|
+
fitView
|
|
403
|
+
fitViewOptions={{ padding: 0.3 }}
|
|
404
|
+
minZoom={0.2}
|
|
405
|
+
maxZoom={2}
|
|
406
|
+
proOptions={{ hideAttribution: true }}
|
|
407
|
+
defaultEdgeOptions={{
|
|
408
|
+
type: "dependency",
|
|
409
|
+
}}
|
|
410
|
+
>
|
|
411
|
+
<Background gap={20} size={1} className="!bg-background" />
|
|
412
|
+
<Controls
|
|
413
|
+
showInteractive={false}
|
|
414
|
+
className="!bg-card !border-border !shadow-lg [&>button]:!bg-card [&>button]:!border-border [&>button]:!text-foreground [&>button:hover]:!bg-muted"
|
|
415
|
+
/>
|
|
416
|
+
<MiniMap
|
|
417
|
+
className="!bg-card !border-border !shadow-lg"
|
|
418
|
+
nodeColor={(n) => {
|
|
419
|
+
const data = n.data as SystemNodeData;
|
|
420
|
+
const status = data.derivedState ?? data.status ?? "operational";
|
|
421
|
+
if (status === "down") return "rgb(239 68 68)";
|
|
422
|
+
if (status === "degraded") return "rgb(245 158 11)";
|
|
423
|
+
return "rgb(16 185 129)";
|
|
424
|
+
}}
|
|
425
|
+
maskColor="rgba(0, 0, 0, 0.2)"
|
|
426
|
+
/>
|
|
427
|
+
|
|
428
|
+
{/* Top-right panel with actions */}
|
|
429
|
+
<Panel position="top-right" className="flex gap-2">
|
|
430
|
+
{hasUnsaved && (
|
|
431
|
+
<Badge variant="warning" className="animate-pulse">
|
|
432
|
+
Unsaved
|
|
433
|
+
</Badge>
|
|
434
|
+
)}
|
|
435
|
+
<Button
|
|
436
|
+
variant="outline"
|
|
437
|
+
size="sm"
|
|
438
|
+
onClick={handleSave}
|
|
439
|
+
disabled={saveMutation.isPending || !hasUnsaved}
|
|
440
|
+
className="bg-card/90 backdrop-blur-sm"
|
|
441
|
+
>
|
|
442
|
+
<Save className="h-4 w-4 mr-1" />
|
|
443
|
+
{saveMutation.isPending ? "Saving..." : "Save Layout"}
|
|
444
|
+
</Button>
|
|
445
|
+
<Button
|
|
446
|
+
variant="outline"
|
|
447
|
+
size="sm"
|
|
448
|
+
onClick={() => {
|
|
449
|
+
void refetchDeps();
|
|
450
|
+
void refetchWarnings();
|
|
451
|
+
void refetchPositions();
|
|
452
|
+
}}
|
|
453
|
+
className="bg-card/90 backdrop-blur-sm"
|
|
454
|
+
>
|
|
455
|
+
<RefreshCw className="h-4 w-4 mr-1" />
|
|
456
|
+
Refresh
|
|
457
|
+
</Button>
|
|
458
|
+
<Button
|
|
459
|
+
variant="outline"
|
|
460
|
+
size="sm"
|
|
461
|
+
onClick={() => fitView({ padding: 0.3 })}
|
|
462
|
+
className="bg-card/90 backdrop-blur-sm"
|
|
463
|
+
>
|
|
464
|
+
<Maximize2 className="h-4 w-4 mr-1" />
|
|
465
|
+
Fit
|
|
466
|
+
</Button>
|
|
467
|
+
</Panel>
|
|
468
|
+
|
|
469
|
+
{/* Bottom-left legend */}
|
|
470
|
+
<Panel position="bottom-left">
|
|
471
|
+
<div className="bg-card/90 backdrop-blur-sm border border-border rounded-lg p-3 shadow-lg max-w-64">
|
|
472
|
+
<p className="text-xs font-semibold text-muted-foreground mb-2 uppercase tracking-wider">
|
|
473
|
+
Impact Legend
|
|
474
|
+
</p>
|
|
475
|
+
<div className="space-y-1.5">
|
|
476
|
+
<div className="flex items-center gap-2">
|
|
477
|
+
<div className="w-6 h-0.5 bg-sky-400/60 rounded" />
|
|
478
|
+
<span className="text-xs text-muted-foreground">
|
|
479
|
+
Informational
|
|
480
|
+
</span>
|
|
481
|
+
</div>
|
|
482
|
+
<div className="flex items-center gap-2">
|
|
483
|
+
<div className="w-6 h-0.5 bg-amber-400/70 rounded" />
|
|
484
|
+
<span className="text-xs text-muted-foreground">Degraded</span>
|
|
485
|
+
</div>
|
|
486
|
+
<div className="flex items-center gap-2">
|
|
487
|
+
<div className="w-6 h-0.5 bg-red-400/80 rounded" />
|
|
488
|
+
<span className="text-xs text-muted-foreground">Critical</span>
|
|
489
|
+
</div>
|
|
490
|
+
<div className="flex items-center gap-2">
|
|
491
|
+
<div className="w-6 h-[1px] border-t border-dashed border-muted-foreground/50" />
|
|
492
|
+
<span className="text-xs text-muted-foreground">Multi-hop</span>
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
<details className="mt-2.5 group">
|
|
496
|
+
<summary className="text-xs text-muted-foreground cursor-pointer select-none hover:text-foreground transition-colors">
|
|
497
|
+
How multi-hop works
|
|
498
|
+
</summary>
|
|
499
|
+
<div className="mt-2 space-y-2 text-xs text-muted-foreground leading-relaxed">
|
|
500
|
+
<p>
|
|
501
|
+
A <strong className="text-foreground">multi-hop</strong> edge
|
|
502
|
+
looks through its target's own dependencies. A{" "}
|
|
503
|
+
<strong className="text-foreground">single-hop</strong> edge
|
|
504
|
+
only reacts to its direct target's status.
|
|
505
|
+
</p>
|
|
506
|
+
<div className="space-y-1 font-mono text-[11px]">
|
|
507
|
+
<p className="text-destructive">
|
|
508
|
+
A ⟶<sup>multi</sup> B ⟶ C<sub>down</sub> → A warned
|
|
509
|
+
</p>
|
|
510
|
+
<p className="text-emerald-400">
|
|
511
|
+
A ⟶<sup>single</sup> B ⟶ C<sub>down</sub> → A safe
|
|
512
|
+
</p>
|
|
513
|
+
</div>
|
|
514
|
+
<p>
|
|
515
|
+
B is operational in both cases. Multi-hop sees through B to
|
|
516
|
+
C's failure; single-hop only sees B directly.
|
|
517
|
+
</p>
|
|
518
|
+
</div>
|
|
519
|
+
</details>
|
|
520
|
+
</div>
|
|
521
|
+
</Panel>
|
|
522
|
+
|
|
523
|
+
{/* Edge editor panel */}
|
|
524
|
+
{selectedEdge && (
|
|
525
|
+
<Panel position="top-left">
|
|
526
|
+
<div className="bg-card/95 backdrop-blur-sm border border-border rounded-lg shadow-lg p-4 w-72 space-y-3">
|
|
527
|
+
<div className="space-y-1">
|
|
528
|
+
<p className="text-sm font-semibold text-foreground">
|
|
529
|
+
Edit Dependency
|
|
530
|
+
</p>
|
|
531
|
+
<p className="text-xs text-muted-foreground">
|
|
532
|
+
{systemNameMap.get(selectedEdge.sourceSystemId) ??
|
|
533
|
+
selectedEdge.sourceSystemId}
|
|
534
|
+
{" → "}
|
|
535
|
+
{systemNameMap.get(selectedEdge.targetSystemId) ??
|
|
536
|
+
selectedEdge.targetSystemId}
|
|
537
|
+
</p>
|
|
538
|
+
</div>
|
|
539
|
+
<DependencyEdgeForm
|
|
540
|
+
impactType={selectedEdge.impactType}
|
|
541
|
+
onImpactTypeChange={(impactType) =>
|
|
542
|
+
setSelectedEdge({ ...selectedEdge, impactType })
|
|
543
|
+
}
|
|
544
|
+
transitive={selectedEdge.transitive}
|
|
545
|
+
onTransitiveChange={(transitive) =>
|
|
546
|
+
setSelectedEdge({ ...selectedEdge, transitive })
|
|
547
|
+
}
|
|
548
|
+
targetSystemId={selectedEdge.targetSystemId}
|
|
549
|
+
healthCheckRules={selectedEdge.healthCheckRules}
|
|
550
|
+
onHealthCheckRulesChange={(rules) =>
|
|
551
|
+
setSelectedEdge({ ...selectedEdge, healthCheckRules: rules })
|
|
552
|
+
}
|
|
553
|
+
compact
|
|
554
|
+
/>
|
|
555
|
+
<div className="flex gap-2 justify-between">
|
|
556
|
+
<Button
|
|
557
|
+
type="button"
|
|
558
|
+
variant="destructive"
|
|
559
|
+
size="sm"
|
|
560
|
+
onClick={() =>
|
|
561
|
+
deleteMutation.mutate({
|
|
562
|
+
id: selectedEdge.id,
|
|
563
|
+
systemId: selectedEdge.sourceSystemId,
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
disabled={deleteMutation.isPending}
|
|
567
|
+
>
|
|
568
|
+
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
|
569
|
+
Delete
|
|
570
|
+
</Button>
|
|
571
|
+
<div className="flex gap-2">
|
|
572
|
+
<Button
|
|
573
|
+
type="button"
|
|
574
|
+
variant="ghost"
|
|
575
|
+
size="sm"
|
|
576
|
+
onClick={() => setSelectedEdge(undefined)}
|
|
577
|
+
>
|
|
578
|
+
Cancel
|
|
579
|
+
</Button>
|
|
580
|
+
<Button
|
|
581
|
+
type="button"
|
|
582
|
+
size="sm"
|
|
583
|
+
onClick={() =>
|
|
584
|
+
updateMutation.mutate({
|
|
585
|
+
id: selectedEdge.id,
|
|
586
|
+
systemId: selectedEdge.sourceSystemId,
|
|
587
|
+
impactType: selectedEdge.impactType,
|
|
588
|
+
transitive: selectedEdge.transitive,
|
|
589
|
+
healthCheckRules:
|
|
590
|
+
selectedEdge.healthCheckRules.length > 0
|
|
591
|
+
? selectedEdge.healthCheckRules
|
|
592
|
+
: [],
|
|
593
|
+
})
|
|
594
|
+
}
|
|
595
|
+
disabled={updateMutation.isPending}
|
|
596
|
+
>
|
|
597
|
+
{updateMutation.isPending ? "Saving..." : "Save"}
|
|
598
|
+
</Button>
|
|
599
|
+
</div>
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
</Panel>
|
|
603
|
+
)}
|
|
604
|
+
</ReactFlow>
|
|
605
|
+
</div>
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Dependency Map page — wrapped in ReactFlowProvider and Suspense.
|
|
611
|
+
*/
|
|
612
|
+
const DependencyMapPageContent = () => {
|
|
613
|
+
return (
|
|
614
|
+
<div className="space-y-4">
|
|
615
|
+
<div className="flex items-center justify-between">
|
|
616
|
+
<div>
|
|
617
|
+
<h1 className="text-2xl font-bold text-foreground">Dependency Map</h1>
|
|
618
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
619
|
+
Interactive topology view of system dependencies. Drag nodes to
|
|
620
|
+
rearrange — positions auto-save.
|
|
621
|
+
</p>
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
<ReactFlowProvider>
|
|
625
|
+
<DependencyMapContent />
|
|
626
|
+
</ReactFlowProvider>
|
|
627
|
+
</div>
|
|
628
|
+
);
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
export const DependencyMapPage = wrapInSuspense(DependencyMapPageContent);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { GitBranch } from "lucide-react";
|
|
4
|
+
import type { UserMenuItemsContext } from "@checkstack/frontend-api";
|
|
5
|
+
import { DropdownMenuItem } from "@checkstack/ui";
|
|
6
|
+
import { resolveRoute } from "@checkstack/common";
|
|
7
|
+
import { dependencyRoutes } from "@checkstack/dependency-common";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Adds a "Dependency Map" link to the user menu.
|
|
11
|
+
*/
|
|
12
|
+
export const DependencyMenuItems = (_props: UserMenuItemsContext) => {
|
|
13
|
+
return (
|
|
14
|
+
<Link to={resolveRoute(dependencyRoutes.routes.map)}>
|
|
15
|
+
<DropdownMenuItem icon={<GitBranch className="h-4 w-4" />}>
|
|
16
|
+
Dependency Map
|
|
17
|
+
</DropdownMenuItem>
|
|
18
|
+
</Link>
|
|
19
|
+
);
|
|
20
|
+
};
|