@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,561 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
usePluginClient,
|
|
5
|
+
type SlotContext,
|
|
6
|
+
} from "@checkstack/frontend-api";
|
|
7
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
8
|
+
import { SystemEditorSlot } from "@checkstack/catalog-common";
|
|
9
|
+
import {
|
|
10
|
+
DependencyApi,
|
|
11
|
+
DEPENDENCY_CHANGED,
|
|
12
|
+
dependencyRoutes,
|
|
13
|
+
type Dependency,
|
|
14
|
+
type ImpactType,
|
|
15
|
+
} from "@checkstack/dependency-common";
|
|
16
|
+
import { DependencyEdgeForm } from "./DependencyEdgeForm";
|
|
17
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
18
|
+
import { resolveRoute } from "@checkstack/common";
|
|
19
|
+
import {
|
|
20
|
+
Badge,
|
|
21
|
+
Button,
|
|
22
|
+
Label,
|
|
23
|
+
LoadingSpinner,
|
|
24
|
+
} from "@checkstack/ui";
|
|
25
|
+
import {
|
|
26
|
+
ArrowUpRight,
|
|
27
|
+
ArrowDownRight,
|
|
28
|
+
Plus,
|
|
29
|
+
Trash2,
|
|
30
|
+
Settings2,
|
|
31
|
+
Check,
|
|
32
|
+
X,
|
|
33
|
+
AlertTriangle,
|
|
34
|
+
RotateCcw,
|
|
35
|
+
MapIcon,
|
|
36
|
+
} from "lucide-react";
|
|
37
|
+
|
|
38
|
+
type Props = SlotContext<typeof SystemEditorSlot>;
|
|
39
|
+
|
|
40
|
+
function getImpactBadge(impactType: ImpactType): React.ReactNode {
|
|
41
|
+
switch (impactType) {
|
|
42
|
+
case "critical": {
|
|
43
|
+
return <Badge variant="destructive">Critical</Badge>;
|
|
44
|
+
}
|
|
45
|
+
case "degraded": {
|
|
46
|
+
return <Badge variant="warning">Degraded</Badge>;
|
|
47
|
+
}
|
|
48
|
+
case "informational": {
|
|
49
|
+
return <Badge variant="secondary">Info</Badge>;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Dependency editor section injected into the SystemEditorSlot.
|
|
56
|
+
* Renders inside the system editor dialog for managing upstream/downstream
|
|
57
|
+
* dependencies. Access is already enforced by the editor dialog itself.
|
|
58
|
+
*/
|
|
59
|
+
export const DependencyEditor: React.FC<Props> = ({ systemId }) => {
|
|
60
|
+
const depClient = usePluginClient(DependencyApi);
|
|
61
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
62
|
+
|
|
63
|
+
const [isAdding, setIsAdding] = useState(false);
|
|
64
|
+
const [selectedTargetId, setSelectedTargetId] = useState("");
|
|
65
|
+
const [selectedImpactType, setSelectedImpactType] =
|
|
66
|
+
useState<ImpactType>("degraded");
|
|
67
|
+
const [selectedTransitive, setSelectedTransitive] = useState(false);
|
|
68
|
+
const [selectedHealthCheckRules, setSelectedHealthCheckRules] = useState<
|
|
69
|
+
{ healthCheckId: string; overrideImpactType: ImpactType }[]
|
|
70
|
+
>([]);
|
|
71
|
+
|
|
72
|
+
// Fetch dependencies for this system
|
|
73
|
+
const {
|
|
74
|
+
data: depsData,
|
|
75
|
+
isLoading: depsLoading,
|
|
76
|
+
refetch: refetchDeps,
|
|
77
|
+
} = depClient.getDependencies.useQuery(
|
|
78
|
+
{ systemId, direction: "both" },
|
|
79
|
+
{ enabled: !!systemId },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Fetch all systems — needed for name resolution in rows and the dropdown
|
|
83
|
+
const { data: systemsData } = catalogClient.getSystems.useQuery({});
|
|
84
|
+
|
|
85
|
+
// Build name lookup map
|
|
86
|
+
const systemNameMap = useMemo(() => {
|
|
87
|
+
const map = new Map<string, string>();
|
|
88
|
+
for (const s of systemsData?.systems ?? []) {
|
|
89
|
+
map.set(s.id, s.name);
|
|
90
|
+
}
|
|
91
|
+
return map;
|
|
92
|
+
}, [systemsData]);
|
|
93
|
+
|
|
94
|
+
// Listen for realtime changes
|
|
95
|
+
useSignal(DEPENDENCY_CHANGED, () => {
|
|
96
|
+
void refetchDeps();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const createMutation = depClient.createDependency.useMutation({
|
|
100
|
+
onSuccess: () => {
|
|
101
|
+
setIsAdding(false);
|
|
102
|
+
setSelectedTargetId("");
|
|
103
|
+
void refetchDeps();
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const deleteMutation = depClient.deleteDependency.useMutation({
|
|
108
|
+
onSuccess: () => {
|
|
109
|
+
void refetchDeps();
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const updateMutation = depClient.updateDependency.useMutation({
|
|
114
|
+
onSuccess: () => {
|
|
115
|
+
void refetchDeps();
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const handleCreate = () => {
|
|
120
|
+
if (!systemId || !selectedTargetId) return;
|
|
121
|
+
createMutation.mutate({
|
|
122
|
+
sourceSystemId: systemId,
|
|
123
|
+
targetSystemId: selectedTargetId,
|
|
124
|
+
impactType: selectedImpactType,
|
|
125
|
+
transitive: selectedTransitive,
|
|
126
|
+
healthCheckRules:
|
|
127
|
+
selectedHealthCheckRules.length > 0
|
|
128
|
+
? selectedHealthCheckRules
|
|
129
|
+
: undefined,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const handleDelete = (dep: Dependency) => {
|
|
134
|
+
deleteMutation.mutate({ id: dep.id, systemId });
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleUpdate = ({
|
|
138
|
+
dep,
|
|
139
|
+
impactType,
|
|
140
|
+
transitive,
|
|
141
|
+
healthCheckRules,
|
|
142
|
+
}: {
|
|
143
|
+
dep: Dependency;
|
|
144
|
+
impactType: ImpactType;
|
|
145
|
+
transitive: boolean;
|
|
146
|
+
healthCheckRules?: { healthCheckId: string; overrideImpactType: ImpactType }[];
|
|
147
|
+
}) => {
|
|
148
|
+
updateMutation.mutate({
|
|
149
|
+
id: dep.id,
|
|
150
|
+
systemId,
|
|
151
|
+
impactType,
|
|
152
|
+
transitive,
|
|
153
|
+
healthCheckRules,
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
if (!systemId) return;
|
|
158
|
+
|
|
159
|
+
const dependencies = depsData?.dependencies ?? [];
|
|
160
|
+
const upstreamDeps = dependencies.filter(
|
|
161
|
+
(d) => d.sourceSystemId === systemId,
|
|
162
|
+
);
|
|
163
|
+
const downstreamDeps = dependencies.filter(
|
|
164
|
+
(d) => d.targetSystemId === systemId,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Filter out systems already linked and self
|
|
168
|
+
const availableSystems =
|
|
169
|
+
systemsData?.systems.filter(
|
|
170
|
+
(s) =>
|
|
171
|
+
s.id !== systemId &&
|
|
172
|
+
!upstreamDeps.some((d) => d.targetSystemId === s.id),
|
|
173
|
+
) ?? [];
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div className="space-y-3">
|
|
177
|
+
<div className="flex items-center justify-between">
|
|
178
|
+
<Label>Dependencies</Label>
|
|
179
|
+
<Button
|
|
180
|
+
type="button"
|
|
181
|
+
variant="outline"
|
|
182
|
+
size="sm"
|
|
183
|
+
onClick={() => setIsAdding(!isAdding)}
|
|
184
|
+
>
|
|
185
|
+
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
186
|
+
Add
|
|
187
|
+
</Button>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{depsLoading && (
|
|
191
|
+
<div className="flex justify-center p-3">
|
|
192
|
+
<LoadingSpinner />
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{/* Add dependency form */}
|
|
197
|
+
{isAdding && (
|
|
198
|
+
<div className="p-3 rounded-lg border border-border bg-muted/30 space-y-3">
|
|
199
|
+
<div className="space-y-2">
|
|
200
|
+
<label className="text-sm font-medium">Depends on (upstream)</label>
|
|
201
|
+
<select
|
|
202
|
+
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
203
|
+
value={selectedTargetId}
|
|
204
|
+
onChange={(e) => setSelectedTargetId(e.target.value)}
|
|
205
|
+
>
|
|
206
|
+
<option value="">Select a system...</option>
|
|
207
|
+
{availableSystems.map((s) => (
|
|
208
|
+
<option key={s.id} value={s.id}>
|
|
209
|
+
{s.name}
|
|
210
|
+
</option>
|
|
211
|
+
))}
|
|
212
|
+
</select>
|
|
213
|
+
</div>
|
|
214
|
+
<DependencyEdgeForm
|
|
215
|
+
impactType={selectedImpactType}
|
|
216
|
+
onImpactTypeChange={setSelectedImpactType}
|
|
217
|
+
transitive={selectedTransitive}
|
|
218
|
+
onTransitiveChange={setSelectedTransitive}
|
|
219
|
+
targetSystemId={selectedTargetId}
|
|
220
|
+
healthCheckRules={selectedHealthCheckRules}
|
|
221
|
+
onHealthCheckRulesChange={setSelectedHealthCheckRules}
|
|
222
|
+
/>
|
|
223
|
+
{createMutation.error && (
|
|
224
|
+
<CycleErrorDisplay
|
|
225
|
+
error={createMutation.error}
|
|
226
|
+
systemNameMap={systemNameMap}
|
|
227
|
+
/>
|
|
228
|
+
)}
|
|
229
|
+
<div className="flex gap-2 justify-end">
|
|
230
|
+
<Button
|
|
231
|
+
type="button"
|
|
232
|
+
variant="ghost"
|
|
233
|
+
size="sm"
|
|
234
|
+
onClick={() => setIsAdding(false)}
|
|
235
|
+
>
|
|
236
|
+
Cancel
|
|
237
|
+
</Button>
|
|
238
|
+
<Button
|
|
239
|
+
type="button"
|
|
240
|
+
size="sm"
|
|
241
|
+
onClick={handleCreate}
|
|
242
|
+
disabled={!selectedTargetId || createMutation.isPending}
|
|
243
|
+
>
|
|
244
|
+
{createMutation.isPending ? "Creating..." : "Create"}
|
|
245
|
+
</Button>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{/* Upstream dependencies */}
|
|
251
|
+
{upstreamDeps.length > 0 && (
|
|
252
|
+
<div>
|
|
253
|
+
<h4 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
|
254
|
+
<ArrowUpRight className="h-4 w-4" />
|
|
255
|
+
Depends On ({upstreamDeps.length})
|
|
256
|
+
</h4>
|
|
257
|
+
<div className="space-y-1">
|
|
258
|
+
{upstreamDeps.map((dep) => (
|
|
259
|
+
<DependencyRow
|
|
260
|
+
key={dep.id}
|
|
261
|
+
dependency={dep}
|
|
262
|
+
systemName={systemNameMap.get(dep.targetSystemId) ?? dep.targetSystemId}
|
|
263
|
+
direction="upstream"
|
|
264
|
+
onDelete={() => handleDelete(dep)}
|
|
265
|
+
onUpdate={handleUpdate}
|
|
266
|
+
isUpdating={updateMutation.isPending}
|
|
267
|
+
/>
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
{/* Downstream dependencies */}
|
|
274
|
+
{downstreamDeps.length > 0 && (
|
|
275
|
+
<div>
|
|
276
|
+
<h4 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
|
277
|
+
<ArrowDownRight className="h-4 w-4" />
|
|
278
|
+
Depended By ({downstreamDeps.length})
|
|
279
|
+
</h4>
|
|
280
|
+
<div className="space-y-1">
|
|
281
|
+
{downstreamDeps.map((dep) => (
|
|
282
|
+
<DependencyRow
|
|
283
|
+
key={dep.id}
|
|
284
|
+
dependency={dep}
|
|
285
|
+
systemName={systemNameMap.get(dep.sourceSystemId) ?? dep.sourceSystemId}
|
|
286
|
+
direction="downstream"
|
|
287
|
+
onDelete={() => handleDelete(dep)}
|
|
288
|
+
onUpdate={handleUpdate}
|
|
289
|
+
isUpdating={updateMutation.isPending}
|
|
290
|
+
/>
|
|
291
|
+
))}
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
{/* Empty state */}
|
|
297
|
+
{!depsLoading && dependencies.length === 0 && !isAdding && (
|
|
298
|
+
<p className="text-sm text-muted-foreground text-center py-2">
|
|
299
|
+
No dependencies configured.
|
|
300
|
+
</p>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{/* Dependency Map link */}
|
|
304
|
+
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/20 p-2.5">
|
|
305
|
+
<MapIcon className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
|
306
|
+
<p className="text-xs text-muted-foreground">
|
|
307
|
+
Managing dependencies is easier on a larger screen using the{" "}
|
|
308
|
+
<Link
|
|
309
|
+
to={resolveRoute(dependencyRoutes.routes.map)}
|
|
310
|
+
className="text-primary underline underline-offset-2 hover:text-primary/80"
|
|
311
|
+
>
|
|
312
|
+
Dependency Map
|
|
313
|
+
</Link>
|
|
314
|
+
.
|
|
315
|
+
</p>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// =============================================================================
|
|
322
|
+
// Sub-component
|
|
323
|
+
// =============================================================================
|
|
324
|
+
|
|
325
|
+
function DependencyRow({
|
|
326
|
+
dependency,
|
|
327
|
+
systemName,
|
|
328
|
+
direction,
|
|
329
|
+
onDelete,
|
|
330
|
+
onUpdate,
|
|
331
|
+
isUpdating,
|
|
332
|
+
}: {
|
|
333
|
+
dependency: Dependency;
|
|
334
|
+
systemName: string;
|
|
335
|
+
direction: "upstream" | "downstream";
|
|
336
|
+
onDelete: () => void;
|
|
337
|
+
onUpdate: (args: {
|
|
338
|
+
dep: Dependency;
|
|
339
|
+
impactType: ImpactType;
|
|
340
|
+
transitive: boolean;
|
|
341
|
+
healthCheckRules?: { healthCheckId: string; overrideImpactType: ImpactType }[];
|
|
342
|
+
}) => void;
|
|
343
|
+
isUpdating: boolean;
|
|
344
|
+
}) {
|
|
345
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
346
|
+
const [editImpact, setEditImpact] = useState<ImpactType>(
|
|
347
|
+
dependency.impactType,
|
|
348
|
+
);
|
|
349
|
+
const [editTransitive, setEditTransitive] = useState(dependency.transitive);
|
|
350
|
+
const [editHealthCheckRules, setEditHealthCheckRules] = useState<
|
|
351
|
+
{ healthCheckId: string; overrideImpactType: ImpactType }[]
|
|
352
|
+
>(
|
|
353
|
+
dependency.healthCheckRules?.map((r) => ({
|
|
354
|
+
healthCheckId: r.healthCheckId,
|
|
355
|
+
overrideImpactType: r.overrideImpactType,
|
|
356
|
+
})) ?? [],
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const handleSave = () => {
|
|
360
|
+
onUpdate({
|
|
361
|
+
dep: dependency,
|
|
362
|
+
impactType: editImpact,
|
|
363
|
+
transitive: editTransitive,
|
|
364
|
+
healthCheckRules:
|
|
365
|
+
editHealthCheckRules.length > 0 ? editHealthCheckRules : [],
|
|
366
|
+
});
|
|
367
|
+
setIsEditing(false);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const handleCancel = () => {
|
|
371
|
+
setEditImpact(dependency.impactType);
|
|
372
|
+
setEditTransitive(dependency.transitive);
|
|
373
|
+
setIsEditing(false);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
if (isEditing) {
|
|
377
|
+
return (
|
|
378
|
+
<div className="p-3 rounded-lg border border-primary/30 bg-muted/30 space-y-3">
|
|
379
|
+
<div className="flex items-center gap-2">
|
|
380
|
+
{direction === "upstream" ? (
|
|
381
|
+
<ArrowUpRight className="h-4 w-4 text-muted-foreground" />
|
|
382
|
+
) : (
|
|
383
|
+
<ArrowDownRight className="h-4 w-4 text-muted-foreground" />
|
|
384
|
+
)}
|
|
385
|
+
<span className="text-sm font-medium">{systemName}</span>
|
|
386
|
+
{dependency.label && (
|
|
387
|
+
<span className="text-xs text-muted-foreground">
|
|
388
|
+
({dependency.label})
|
|
389
|
+
</span>
|
|
390
|
+
)}
|
|
391
|
+
</div>
|
|
392
|
+
<DependencyEdgeForm
|
|
393
|
+
impactType={editImpact}
|
|
394
|
+
onImpactTypeChange={setEditImpact}
|
|
395
|
+
transitive={editTransitive}
|
|
396
|
+
onTransitiveChange={setEditTransitive}
|
|
397
|
+
targetSystemId={dependency.targetSystemId}
|
|
398
|
+
healthCheckRules={editHealthCheckRules}
|
|
399
|
+
onHealthCheckRulesChange={setEditHealthCheckRules}
|
|
400
|
+
/>
|
|
401
|
+
<div className="flex gap-2 justify-end">
|
|
402
|
+
<Button
|
|
403
|
+
type="button"
|
|
404
|
+
variant="ghost"
|
|
405
|
+
size="sm"
|
|
406
|
+
onClick={handleCancel}
|
|
407
|
+
>
|
|
408
|
+
<X className="h-3.5 w-3.5 mr-1" />
|
|
409
|
+
Cancel
|
|
410
|
+
</Button>
|
|
411
|
+
<Button
|
|
412
|
+
type="button"
|
|
413
|
+
size="sm"
|
|
414
|
+
onClick={handleSave}
|
|
415
|
+
disabled={isUpdating}
|
|
416
|
+
>
|
|
417
|
+
<Check className="h-3.5 w-3.5 mr-1" />
|
|
418
|
+
{isUpdating ? "Saving..." : "Save"}
|
|
419
|
+
</Button>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return (
|
|
426
|
+
<div
|
|
427
|
+
className="flex items-center justify-between p-2 rounded border border-border bg-background hover:bg-muted/30 transition-colors cursor-pointer"
|
|
428
|
+
onClick={() => setIsEditing(true)}
|
|
429
|
+
role="button"
|
|
430
|
+
tabIndex={0}
|
|
431
|
+
onKeyDown={(e) => {
|
|
432
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
433
|
+
e.preventDefault();
|
|
434
|
+
setIsEditing(true);
|
|
435
|
+
}
|
|
436
|
+
}}
|
|
437
|
+
>
|
|
438
|
+
<div className="flex items-center gap-2">
|
|
439
|
+
{direction === "upstream" ? (
|
|
440
|
+
<ArrowUpRight className="h-4 w-4 text-muted-foreground" />
|
|
441
|
+
) : (
|
|
442
|
+
<ArrowDownRight className="h-4 w-4 text-muted-foreground" />
|
|
443
|
+
)}
|
|
444
|
+
<span className="text-sm font-medium">{systemName}</span>
|
|
445
|
+
{dependency.label && (
|
|
446
|
+
<span className="text-xs text-muted-foreground">
|
|
447
|
+
({dependency.label})
|
|
448
|
+
</span>
|
|
449
|
+
)}
|
|
450
|
+
</div>
|
|
451
|
+
<div className="flex items-center gap-2">
|
|
452
|
+
{dependency.transitive && (
|
|
453
|
+
<Badge variant="outline" className="text-xs">
|
|
454
|
+
<Settings2 className="h-3 w-3 mr-1" />
|
|
455
|
+
Multi-hop
|
|
456
|
+
</Badge>
|
|
457
|
+
)}
|
|
458
|
+
{getImpactBadge(dependency.impactType)}
|
|
459
|
+
{dependency.healthCheckRules &&
|
|
460
|
+
dependency.healthCheckRules.length > 0 && (
|
|
461
|
+
<Badge variant="outline" className="text-xs">
|
|
462
|
+
{dependency.healthCheckRules.length} rules
|
|
463
|
+
</Badge>
|
|
464
|
+
)}
|
|
465
|
+
<Button
|
|
466
|
+
type="button"
|
|
467
|
+
variant="ghost"
|
|
468
|
+
size="icon"
|
|
469
|
+
className="h-7 w-7"
|
|
470
|
+
onClick={(e) => {
|
|
471
|
+
e.stopPropagation();
|
|
472
|
+
onDelete();
|
|
473
|
+
}}
|
|
474
|
+
>
|
|
475
|
+
<Trash2 className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
|
|
476
|
+
</Button>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// =============================================================================
|
|
483
|
+
// Cycle Error Display
|
|
484
|
+
// =============================================================================
|
|
485
|
+
|
|
486
|
+
const CYCLE_CHAIN_REGEX = /circular chain: (.+)$/;
|
|
487
|
+
const UUID_REGEX = /[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}/gi;
|
|
488
|
+
|
|
489
|
+
function CycleErrorDisplay({
|
|
490
|
+
error,
|
|
491
|
+
systemNameMap,
|
|
492
|
+
}: {
|
|
493
|
+
error: Error;
|
|
494
|
+
systemNameMap: Map<string, string>;
|
|
495
|
+
}) {
|
|
496
|
+
const message = error.message;
|
|
497
|
+
|
|
498
|
+
// Try to parse cycle chain from error message
|
|
499
|
+
const chainMatch = CYCLE_CHAIN_REGEX.exec(message);
|
|
500
|
+
if (!chainMatch) {
|
|
501
|
+
// Not a cycle error — render as plain text
|
|
502
|
+
return (
|
|
503
|
+
<p className="text-sm text-destructive">{message}</p>
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Extract system IDs from the chain
|
|
508
|
+
const chainIds = chainMatch[1].match(UUID_REGEX) ?? [];
|
|
509
|
+
if (chainIds.length === 0) {
|
|
510
|
+
return (
|
|
511
|
+
<p className="text-sm text-destructive">{message}</p>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const chainNames = chainIds.map(
|
|
516
|
+
(id) => systemNameMap.get(id) ?? id.slice(0, 8),
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
return (
|
|
520
|
+
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 space-y-2.5">
|
|
521
|
+
<div className="flex items-center gap-2">
|
|
522
|
+
<AlertTriangle className="h-4 w-4 text-destructive shrink-0" />
|
|
523
|
+
<p className="text-sm font-medium text-destructive">
|
|
524
|
+
Circular dependency detected
|
|
525
|
+
</p>
|
|
526
|
+
</div>
|
|
527
|
+
<p className="text-xs text-muted-foreground">
|
|
528
|
+
This dependency would create a cycle. Systems cannot transitively
|
|
529
|
+
depend on themselves.
|
|
530
|
+
</p>
|
|
531
|
+
<div className="flex items-center gap-1.5 flex-wrap py-1">
|
|
532
|
+
{chainNames.map((name, i) => {
|
|
533
|
+
const isFirst = i === 0;
|
|
534
|
+
const isLast = i === chainNames.length - 1;
|
|
535
|
+
// First and last are the same node (the cycle)
|
|
536
|
+
const isCycleNode = isFirst || isLast;
|
|
537
|
+
|
|
538
|
+
return (
|
|
539
|
+
<React.Fragment key={`${name}-${String(i)}`}>
|
|
540
|
+
<span
|
|
541
|
+
className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border ${
|
|
542
|
+
isCycleNode
|
|
543
|
+
? "border-destructive/40 bg-destructive/10 text-destructive"
|
|
544
|
+
: "border-border bg-muted text-foreground"
|
|
545
|
+
}`}
|
|
546
|
+
>
|
|
547
|
+
{isCycleNode && (
|
|
548
|
+
<RotateCcw className="h-3 w-3 mr-1 shrink-0" />
|
|
549
|
+
)}
|
|
550
|
+
{name}
|
|
551
|
+
</span>
|
|
552
|
+
{!isLast && (
|
|
553
|
+
<span className="text-muted-foreground text-xs">→</span>
|
|
554
|
+
)}
|
|
555
|
+
</React.Fragment>
|
|
556
|
+
);
|
|
557
|
+
})}
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
);
|
|
561
|
+
}
|