@skyhook-io/radar-app 1.14.2 → 1.14.4

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.
Files changed (83) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +32 -0
  3. package/src/api/diagnose.ts +7 -1
  4. package/src/components/CloudConnectFlow.tsx +21 -7
  5. package/src/components/CloudFunnelButton.tsx +85 -8
  6. package/src/components/cloudConnectHandoff.test.ts +10 -1
  7. package/src/components/cloudConnectHandoff.ts +15 -1
  8. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  9. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  10. package/src/components/diagnose/AgentControls.tsx +603 -0
  11. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  12. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  13. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  14. package/src/components/diagnose/AssessmentCard.tsx +1579 -0
  15. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  16. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  17. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  18. package/src/components/diagnose/Home.test.tsx +1 -3
  19. package/src/components/diagnose/Home.tsx +2 -1
  20. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  21. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  22. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  23. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  24. package/src/components/diagnose/InvestigationView.tsx +39 -51
  25. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  26. package/src/components/diagnose/assessmentCopy.ts +169 -0
  27. package/src/components/diagnose/investigationCase.test.tsx +658 -2
  28. package/src/components/diagnose/investigationCase.ts +135 -26
  29. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +682 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
  31. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
  32. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  34. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +136 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  41. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  47. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  48. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  49. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  50. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  51. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  52. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  53. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  54. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  55. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  57. package/src/components/diagnose/investigationEvidence/observations.ts +13 -0
  58. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  59. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  60. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  61. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  62. package/src/components/diagnose/investigationState.test.ts +2 -2
  63. package/src/components/diagnose/investigationState.ts +2 -2
  64. package/src/components/diagnose/investigationStory.ts +1 -1
  65. package/src/components/diagnose/parts.test.tsx +15 -1
  66. package/src/components/diagnose/parts.tsx +26 -3904
  67. package/src/components/diagnose/target.ts +9 -3
  68. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  69. package/src/components/diagnose/toolCallLabel.ts +41 -2
  70. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  71. package/src/components/home/MCPSetupDialog.tsx +1 -1
  72. package/src/components/home/mcpToolCatalog.ts +27 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  74. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  75. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  76. package/src/components/resources/PodFilePreview.tsx +1 -1
  77. package/src/components/rightsizing/RightsizingScanView.tsx +20 -1
  78. package/src/components/ui/CommandPalette.tsx +12 -8
  79. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  80. package/src/components/ui/Omnibar.tsx +96 -60
  81. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  82. package/src/components/workload/WorkloadView.tsx +7 -1
  83. package/src/components/ui/Disclosure.tsx +0 -47
