@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,194 @@
1
+ import React, { useState } from "react";
2
+ import { usePluginClient } from "@checkstack/frontend-api";
3
+ import { HealthCheckApi } from "@checkstack/healthcheck-common";
4
+ import type { ImpactType } from "@checkstack/dependency-common";
5
+ import { Button, Badge } from "@checkstack/ui";
6
+ import { Plus, Trash2, ChevronDown, ChevronUp, Activity } from "lucide-react";
7
+
8
+ interface HealthCheckRule {
9
+ healthCheckId: string;
10
+ overrideImpactType: ImpactType;
11
+ }
12
+
13
+ interface Props {
14
+ /** The upstream system ID to query health checks for */
15
+ targetSystemId: string;
16
+ /** Current rules */
17
+ rules: HealthCheckRule[];
18
+ /** Callback when rules change */
19
+ onChange: (rules: HealthCheckRule[]) => void;
20
+ /** Compact mode for tight layouts like the map edge editor */
21
+ compact?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Collapsible editor for health check rules on a dependency edge.
26
+ * When rules are configured, the dependency only triggers for failures
27
+ * of the specified health checks (with optional per-check impact override).
28
+ */
29
+ export const HealthCheckRulesEditor: React.FC<Props> = ({
30
+ targetSystemId,
31
+ rules,
32
+ onChange,
33
+ compact = false,
34
+ }) => {
35
+ const [expanded, setExpanded] = useState(rules.length > 0);
36
+
37
+ // Fetch health checks for the target (upstream) system
38
+ const healthCheckClient = usePluginClient(HealthCheckApi);
39
+ const { data: associations } =
40
+ healthCheckClient.getSystemAssociations.useQuery(
41
+ { systemId: targetSystemId },
42
+ { enabled: !!targetSystemId },
43
+ );
44
+
45
+ const availableChecks = associations ?? [];
46
+
47
+ // Filter out already-added checks
48
+ const unusedChecks = availableChecks.filter(
49
+ (check) => !rules.some((r) => r.healthCheckId === check.configurationId),
50
+ );
51
+
52
+ const handleAddRule = (configurationId: string) => {
53
+ onChange([
54
+ ...rules,
55
+ { healthCheckId: configurationId, overrideImpactType: "critical" },
56
+ ]);
57
+ };
58
+
59
+ const handleRemoveRule = (healthCheckId: string) => {
60
+ onChange(rules.filter((r) => r.healthCheckId !== healthCheckId));
61
+ };
62
+
63
+ const handleImpactChange = (
64
+ healthCheckId: string,
65
+ impactType: ImpactType,
66
+ ) => {
67
+ onChange(
68
+ rules.map((r) =>
69
+ r.healthCheckId === healthCheckId
70
+ ? { ...r, overrideImpactType: impactType }
71
+ : r,
72
+ ),
73
+ );
74
+ };
75
+
76
+ const getCheckName = (healthCheckId: string): string =>
77
+ availableChecks.find((c) => c.configurationId === healthCheckId)
78
+ ?.configurationName ?? healthCheckId;
79
+
80
+ if (availableChecks.length === 0) {
81
+ return;
82
+ }
83
+
84
+ return (
85
+ <div
86
+ className={`rounded-lg border border-border ${compact ? "p-2" : "p-3"} bg-muted/20`}
87
+ >
88
+ <button
89
+ type="button"
90
+ className="flex items-center justify-between w-full text-left"
91
+ onClick={() => setExpanded(!expanded)}
92
+ >
93
+ <div className="flex items-center gap-2">
94
+ <Activity className="h-3.5 w-3.5 text-muted-foreground" />
95
+ <span className={`font-medium ${compact ? "text-xs" : "text-sm"}`}>
96
+ Health check rules
97
+ </span>
98
+ {rules.length > 0 && (
99
+ <Badge variant="outline" className="text-xs h-5">
100
+ {rules.length}
101
+ </Badge>
102
+ )}
103
+ </div>
104
+ {expanded ? (
105
+ <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
106
+ ) : (
107
+ <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
108
+ )}
109
+ </button>
110
+
111
+ {expanded && (
112
+ <div className="mt-2 space-y-2">
113
+ <p className="text-xs text-muted-foreground">
114
+ When rules are configured, this dependency only triggers warnings
115
+ when the specified health checks fail. Each rule can override the
116
+ impact type.
117
+ </p>
118
+
119
+ {/* Existing rules */}
120
+ {rules.map((rule) => (
121
+ <div
122
+ key={rule.healthCheckId}
123
+ className="flex items-center gap-2 rounded border border-border bg-background p-2"
124
+ >
125
+ <span className="text-xs flex-1 truncate font-medium">
126
+ {getCheckName(rule.healthCheckId)}
127
+ </span>
128
+ <select
129
+ className="text-xs rounded border border-input bg-background px-2 py-1"
130
+ value={rule.overrideImpactType}
131
+ onChange={(e) =>
132
+ handleImpactChange(
133
+ rule.healthCheckId,
134
+ e.target.value as ImpactType,
135
+ )
136
+ }
137
+ >
138
+ <option value="informational">Info</option>
139
+ <option value="degraded">Degraded</option>
140
+ <option value="critical">Critical</option>
141
+ </select>
142
+ <Button
143
+ type="button"
144
+ variant="ghost"
145
+ size="icon"
146
+ className="h-6 w-6"
147
+ onClick={() => handleRemoveRule(rule.healthCheckId)}
148
+ >
149
+ <Trash2 className="h-3 w-3 text-destructive" />
150
+ </Button>
151
+ </div>
152
+ ))}
153
+
154
+ {/* Add new rule */}
155
+ {unusedChecks.length > 0 && (
156
+ <div className="flex items-center gap-2">
157
+ <select
158
+ id="add-hc-rule"
159
+ className="flex-1 text-xs rounded border border-input bg-background px-2 py-1.5"
160
+ defaultValue=""
161
+ onChange={(e) => {
162
+ if (e.target.value) {
163
+ handleAddRule(e.target.value);
164
+ e.target.value = "";
165
+ }
166
+ }}
167
+ >
168
+ <option value="" disabled>
169
+ Add health check...
170
+ </option>
171
+ {unusedChecks.map((check) => (
172
+ <option
173
+ key={check.configurationId}
174
+ value={check.configurationId}
175
+ >
176
+ {check.configurationName}
177
+ </option>
178
+ ))}
179
+ </select>
180
+ <Plus className="h-3.5 w-3.5 text-muted-foreground" />
181
+ </div>
182
+ )}
183
+
184
+ {rules.length === 0 && unusedChecks.length > 0 && (
185
+ <p className="text-xs text-muted-foreground/60 italic">
186
+ No rules configured — all health check failures trigger this
187
+ dependency.
188
+ </p>
189
+ )}
190
+ </div>
191
+ )}
192
+ </div>
193
+ );
194
+ };
@@ -0,0 +1,89 @@
1
+
2
+ import {
3
+ BaseEdge,
4
+ getBezierPath,
5
+ type EdgeProps,
6
+ type Edge,
7
+ } from "@xyflow/react";
8
+ import type { ImpactType } from "@checkstack/dependency-common";
9
+
10
+ export interface DependencyEdgeData extends Record<string, unknown> {
11
+ impactType: ImpactType;
12
+ transitive: boolean;
13
+ label?: string | null;
14
+ }
15
+
16
+ export type DependencyEdge = Edge<DependencyEdgeData, "dependency">;
17
+
18
+ const impactColors: Record<ImpactType, string> = {
19
+ informational: "stroke-sky-400/50",
20
+ degraded: "stroke-amber-400/60",
21
+ critical: "stroke-red-400/70",
22
+ };
23
+
24
+ const impactStrokeWidths: Record<ImpactType, number> = {
25
+ informational: 1.5,
26
+ degraded: 2,
27
+ critical: 2.5,
28
+ };
29
+
30
+ /**
31
+ * Custom React Flow edge displaying dependency impact type via color + thickness.
32
+ * Transitive edges use dashed stroke.
33
+ */
34
+ export function DependencyEdgeComponent({
35
+ id,
36
+ sourceX,
37
+ sourceY,
38
+ targetX,
39
+ targetY,
40
+ sourcePosition,
41
+ targetPosition,
42
+ data,
43
+ selected,
44
+ }: EdgeProps<DependencyEdge>) {
45
+ const [edgePath, labelX, labelY] = getBezierPath({
46
+ sourceX,
47
+ sourceY,
48
+ sourcePosition,
49
+ targetX,
50
+ targetY,
51
+ targetPosition,
52
+ });
53
+
54
+ const impactType = data?.impactType ?? "informational";
55
+ const isTransitive = data?.transitive ?? false;
56
+ const colorClass = impactColors[impactType];
57
+ const strokeWidth = impactStrokeWidths[impactType];
58
+
59
+ return (
60
+ <>
61
+ <BaseEdge
62
+ id={id}
63
+ path={edgePath}
64
+ className={`${colorClass} ${selected ? "!stroke-primary" : ""}`}
65
+ style={{
66
+ strokeWidth: selected ? strokeWidth + 1 : strokeWidth,
67
+ strokeDasharray: isTransitive ? "6 4" : undefined,
68
+ }}
69
+ />
70
+
71
+ {/* Edge label */}
72
+ {data?.label && (
73
+ <foreignObject
74
+ x={labelX - 40}
75
+ y={labelY - 10}
76
+ width={80}
77
+ height={20}
78
+ className="pointer-events-none overflow-visible"
79
+ >
80
+ <div className="flex justify-center">
81
+ <span className="text-[10px] bg-background/90 border border-border rounded px-1.5 py-0.5 text-muted-foreground whitespace-nowrap">
82
+ {data.label}
83
+ </span>
84
+ </div>
85
+ </foreignObject>
86
+ )}
87
+ </>
88
+ );
89
+ }
@@ -0,0 +1,142 @@
1
+ import { memo } from "react";
2
+ import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
3
+
4
+ export interface SystemNodeData extends Record<string, unknown> {
5
+ label: string;
6
+ status?: "operational" | "degraded" | "down";
7
+ derivedState?: "info" | "degraded" | "down";
8
+ systemId: string;
9
+ }
10
+
11
+ export type SystemNode = Node<SystemNodeData, "system">;
12
+
13
+ const statusStyles: Record<string, { border: string; bg: string; glow: string; dot: string }> = {
14
+ operational: {
15
+ border: "border-emerald-500/40",
16
+ bg: "bg-emerald-500/5",
17
+ glow: "shadow-emerald-500/10",
18
+ dot: "bg-emerald-500",
19
+ },
20
+ degraded: {
21
+ border: "border-amber-500/40",
22
+ bg: "bg-amber-500/5",
23
+ glow: "shadow-amber-500/10",
24
+ dot: "bg-amber-500",
25
+ },
26
+ down: {
27
+ border: "border-red-500/40",
28
+ bg: "bg-red-500/5",
29
+ glow: "shadow-red-500/10",
30
+ dot: "bg-red-500",
31
+ },
32
+ };
33
+
34
+ function combineStatus(
35
+ status?: string,
36
+ derivedState?: string,
37
+ ): "operational" | "degraded" | "down" {
38
+ const order = { operational: 0, info: 0, degraded: 1, down: 2 };
39
+ const ownLevel = order[(status ?? "operational") as keyof typeof order] ?? 0;
40
+ const derivedLevel =
41
+ order[(derivedState ?? "operational") as keyof typeof order] ?? 0;
42
+ const level = Math.max(ownLevel, derivedLevel);
43
+ if (level >= 2) return "down";
44
+ if (level >= 1) return "degraded";
45
+ return "operational";
46
+ }
47
+
48
+ /**
49
+ * Custom React Flow node representing a system in the dependency graph.
50
+ * Color-coded by worst of own status and derived warning state.
51
+ */
52
+ export const SystemNodeComponent = memo(function SystemNodeComponent({
53
+ data,
54
+ selected,
55
+ }: NodeProps<SystemNode>) {
56
+ const effectiveStatus = combineStatus(data.status, data.derivedState);
57
+ const styles = statusStyles[effectiveStatus];
58
+
59
+ return (
60
+ <>
61
+ {/* Target handle (left) — "I am depended upon" */}
62
+ <Handle
63
+ type="target"
64
+ position={Position.Left}
65
+ className="!w-2.5 !h-2.5 !bg-muted-foreground/40 !border-2 !border-background"
66
+ />
67
+
68
+ <div
69
+ className={`
70
+ px-4 py-3 rounded-xl border-2 shadow-lg backdrop-blur-sm
71
+ transition-all duration-200
72
+ ${styles.border} ${styles.bg} ${styles.glow}
73
+ ${selected ? "ring-2 ring-primary ring-offset-2 ring-offset-background" : ""}
74
+ hover:scale-[1.02] cursor-grab active:cursor-grabbing
75
+ min-w-[140px] max-w-[200px]
76
+ `}
77
+ >
78
+ <div className="flex items-center gap-2">
79
+ {/* Status dot */}
80
+ <div className="relative flex-shrink-0">
81
+ <div
82
+ className={`w-2.5 h-2.5 rounded-full ${styles.dot}`}
83
+ />
84
+ {effectiveStatus !== "operational" && (
85
+ <div
86
+ className={`absolute inset-0 w-2.5 h-2.5 rounded-full ${styles.dot} animate-ping opacity-75`}
87
+ />
88
+ )}
89
+ </div>
90
+
91
+ {/* System name */}
92
+ <span className="text-sm font-medium text-foreground truncate">
93
+ {data.label}
94
+ </span>
95
+ </div>
96
+
97
+ {/* Status labels */}
98
+ <div className="mt-1.5 flex flex-col gap-0.5">
99
+ <span
100
+ className={`text-[10px] uppercase tracking-wider font-semibold ${
101
+ (data.status ?? "operational") === "operational"
102
+ ? "text-emerald-500/70"
103
+ : (data.status ?? "operational") === "degraded"
104
+ ? "text-amber-500/70"
105
+ : "text-red-500/70"
106
+ }`}
107
+ >
108
+ {(data.status ?? "operational") === "operational"
109
+ ? "Healthy"
110
+ : (data.status ?? "operational") === "degraded"
111
+ ? "Degraded"
112
+ : "Unhealthy"}
113
+ </span>
114
+ {data.derivedState && (
115
+ <span
116
+ className={`text-[10px] uppercase tracking-wider font-semibold ${
117
+ data.derivedState === "info"
118
+ ? "text-blue-400/70"
119
+ : data.derivedState === "degraded"
120
+ ? "text-amber-500/70"
121
+ : "text-red-500/70"
122
+ }`}
123
+ >
124
+ {data.derivedState === "info"
125
+ ? "↑ Upstream issue"
126
+ : data.derivedState === "degraded"
127
+ ? "↑ Upstream degraded"
128
+ : "↑ Upstream down"}
129
+ </span>
130
+ )}
131
+ </div>
132
+ </div>
133
+
134
+ {/* Source handle (right) — "I depend on..." */}
135
+ <Handle
136
+ type="source"
137
+ position={Position.Right}
138
+ className="!w-2.5 !h-2.5 !bg-muted-foreground/40 !border-2 !border-background"
139
+ />
140
+ </>
141
+ );
142
+ });
package/src/index.tsx ADDED
@@ -0,0 +1,51 @@
1
+ import {
2
+ createFrontendPlugin,
3
+ createSlotExtension,
4
+ UserMenuItemsSlot,
5
+ } from "@checkstack/frontend-api";
6
+ import {
7
+ pluginMetadata,
8
+ dependencyRoutes,
9
+ dependencyAccess,
10
+ } from "@checkstack/dependency-common";
11
+ import {
12
+ SystemDetailsTopSlot,
13
+ SystemStateBadgesSlot,
14
+ SystemEditorSlot,
15
+ } from "@checkstack/catalog-common";
16
+ import { DependencyBadge } from "./components/DependencyBadge";
17
+ import { DependencyAlert } from "./components/DependencyAlert";
18
+ import { DependencyEditor } from "./components/DependencyEditor";
19
+ import { DependencyMapPage } from "./components/DependencyMapPage";
20
+ import { DependencyMenuItems } from "./components/DependencyMenuItems";
21
+
22
+ export default createFrontendPlugin({
23
+ metadata: pluginMetadata,
24
+ routes: [
25
+ {
26
+ route: dependencyRoutes.routes.map,
27
+ element: <DependencyMapPage />,
28
+ title: "Dependency Map",
29
+ accessRule: dependencyAccess.dependency.read,
30
+ },
31
+ ],
32
+ apis: [],
33
+ extensions: [
34
+ createSlotExtension(SystemStateBadgesSlot, {
35
+ id: "dependency.system-state-badge",
36
+ component: DependencyBadge,
37
+ }),
38
+ createSlotExtension(SystemDetailsTopSlot, {
39
+ id: "dependency.system-details-top.alert",
40
+ component: DependencyAlert,
41
+ }),
42
+ createSlotExtension(SystemEditorSlot, {
43
+ id: "dependency.system-editor",
44
+ component: DependencyEditor,
45
+ }),
46
+ createSlotExtension(UserMenuItemsSlot, {
47
+ id: "dependency.user-menu.map",
48
+ component: DependencyMenuItems,
49
+ }),
50
+ ],
51
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }