@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.
@@ -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
+ }