@@ -0,0 +1,373 @@
1
+ import {
2
+ Badge,
3
+ TerminalBlock,
4
+ formatRelativeAgeTime,
5
+ } from "@skyhook-io/k8s-ui";
6
+ import {
7
+ AreaChart,
8
+ SERIES_COLORS,
9
+ SeriesLegend,
10
+ formatMetricValue,
11
+ seriesDisplayLabels,
12
+ seriesFill,
13
+ type TimeSeries,
14
+ } from "@skyhook-io/k8s-ui/components/charts";
15
+ import {
16
+ metricsDomain,
17
+ type MetricsChangeCoverage,
18
+ } from "../../investigationMetrics";
19
+ import { Tooltip } from "../../../ui/Tooltip";
20
+ import { type EvidenceDataOf, severityBadge } from "../cardParts";
21
+
22
+ const METRICS_AXIS_LABEL_MAX_CHARS = 72;
23
+
24
+ function finiteSamples(series: TimeSeries): TimeSeries["dataPoints"] {
25
+ return series.dataPoints.filter((point) => typeof point.value === "number");
26
+ }
27
+
28
+ function MetricsValueTable({
29
+ series,
30
+ labels,
31
+ unit,
32
+ withTime,
33
+ }: {
34
+ series: TimeSeries[];
35
+ /** Display name per series, derived from the complete result. */
36
+ labels: string[];
37
+ unit: string;
38
+ withTime: boolean;
39
+ }) {
40
+ return (
41
+ <table className="w-full text-xs">
42
+ <tbody>
43
+ {series.map((item, index) => {
44
+ const sample = finiteSamples(item).at(-1);
45
+ return (
46
+ <tr
47
+ key={`${labels[index]}-${index}`}
48
+ className="border-b border-theme-border/60 last:border-b-0"
49
+ >
50
+ <td className="py-1 pr-3 font-mono text-theme-text-secondary [overflow-wrap:anywhere]">
51
+ {labels[index]}
52
+ </td>
53
+ {withTime ? (
54
+ <td className="py-1 pr-3 text-right text-theme-text-tertiary tabular-nums">
55
+ {sample
56
+ ? new Date(sample.timestamp * 1000).toLocaleTimeString()
57
+ : ""}
58
+ </td>
59
+ ) : null}
60
+ <td className="py-1 text-right font-mono tabular-nums text-theme-text-primary">
61
+ {sample && typeof sample.value === "number"
62
+ ? formatMetricValue(sample.value, unit)
63
+ : "no value"}
64
+ </td>
65
+ </tr>
66
+ );
67
+ })}
68
+ </tbody>
69
+ </table>
70
+ );
71
+ }
72
+
73
+ export function MetricsBody({
74
+ data,
75
+ changeCoverage,
76
+ }: {
77
+ data: EvidenceDataOf<"metrics">;
78
+ changeCoverage?: MetricsChangeCoverage;
79
+ }) {
80
+ const annotations = changeCoverage?.markers;
81
+ const unit = data.unit ?? "";
82
+ const axisLabel = data.label ?? data.query;
83
+ const axisTruncated = axisLabel.length > METRICS_AXIS_LABEL_MAX_CHARS;
84
+ const axisText = axisTruncated
85
+ ? `${axisLabel.slice(0, METRICS_AXIS_LABEL_MAX_CHARS - 1)}…`
86
+ : axisLabel;
87
+ const domain = metricsDomain(data);
88
+ const windowText = domain
89
+ ? `${new Date(domain.start * 1000).toLocaleString()} to ${new Date(domain.end * 1000).toLocaleString()}`
90
+ : undefined;
91
+ if (data.series.length === 0) {
92
+ return (
93
+ <div className="space-y-2">
94
+ <p className="text-xs text-theme-text-secondary">
95
+ No series matched this query
96
+ {data.mode === "range" ? " in the window" : ""}.
97
+ </p>
98
+ <pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
99
+ {data.query}
100
+ </pre>
101
+ {data.note ? (
102
+ <p className="text-xs text-theme-text-tertiary">{data.note}</p>
103
+ ) : null}
104
+ </div>
105
+ );
106
+ }
107
+ if (data.mode === "instant") {
108
+ return (
109
+ <div className="space-y-2">
110
+ <MetricsValueTable
111
+ series={data.series}
112
+ labels={seriesDisplayLabels(data.series)}
113
+ unit={unit}
114
+ withTime={false}
115
+ />
116
+ <pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
117
+ {data.query}
118
+ </pre>
119
+ {data.note ? (
120
+ <p className="text-xs text-theme-text-tertiary">{data.note}</p>
121
+ ) : null}
122
+ </div>
123
+ );
124
+ }
125
+ // Names come from the whole result so two series that differ only by a
126
+ // label the chart would hide stay distinguishable wherever they are listed.
127
+ const labels = seriesDisplayLabels(data.series);
128
+ const indexed = data.series.map((item, index) => ({
129
+ item,
130
+ label: labels[index],
131
+ finite: finiteSamples(item).length,
132
+ }));
133
+ // A series with one finite sample has no line to draw, and one with none
134
+ // (Prometheus serializes NaN and infinities as gaps) has nothing to show.
135
+ // Listing both keeps what was captured readable and states what was not.
136
+ const charted = indexed.filter((entry) => entry.finite >= 2);
137
+ const single = indexed.filter((entry) => entry.finite === 1);
138
+ const empty = indexed.filter((entry) => entry.finite === 0);
139
+ return (
140
+ <div className="space-y-2">
141
+ <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs">
142
+ <Tooltip
143
+ content={axisLabel}
144
+ delay={200}
145
+ position="top"
146
+ disabled={!axisTruncated}
147
+ >
148
+ <span
149
+ className="min-w-0 truncate font-mono text-theme-text-secondary"
150
+ data-testid="investigation-metrics-axis-label"
151
+ >
152
+ {axisText}
153
+ </span>
154
+ </Tooltip>
155
+ {unit ? (
156
+ <span className="text-theme-text-tertiary">({unit})</span>
157
+ ) : null}
158
+ {windowText ? (
159
+ <span className="ml-auto text-theme-text-tertiary">{windowText}</span>
160
+ ) : null}
161
+ </div>
162
+ {charted.length > 0 ? (
163
+ <div className="space-y-1.5 rounded-md border border-theme-border/70 bg-theme-base/30 p-2">
164
+ <AreaChart
165
+ series={charted.map((entry) => entry.item)}
166
+ seriesLabels={charted.map((entry) => entry.label)}
167
+ color={SERIES_COLORS[0]}
168
+ fillColor={seriesFill(0, SERIES_COLORS[0])}
169
+ unit={unit}
170
+ annotations={annotations}
171
+ domain={domain}
172
+ layout="auto"
173
+ />
174
+ {charted.length > 1 ? (
175
+ <SeriesLegend
176
+ series={charted.map((entry) => entry.item)}
177
+ seriesLabels={charted.map((entry) => entry.label)}
178
+ color={SERIES_COLORS[0]}
179
+ />
180
+ ) : null}
181
+ </div>
182
+ ) : null}
183
+ {single.length > 0 ? (
184
+ <div
185
+ className="space-y-1"
186
+ data-testid="investigation-metrics-sparse-series"
187
+ >
188
+ <p className="text-xs text-theme-text-tertiary">
189
+ {single.length === 1
190
+ ? "1 series has a single sample in this window, listed with its time:"
191
+ : `${single.length} series have a single sample in this window, listed with their times:`}
192
+ </p>
193
+ <MetricsValueTable
194
+ series={single.map((entry) => entry.item)}
195
+ labels={single.map((entry) => entry.label)}
196
+ unit={unit}
197
+ withTime
198
+ />
199
+ </div>
200
+ ) : null}
201
+ {empty.length > 0 ? (
202
+ <p
203
+ className="text-xs text-theme-text-tertiary [overflow-wrap:anywhere]"
204
+ data-testid="investigation-metrics-empty-series"
205
+ >
206
+ {empty.length === 1
207
+ ? "1 series had no usable samples in this window: "
208
+ : `${empty.length} series had no usable samples in this window: `}
209
+ <span className="font-mono">
210
+ {empty.map((entry) => entry.label).join("; ")}
211
+ </span>
212
+ </p>
213
+ ) : null}
214
+ {changeCoverage?.checked && charted.length > 0 ? (
215
+ <p className="text-xs text-theme-text-tertiary">
216
+ {annotations?.length === 1
217
+ ? "1 change recorded in this window is marked on the chart."
218
+ : annotations?.length
219
+ ? `${annotations.length} changes recorded in this window are marked on the chart.`
220
+ : // Only that the changes Radar read miss this window — not that
221
+ // the window was fully covered. The change lookup has its own
222
+ // window, which need not span the chart's.
223
+ "None of the changes Radar read fall in this window."}
224
+ </p>
225
+ ) : null}
226
+ {axisTruncated ? (
227
+ <pre className="whitespace-pre-wrap break-all rounded-md border border-theme-border/70 bg-theme-base/30 p-2 font-mono text-xs text-theme-text-secondary">
228
+ {data.query}
229
+ </pre>
230
+ ) : null}
231
+ {data.note ? (
232
+ <p className="text-xs text-theme-text-tertiary">{data.note}</p>
233
+ ) : null}
234
+ </div>
235
+ );
236
+ }
237
+
238
+ const VISIBLE_ALERT_LABELS = 6;
239
+
240
+ function alertStateSeverity(state: string | undefined) {
241
+ switch ((state ?? "").toLowerCase()) {
242
+ case "firing":
243
+ return "error" as const;
244
+ case "pending":
245
+ return "warning" as const;
246
+ case "inactive":
247
+ return "success" as const;
248
+ default:
249
+ return "neutral" as const;
250
+ }
251
+ }
252
+
253
+ export function AlertsBody({ data }: { data: EvidenceDataOf<"alerts"> }) {
254
+ const { rule, instances, annotations } = data;
255
+ const health = rule.health?.toLowerCase();
256
+ // Target instances lead; the rest stay visible in producer order.
257
+ const ordered = [...instances].sort(
258
+ (left, right) => Number(right.namesTarget) - Number(left.namesTarget),
259
+ );
260
+ const annotationEntries = Object.entries(annotations);
261
+ return (
262
+ <div className="space-y-2.5">
263
+ <div className="flex flex-wrap items-center gap-1.5">
264
+ {rule.state ? (
265
+ <Badge severity={alertStateSeverity(rule.state)} size="sm">
266
+ {rule.state}
267
+ </Badge>
268
+ ) : null}
269
+ {rule.labels.severity ? (
270
+ <Badge severity={severityBadge(rule.labels.severity)} size="sm">
271
+ severity {rule.labels.severity}
272
+ </Badge>
273
+ ) : null}
274
+ <Badge tone="structural" size="sm">
275
+ {rule.group}
276
+ </Badge>
277
+ {health && health !== "ok" ? (
278
+ <Badge severity={health === "err" ? "error" : "neutral"} size="sm">
279
+ health {health}
280
+ </Badge>
281
+ ) : null}
282
+ </div>
283
+ {ordered.length > 0 ? (
284
+ <ol className="max-h-64 space-y-1.5 overflow-y-auto pr-1">
285
+ {ordered.map((instance, index) => {
286
+ const labels = Object.entries(instance.labels).filter(
287
+ ([key]) => key !== "alertname" && key !== "severity",
288
+ );
289
+ const hidden = labels.length - VISIBLE_ALERT_LABELS;
290
+ return (
291
+ <li
292
+ key={`${instance.state}-${index}-${labels.map(([k, v]) => `${k}=${v}`).join(",")}`}
293
+ className="rounded-md border border-theme-border/70 bg-theme-base/30 px-2.5 py-2"
294
+ >
295
+ <div className="flex flex-wrap items-center gap-1.5">
296
+ <Badge
297
+ severity={alertStateSeverity(instance.state)}
298
+ size="sm"
299
+ >
300
+ {instance.state}
301
+ </Badge>
302
+ {instance.namesTarget ? (
303
+ <Badge severity="info" size="sm">
304
+ names this resource
305
+ </Badge>
306
+ ) : null}
307
+ {instance.value ? (
308
+ <span className="font-mono text-xs text-theme-text-secondary">
309
+ value {instance.value}
310
+ </span>
311
+ ) : null}
312
+ {instance.activeAt ? (
313
+ <Tooltip
314
+ content={new Date(instance.activeAt).toLocaleString()}
315
+ delay={150}
316
+ position="left"
317
+ wrapperClassName="ml-auto"
318
+ >
319
+ <time
320
+ dateTime={instance.activeAt}
321
+ className="text-xs text-theme-text-tertiary"
322
+ >
323
+ active {formatRelativeAgeTime(instance.activeAt)}
324
+ </time>
325
+ </Tooltip>
326
+ ) : null}
327
+ </div>
328
+ {labels.length > 0 ? (
329
+ <div className="mt-1.5 flex flex-wrap gap-1 font-mono text-[11px] text-theme-text-secondary">
330
+ {labels
331
+ .slice(0, VISIBLE_ALERT_LABELS)
332
+ .map(([key, value]) => (
333
+ <span
334
+ key={key}
335
+ className="rounded bg-theme-base px-1.5 py-0.5 [overflow-wrap:anywhere]"
336
+ >
337
+ {key}={value}
338
+ </span>
339
+ ))}
340
+ {hidden > 0 ? (
341
+ <span className="px-1 py-0.5 text-theme-text-tertiary">
342
+ +{hidden} more
343
+ </span>
344
+ ) : null}
345
+ </div>
346
+ ) : null}
347
+ </li>
348
+ );
349
+ })}
350
+ </ol>
351
+ ) : (
352
+ <p className="text-xs italic text-theme-text-tertiary">
353
+ No active instances were reported for this rule.
354
+ </p>
355
+ )}
356
+ {annotationEntries.length > 0 ? (
357
+ <dl className="space-y-1 text-xs">
358
+ {annotationEntries.map(([key, value]) => (
359
+ <div key={key} className="flex min-w-0 gap-2">
360
+ <dt className="shrink-0 text-theme-text-tertiary">{key}</dt>
361
+ <dd className="min-w-0 leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]">
362
+ {value}
363
+ </dd>
364
+ </div>
365
+ ))}
366
+ </dl>
367
+ ) : null}
368
+ {rule.query ? (
369
+ <TerminalBlock label="Rule expression">{rule.query}</TerminalBlock>
370
+ ) : null}
371
+ </div>
372
+ );
373
+ }
@@ -0,0 +1,274 @@
1
+ import { useContext } from "react";
2
+ import { AlertTriangle } from "lucide-react";
3
+ import { Badge, StatusDot, ResourceLink } from "@skyhook-io/k8s-ui";
4
+ import { EvidenceNavigationContext } from "../navigation";
5
+ import { type EvidenceDataOf, severityBadge } from "../cardParts";
6
+
7
+ export function DNSBody({ data }: { data: EvidenceDataOf<"dns"> }) {
8
+ const { onOpenResource } = useContext(EvidenceNavigationContext);
9
+ return (
10
+ <div className="space-y-2">
11
+ {(data.dns.signals ?? []).map((signal) => (
12
+ <p
13
+ key={signal}
14
+ className="text-xs leading-relaxed text-theme-text-secondary"
15
+ >
16
+ {signal}
17
+ </p>
18
+ ))}
19
+ {(data.dns.coreDNSFindings ?? []).map((finding) => (
20
+ <div
21
+ key={`${finding.kind}-${finding.namespace}-${finding.name}-${finding.reason}`}
22
+ className="rounded-md border border-theme-border bg-theme-base/40 p-2"
23
+ >
24
+ <div className="flex flex-wrap gap-1.5">
25
+ <Badge tone="structural" size="sm">
26
+ {finding.kind}
27
+ </Badge>
28
+ <span className="font-mono text-xs">
29
+ <ResourceLink
30
+ name={finding.name}
31
+ kind={finding.kind}
32
+ namespace={finding.namespace}
33
+ group=""
34
+ label={`${finding.namespace}/${finding.name}`}
35
+ onNavigate={
36
+ finding.kind.toLowerCase() === "configmap" && onOpenResource
37
+ ? (ref) => onOpenResource(ref)
38
+ : undefined
39
+ }
40
+ />
41
+ </span>
42
+ <Badge severity={severityBadge(finding.severity)} size="sm">
43
+ {finding.severity}
44
+ </Badge>
45
+ </div>
46
+ <p className="mt-1 text-xs text-theme-text-secondary">
47
+ {finding.reason}
48
+ {finding.message ? ` — ${finding.message}` : ""}
49
+ </p>
50
+ </div>
51
+ ))}
52
+ </div>
53
+ );
54
+ }
55
+
56
+ export function NetworkBody({ data }: { data: EvidenceDataOf<"network"> }) {
57
+ const { network } = data;
58
+ const stats = [
59
+ ["Tested", network.summary.tested],
60
+ ["Passed", network.summary.passed],
61
+ ["Failed", network.summary.failed],
62
+ ["Inferred", network.summary.derived ?? 0],
63
+ ["Skipped", network.summary.skipped],
64
+ ] as const;
65
+ return (
66
+ <div className="space-y-3">
67
+ <div className="grid grid-cols-3 gap-1.5 @min-[620px]/evidence:grid-cols-5">
68
+ {stats.map(([label, value]) => (
69
+ <div
70
+ key={label}
71
+ className="rounded-md border border-theme-border bg-theme-base/40 px-2 py-1.5 text-center"
72
+ >
73
+ <div className="font-mono text-sm font-semibold text-theme-text-primary">
74
+ {value}
75
+ </div>
76
+ <div className="text-xs uppercase tracking-wide text-theme-text-tertiary">
77
+ {label}
78
+ </div>
79
+ </div>
80
+ ))}
81
+ </div>
82
+ {network.diagnosis ? (
83
+ <div className="rounded-md border border-theme-border bg-theme-base/40 px-2.5 py-2">
84
+ <p className="text-xs font-medium leading-relaxed text-theme-text-primary">
85
+ {network.diagnosis.summary}
86
+ </p>
87
+ {network.diagnosis.nextAction ? (
88
+ <p className="mt-1 border-l-2 border-accent/50 pl-2 text-xs leading-relaxed text-theme-text-secondary">
89
+ Next check: {network.diagnosis.nextAction}
90
+ </p>
91
+ ) : null}
92
+ </div>
93
+ ) : (
94
+ <p className="text-xs leading-relaxed text-theme-text-secondary">
95
+ {network.summary.headline}
96
+ </p>
97
+ )}
98
+ {network.routes.length > 0 ? (
99
+ <ol className="max-h-64 space-y-1.5 overflow-y-auto pr-1">
100
+ {network.routes.map((route, index) => (
101
+ <li
102
+ key={`${route.route}-${route.target ?? ""}-${index}`}
103
+ className="flex min-w-0 items-start gap-2 rounded-md border border-theme-border/70 bg-theme-base/30 px-2.5 py-2"
104
+ >
105
+ <StatusDot
106
+ tone={networkOutcomeTone(route.outcome, route.benign)}
107
+ className="mt-1 shrink-0"
108
+ />
109
+ <span className="min-w-0 flex-1">
110
+ <span className="block truncate font-mono text-xs text-theme-text-primary">
111
+ {route.route}
112
+ {route.target ? ` → ${route.target}` : ""}
113
+ </span>
114
+ {route.evidence ? (
115
+ <span className="mt-0.5 block text-xs leading-relaxed text-theme-text-secondary">
116
+ {route.evidence}
117
+ </span>
118
+ ) : null}
119
+ </span>
120
+ <Badge
121
+ severity={networkOutcomeSeverity(route.outcome, route.benign)}
122
+ size="sm"
123
+ >
124
+ {route.benign
125
+ ? "intentional"
126
+ : route.outcome.replaceAll("_", " ")}
127
+ </Badge>
128
+ </li>
129
+ ))}
130
+ </ol>
131
+ ) : null}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ function networkOutcomeTone(
137
+ outcome: string,
138
+ benign?: boolean,
139
+ ): "healthy" | "degraded" | "unhealthy" | "unknown" {
140
+ if (benign) return "degraded";
141
+ const normalized = outcome.toLowerCase();
142
+ if (normalized.includes("verified") || normalized.includes("reached"))
143
+ return "healthy";
144
+ if (normalized.includes("fail") || normalized.includes("unreachable"))
145
+ return "unhealthy";
146
+ if (normalized.includes("skip") || normalized.includes("not"))
147
+ return "unknown";
148
+ return "degraded";
149
+ }
150
+
151
+ function networkOutcomeSeverity(outcome: string, benign?: boolean) {
152
+ switch (networkOutcomeTone(outcome, benign)) {
153
+ case "healthy":
154
+ return "success" as const;
155
+ case "degraded":
156
+ return "warning" as const;
157
+ case "unhealthy":
158
+ return "error" as const;
159
+ case "unknown":
160
+ return "neutral" as const;
161
+ }
162
+ }
163
+
164
+ export function RelationshipsBody({
165
+ data,
166
+ }: {
167
+ data: EvidenceDataOf<"relationships">;
168
+ }) {
169
+ return (
170
+ <div className="space-y-2.5">
171
+ <div className="flex flex-wrap items-center gap-1.5">
172
+ <Badge tone="structural" size="sm">
173
+ {data.root.kind}
174
+ </Badge>
175
+ <span className="font-mono text-xs text-theme-text-secondary">
176
+ {data.root.namespace ? `${data.root.namespace}/` : ""}
177
+ {data.root.name}
178
+ </span>
179
+ <span className="text-xs text-theme-text-tertiary">
180
+ {data.nodes.length} resources · {data.edges.length} direct
181
+ relationships
182
+ </span>
183
+ </div>
184
+ <div className="max-h-44 overflow-y-auto pr-1">
185
+ <div className="flex flex-wrap gap-1.5">
186
+ {data.nodes.map((node) => (
187
+ <span
188
+ key={node.id}
189
+ className="inline-flex items-center gap-1 rounded-md border border-theme-border bg-theme-base px-2 py-1 text-xs"
190
+ >
191
+ <Badge tone="structural" size="sm">
192
+ {node.kind}
193
+ </Badge>
194
+ <span className="font-mono text-theme-text-secondary">
195
+ {node.name}
196
+ </span>
197
+ </span>
198
+ ))}
199
+ </div>
200
+ </div>
201
+ {data.edges.length > 0 ? (
202
+ <div className="grid max-h-52 gap-1 overflow-y-auto pr-1 @min-[680px]/evidence:grid-cols-2">
203
+ {data.edges.map((edge, index) => (
204
+ <div
205
+ key={
206
+ edge.id ?? `${edge.source}-${edge.target}-${edge.type}-${index}`
207
+ }
208
+ className="flex min-w-0 items-center gap-1.5 rounded bg-theme-base/50 px-2 py-1.5 font-mono text-xs text-theme-text-secondary"
209
+ >
210
+ <span className="truncate">{edge.source}</span>
211
+ <span className="shrink-0 text-theme-text-tertiary">→</span>
212
+ <span className="truncate">{edge.target}</span>
213
+ <Badge tone="structural" size="sm">
214
+ {edge.label || edge.type}
215
+ </Badge>
216
+ </div>
217
+ ))}
218
+ </div>
219
+ ) : null}
220
+ </div>
221
+ );
222
+ }
223
+
224
+ export function TopologyBody({ data }: { data: EvidenceDataOf<"topology"> }) {
225
+ return (
226
+ <div className="space-y-2.5">
227
+ <div className="grid grid-cols-2 gap-2">
228
+ <TopologyStat label="Nodes" value={data.stats.nodes} />
229
+ <TopologyStat label="Relationships" value={data.stats.edges} />
230
+ </div>
231
+ {data.problems.map((problem) => (
232
+ <p
233
+ key={problem}
234
+ className="flex items-start gap-1.5 text-xs text-warning-text"
235
+ >
236
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
237
+ {problem}
238
+ </p>
239
+ ))}
240
+ <div className="max-h-64 space-y-2 overflow-y-auto pr-1">
241
+ {data.namespaces.map((namespace) => (
242
+ <div key={namespace.namespace}>
243
+ <div className="text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
244
+ {namespace.namespace || "cluster-scoped"}
245
+ </div>
246
+ <ul className="mt-1 space-y-1 font-mono text-xs text-theme-text-secondary">
247
+ {namespace.chains.map((chain) => (
248
+ <li key={chain}>{chain}</li>
249
+ ))}
250
+ </ul>
251
+ </div>
252
+ ))}
253
+ </div>
254
+ {data.warnings.map((warning) => (
255
+ <p key={warning} className="text-xs text-warning-text">
256
+ {warning}
257
+ </p>
258
+ ))}
259
+ </div>
260
+ );
261
+ }
262
+
263
+ function TopologyStat({ label, value }: { label: string; value: number }) {
264
+ return (
265
+ <div className="rounded-md border border-theme-border bg-theme-base/40 px-3 py-2">
266
+ <div className="font-mono text-lg font-semibold text-theme-text-primary">
267
+ {value}
268
+ </div>
269
+ <div className="text-xs uppercase tracking-wide text-theme-text-tertiary">
270
+ {label}
271
+ </div>
272
+ </div>
273
+ );
274
+ }