@skyhook-io/k8s-ui 1.10.4 → 1.11.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.
Files changed (99) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.test.tsx +46 -0
  3. package/src/components/applications/ApplicationDetail.tsx +14 -15
  4. package/src/components/issues/IssuesView.tsx +9 -0
  5. package/src/components/issues/diagnostic.ts +3 -0
  6. package/src/components/issues/issues.test.ts +11 -0
  7. package/src/components/issues/severity.ts +3 -0
  8. package/src/components/issues/types.ts +3 -0
  9. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  10. package/src/components/resources/ResourcesView.tsx +259 -30
  11. package/src/components/resources/index.ts +2 -0
  12. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  13. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  14. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
  17. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  18. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  19. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  20. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  21. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  22. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  24. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  25. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  26. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  27. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  28. package/src/components/resources/renderers/index.ts +4 -0
  29. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  30. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  31. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  32. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  33. package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
  34. package/src/components/resources/resource-utils-cnpg.ts +373 -56
  35. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  36. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  37. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  38. package/src/components/resources/resource-utils-velero.ts +214 -22
  39. package/src/components/resources/resource-utils.ts +70 -3
  40. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  41. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  42. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  43. package/src/components/topology/K8sResourceNode.tsx +11 -0
  44. package/src/components/topology/TopologyGraph.tsx +85 -5
  45. package/src/components/trace/ReachabilityGraph.tsx +517 -0
  46. package/src/components/trace/ReachabilityView.tsx +1173 -0
  47. package/src/components/trace/TracePanel.test.ts +172 -0
  48. package/src/components/trace/TracePanel.tsx +558 -0
  49. package/src/components/trace/TraceSummary.test.ts +213 -0
  50. package/src/components/trace/TraceSummary.tsx +168 -0
  51. package/src/components/trace/inClusterSummary.test.ts +98 -0
  52. package/src/components/trace/inClusterSummary.ts +81 -0
  53. package/src/components/trace/index.ts +19 -0
  54. package/src/components/trace/podReach.test.ts +71 -0
  55. package/src/components/trace/podReach.ts +37 -0
  56. package/src/components/trace/probe-display.test.ts +107 -0
  57. package/src/components/trace/probe-display.ts +37 -0
  58. package/src/components/trace/problemRows.test.ts +107 -0
  59. package/src/components/trace/reachFixtures.ts +261 -0
  60. package/src/components/trace/reachGraphModel.test.ts +1423 -0
  61. package/src/components/trace/reachGraphModel.ts +1521 -0
  62. package/src/components/trace/reachInspector.test.ts +892 -0
  63. package/src/components/trace/reachInspector.ts +797 -0
  64. package/src/components/trace/reachMarks.test.ts +698 -0
  65. package/src/components/trace/reachMarks.ts +623 -0
  66. package/src/components/trace/reachOrigins.test.ts +392 -0
  67. package/src/components/trace/reachOrigins.ts +449 -0
  68. package/src/components/trace/reachVerdict.test.ts +537 -0
  69. package/src/components/trace/reachVerdict.ts +601 -0
  70. package/src/components/trace/traceFingerprint.test.ts +214 -0
  71. package/src/components/trace/traceFingerprint.ts +122 -0
  72. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  73. package/src/components/trace/traceToSubgraph.ts +463 -0
  74. package/src/components/trace/types.ts +414 -0
  75. package/src/components/ui/Badge.tsx +36 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  77. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  78. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  79. package/src/components/ui/index.ts +1 -0
  80. package/src/components/workload/WorkloadView.tsx +58 -5
  81. package/src/components/workload/index.ts +1 -1
  82. package/src/components/workload/reachabilityTab.test.ts +47 -0
  83. package/src/index.ts +5 -0
  84. package/src/theme/components.css +64 -0
  85. package/src/theme/variables.css +5 -0
  86. package/src/types/core.ts +8 -0
  87. package/src/utils/api-resources.ts +2 -0
  88. package/src/utils/application-history.test.ts +1 -1
  89. package/src/utils/applications.test.ts +63 -1
  90. package/src/utils/applications.ts +27 -0
  91. package/src/utils/inClusterConsent.test.ts +109 -0
  92. package/src/utils/inClusterConsent.ts +72 -0
  93. package/src/utils/index.ts +1 -0
  94. package/src/utils/navigation.test.ts +61 -0
  95. package/src/utils/navigation.ts +11 -9
  96. package/src/utils/pluralize.test.ts +80 -1
  97. package/src/utils/pluralize.ts +34 -0
  98. package/src/utils/resource-icons.test.ts +33 -0
  99. package/src/utils/resource-icons.ts +65 -2
@@ -0,0 +1,1173 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { Trace, RouteResult, ResourceRef } from './types'
3
+ import { ReachActions, JustTestedNote, CopyableCommand, completedRequestMode, type TracePanelProps } from './TracePanel'
4
+ import { AlertBanner } from '../ui/drawer-components'
5
+ import { PaneLoader } from '../ui/PaneLoader'
6
+ import { ReachabilityGraph, MarkGlyph } from './ReachabilityGraph'
7
+ import { Activity, ChevronRight } from 'lucide-react'
8
+ import { Tooltip } from '../ui/Tooltip'
9
+ import { buildGraph, noteHeadline } from './reachGraphModel'
10
+ import { buildOrigins, defaultOrigin, probeCheckStats, type Origin, type OriginId } from './reachOrigins'
11
+ import { buildSidebar, buildVerdict, type Sidebar, type HopReport, type InspectorCTA, type Selection } from './reachInspector'
12
+ import { markStyle, glyphStyle, markHelp, scenariosFor, routeTone, routeChip, routeIdentity, routeForOrigin, traceInClusterRunnable, SEV_COLOR, SEV_BADGE, type Scenario } from './reachMarks'
13
+ import { evidenceBannerTitle } from './inClusterSummary'
14
+ import { DEV_STATES, devTrace, type DevState } from './reachFixtures'
15
+
16
+ export { podReach, podProbeKey } from './podReach'
17
+
18
+ /**
19
+ * ReachabilityView — the Reachability tab body.
20
+ *
21
+ * The organising idea is that a reachability result is meaningless without its
22
+ * vantage. So the view is built around two selections the operator makes
23
+ * explicitly: WHICH scenario (a port, a route, a front door) and FROM WHICH
24
+ * ORIGIN. Everything else — the graph, the verdict, the inspector — is a
25
+ * function of that pair, and changing the origin genuinely re-routes the graph
26
+ * rather than relabelling it.
27
+ *
28
+ * Node dots carry resource health; edges carry path truth. Those are never
29
+ * merged: a healthy resource behind a failed hop stays healthy and the hop
30
+ * stays red.
31
+ */
32
+ export function ReachabilityView(props: TracePanelProps) {
33
+ const {
34
+ trace: liveTrace,
35
+ isLoading,
36
+ error,
37
+ inClusterError,
38
+ inClusterPartial,
39
+ inClusterFallback,
40
+ inClusterEvidenceOnly,
41
+ inClusterEvidenceNote,
42
+ probeError,
43
+ onRunProbes,
44
+ onRefresh,
45
+ clusterChangedSinceTest,
46
+ } = props
47
+
48
+ // Dev-only: drive the view from fixtures so the empty / failing / sampled /
49
+ // denied states are reachable without a cluster shaped like each one.
50
+ const devEnabled = !!import.meta.env?.DEV
51
+ // Survives re-renders, and deliberately NOT state: flipping it must not cause
52
+ // a render of its own.
53
+ const shownRef = useRef(false)
54
+ const [devState, setDevState] = useState<DevState | null>(null)
55
+ const trace = devState ? devTrace(devState) : liveTrace
56
+ const running = devState === 'running' || !!props.inClusterRunning
57
+ const stale = devState === 'stale' || !!clusterChangedSinceTest
58
+ const inClusterAllowed = devState === 'rbac' ? false : props.inClusterAllowed
59
+
60
+ // The full-pane loader is for the FIRST paint only. Returning it later
61
+ // UNMOUNTS the board, which throws away the vantage and path the operator had
62
+ // selected - so starting an in-cluster test dropped them back to a default
63
+ // view of a different vantage, mid-test. Once the board has been shown it
64
+ // stays shown, and progress is expressed inside it (the tested path animates).
65
+ const firstPaintDone = shownRef.current
66
+ if (isLoading && !firstPaintDone && (!trace || !props.probed)) {
67
+ return <PaneLoader className="flex-1 py-10" label="Testing reachability…" />
68
+ }
69
+ if (trace) shownRef.current = true
70
+ if (error && !trace) {
71
+ return (
72
+ <div className="p-1">
73
+ <AlertBanner variant="error" title="Couldn’t load reachability" message={error.message}>
74
+ {onRefresh && (
75
+ <button
76
+ type="button"
77
+ onClick={onRefresh}
78
+ className="mt-2 rounded border border-theme-border bg-theme-surface px-2 py-1 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover"
79
+ >
80
+ Retry
81
+ </button>
82
+ )}
83
+ </AlertBanner>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ return (
89
+ <div className="flex h-full min-h-0 flex-col gap-2">
90
+ {devEnabled && <DevStateBar state={devState} onPick={setDevState} />}
91
+
92
+ {/* A dead backend must not present a frozen board as current truth: once
93
+ anything rendered, poll failures kept the old verdict + timestamp on
94
+ screen with no indication - and while polls fail, the cluster-changed
95
+ staleness detector is blind. */}
96
+ {error && trace && (
97
+ <div
98
+ className="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[11.5px]"
99
+ style={{ borderColor: 'var(--color-warning)', background: 'color-mix(in srgb, var(--color-warning) 8%, transparent)', color: 'var(--text-secondary)' }}
100
+ >
101
+ <span style={{ color: 'var(--color-warning-dark)' }}>◷</span>
102
+ Live updates unavailable — showing the last result. Changes in the cluster since then are not reflected.
103
+ </div>
104
+ )}
105
+ {probeError && (
106
+ <AlertBanner variant="error" title="Reachability test failed" message={probeError.message}>
107
+ {onRunProbes && (
108
+ <button
109
+ type="button"
110
+ onClick={onRunProbes}
111
+ className="mt-2 rounded border border-theme-border bg-theme-surface px-2 py-1 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover"
112
+ >
113
+ Try again
114
+ </button>
115
+ )}
116
+ </AlertBanner>
117
+ )}
118
+ {/* "Couldn't run" is only honest when NOTHING ran. A run whose trailing
119
+ routes hit the pod cap / time budget still folded results into the
120
+ merged trace - title that "partially completed", or the banner lies
121
+ over live results. */}
122
+ {inClusterError && (
123
+ <AlertBanner
124
+ variant="warning"
125
+ title={inClusterPartial ? 'In-cluster test partially completed' : "In-cluster test couldn't run"}
126
+ message={inClusterError}
127
+ >
128
+ {inClusterFallback && (
129
+ <div className="mt-2">
130
+ <CopyableCommand command={inClusterFallback} />
131
+ </div>
132
+ )}
133
+ </AlertBanner>
134
+ )}
135
+ {!inClusterError && inClusterEvidenceOnly && (
136
+ <AlertBanner
137
+ variant="info"
138
+ title={evidenceBannerTitle({ partial: !!inClusterPartial, evidenceOnly: true })}
139
+ message={inClusterEvidenceNote || 'The in-cluster probe produced evidence but nothing that changes a declared route outcome.'}
140
+ >
141
+ {inClusterFallback && (
142
+ <div className="mt-2">
143
+ <CopyableCommand command={inClusterFallback} />
144
+ </div>
145
+ )}
146
+ </AlertBanner>
147
+ )}
148
+
149
+ {trace ? (
150
+ <ReachabilityBoard {...props} trace={trace} running={running} stale={stale} inClusterAllowed={inClusterAllowed} />
151
+ ) : (
152
+ <div className="p-4 text-sm text-theme-text-tertiary">No reachability data.</div>
153
+ )}
154
+ </div>
155
+ )
156
+ }
157
+
158
+ interface BoardProps extends TracePanelProps {
159
+ trace: Trace
160
+ running: boolean
161
+ stale: boolean
162
+ }
163
+
164
+ function ReachabilityBoard(props: BoardProps) {
165
+ const { trace, running, stale, onNavigateToResource, onRunInCluster, onRunProbes, inClusterAllowed, inClusterDeniedReason, inClusterError, testedAt, runNonce } = props
166
+
167
+ // Scenarios group routes that agree in every respect - a Gateway route with
168
+ // three hostnames and one backend is one situation, not three.
169
+ const scenarios = useMemo(() => scenariosFor(trace.routes ?? [], trace.notTested ?? [], trace.upstreams ?? []), [trace])
170
+ // Anchored to the selected ROUTE's identity, not to the scenario's group key.
171
+ // The group key carries outcome, confidence and evidence, so a re-run that
172
+ // changed a result changed the key and dropped the user back to the first
173
+ // scenario - losing their place exactly when they had just asked for new
174
+ // evidence about the path they were reading.
175
+ const [selectedRoute, setSelectedRoute] = useState<string | null>(null)
176
+ const scenario =
177
+ scenarios.find((s) => s.routes.some((r) => routeIdentity(r) === selectedRoute)) ?? scenarios[0]
178
+ const route: RouteResult | undefined = scenario?.primary
179
+
180
+ const origins = useMemo(
181
+ () => buildOrigins(trace, { inClusterAllowed, inClusterDeniedReason, inClusterRunning: running, inClusterRunError: inClusterError, stale, route }),
182
+ [trace, inClusterAllowed, inClusterDeniedReason, running, inClusterError, stale, route],
183
+ )
184
+ const [originId, setOriginId] = useState<OriginId | null>(null)
185
+ const origin = origins.find((o) => o.id === (originId ?? defaultOrigin(origins, route))) ?? origins[0]
186
+ // The Job runs from the in-cluster vantage; a band scoped to the LAPTOP must
187
+ // not read "testing" because a different vantage's test started. The graph
188
+ // already scopes per-origin (isRunning) - the verdict + sidebar were reading
189
+ // the global flag.
190
+ const selectedOriginRunning = running && origin?.id === 'incluster'
191
+
192
+ const [selection, setSelection] = useState<Selection>(undefined)
193
+ // The entry-problem rows point at a node from outside the graph: hovering one
194
+ // rings it, so "where is it?" is answered before a click is spent.
195
+ const [hoveredNodeId, setHoveredNodeId] = useState<string | undefined>(undefined)
196
+
197
+ const model = useMemo(
198
+ () => buildGraph({ trace, route, origin, origins, stale, running }),
199
+ [trace, route, origin, origins, stale, running],
200
+ )
201
+ const multiPath = scenarios.length > 1
202
+ const sidebar = useMemo(
203
+ () =>
204
+ buildSidebar(selection, {
205
+ trace,
206
+ route,
207
+ origin,
208
+ origins,
209
+ nodes: model.nodes,
210
+ breakNodeId: model.breakNodeId,
211
+ breakAtExitOf: model.breakAtExitOf,
212
+ nonNetworkNodeIds: model.nonNetworkNodeIds,
213
+ contextNodeIds: model.contextNodeIds,
214
+ interleave: model.interleave,
215
+ entryParallelCount: model.entryParallelCount,
216
+ journeyEntryNodeIds: model.journeyEntryNodeIds,
217
+ pathNodeIds: model.pathNodeIds,
218
+ stale,
219
+ running: selectedOriginRunning,
220
+ multiPath,
221
+ httpPath: props.probePath,
222
+ canRunInCluster: !!onRunInCluster && inClusterAllowed !== false,
223
+ }),
224
+ [selection, trace, route, origin, origins, model, stale, selectedOriginRunning, multiPath, props.probePath, onRunInCluster, inClusterAllowed],
225
+ )
226
+ const verdict = useMemo(
227
+ () => buildVerdict(trace, route, { stale, running: selectedOriginRunning, originId: origin?.id, originName: origin?.name, pathLabel: multiPath ? scenario?.primary.target || scenario?.label : undefined }),
228
+ [trace, route, origins, stale, running, multiPath, scenario, origin],
229
+ )
230
+
231
+ const problems = useMemo(
232
+ () => problemRows(trace, origins, new Set(model.nodes.map((n) => n.id))),
233
+ // Deliberately NOT keyed on the selection: the header is the resource.
234
+ [trace, origins, model],
235
+ )
236
+
237
+ const onCTA = (cta: InspectorCTA) => {
238
+ if (cta.disabledReason) return
239
+ if (cta.action === 'run-in-cluster') onRunInCluster?.()
240
+ // onRefresh refetches the STATIC trace and collects no evidence - wrong for a
241
+ // CTA whose own copy offers to go and test something.
242
+ else if (cta.action === 'run-probes') onRunProbes?.()
243
+ else if (cta.action === 'open-resource' && cta.ref) onNavigateToResource?.(cta.ref)
244
+ else if (cta.action === 'copy-command' && cta.command) void navigator.clipboard?.writeText(cta.command)
245
+ }
246
+
247
+ return (
248
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-theme-border bg-theme-surface">
249
+ {/* The HTTP-path editor only makes sense when some declared route takes an
250
+ HTTP-shaped request - a TCP-only (redis/gRPC) subject would offer a
251
+ path field nothing would ever send. */}
252
+ <VerdictBand
253
+ verdict={verdict}
254
+ trace={trace}
255
+ runNonce={runNonce}
256
+ problems={problems}
257
+ onEntryHover={setHoveredNodeId}
258
+ onEntrySelect={(id) => {
259
+ // A pointer row addresses a VANTAGE capsule; everything else a
260
+ // resource node. Selecting the capsule re-routes the whole board to
261
+ // that vantage, which is what "show me what it saw" means.
262
+ const originId = id.startsWith('origin:') ? (id.slice('origin:'.length) as OriginId) : undefined
263
+ if (originId) {
264
+ setOriginId(originId)
265
+ setSelection(undefined)
266
+ return
267
+ }
268
+ setSelection(id)
269
+ }}
270
+ actions={
271
+ <ReachActions
272
+ {...props}
273
+ inClusterRunnable={traceInClusterRunnable(trace)}
274
+ inClusterTested={origins.some((o) => o.id === 'incluster' && o.mark !== 'untested')}
275
+ supportsHTTPPath={(trace.routes ?? []).some((r) => {
276
+ const p = r.inClusterRequest?.protocol
277
+ return p === 'http' || p === 'https' || (!p && !!r.inClusterRequest)
278
+ }) || !(trace.routes ?? []).some((r) => r.inClusterRequest?.protocol === 'tcp')}
279
+ />
280
+ }
281
+ />
282
+
283
+ {/* Three columns once there is room for them. The graph is the navigation
284
+ surface and the inspector the reading surface, so keeping them side by
285
+ side preserves the click-an-edge-then-read loop; below xl the inspector
286
+ wraps to its own full-width row rather than being clipped. */}
287
+ {/* The board fills the pane. Each column scrolls on its own so a long
288
+ origin list or inspector never pushes the graph out of view. */}
289
+ {/* Always three columns. Below xl the inspector used to wrap onto its own
290
+ full-width row UNDER the graph, which split one diagnosis into two
291
+ stacked, separately-scrolling regions and left neither enough room.
292
+ Narrow widths tighten the two rails instead - they are a picker and a
293
+ reading column, and both compress better than the path does. */}
294
+ {/* Wider than it was: the vantage rail used to own a column of its own, and
295
+ that width now belongs to the two panes that carry content. */}
296
+ {/* The inspector is the report; the graph rarely uses its full width.
297
+ Starved at ~336px, sentences wrapped into fragments and the next
298
+ action fell below the fold. */}
299
+ <div className="grid min-h-0 flex-1 items-stretch grid-cols-[minmax(0,1fr)_minmax(320px,384px)] xl:grid-cols-[minmax(0,1fr)_minmax(380px,29%)]">
300
+ {/* The vantages are IN the graph now, one node each, so the rail that
301
+ listed them again is gone and its width belongs to the path. */}
302
+ <div className="relative flex min-h-0 min-w-0 flex-col">
303
+ {/* The path picker lives ON the board, like the vantage capsules -
304
+ both selection axes (which path x from where) belong to the same
305
+ surface. As a full-width bar above the header it read as page
306
+ chrome outside the card, cost a row of height, and the eye skimmed
307
+ past the most important control on the page. Top-right is the
308
+ fitted graph's quiet corner. */}
309
+ {scenarios.length > 1 && (
310
+ <ScenarioPicker
311
+ scenarios={scenarios}
312
+ activeKey={scenario?.key}
313
+ onPick={(k) => {
314
+ const picked = scenarios.find((s) => s.key === k)
315
+ setSelectedRoute(picked ? routeIdentity(picked.primary) : null)
316
+ setSelection(undefined)
317
+ }}
318
+ stale={stale}
319
+ running={running}
320
+ />
321
+ )}
322
+ <ViewingStrip verdict={verdict} scenario={scenario} multiPath={multiPath} />
323
+ <ReachabilityGraph
324
+ model={model}
325
+ onAction={(a) => {
326
+ if (a.kind !== 'run-in-cluster') return
327
+ // Pin the vantage you just asked to exercise. Without this the
328
+ // origin stayed on whatever the default resolved to, and when the
329
+ // results arrived the default moved - so you were sent away from
330
+ // the very test you started.
331
+ setOriginId('incluster')
332
+ setSelection(undefined)
333
+ onRunInCluster?.()
334
+ }}
335
+ selected={selection}
336
+ hovered={hoveredNodeId}
337
+ onSelect={(id) => {
338
+ // An origin capsule selects the VANTAGE - the rest of the graph
339
+ // and the inspector re-route to it. Any other node selects the
340
+ // resource, as before.
341
+ const originId = id?.startsWith('origin:') ? (id.slice('origin:'.length) as OriginId) : undefined
342
+ if (originId) {
343
+ setOriginId(originId)
344
+ setSelection(undefined)
345
+ return
346
+ }
347
+ setSelection(id)
348
+ }}
349
+ />
350
+
351
+ </div>
352
+ <div className="min-h-0 overflow-y-auto [scrollbar-gutter:stable]">
353
+ <InspectorPanel
354
+ sidebar={sidebar}
355
+ onCTA={onCTA}
356
+ onOpen={(r) => onNavigateToResource?.(r)}
357
+ selected={selection}
358
+ onSelect={setSelection}
359
+ />
360
+ </div>
361
+ </div>
362
+
363
+ <CoverageFooter trace={trace} testedAt={testedAt} stale={stale} origins={origins} />
364
+ </div>
365
+ )
366
+ }
367
+
368
+ // ---------------------------------------------------------------- scenarios
369
+
370
+ /** The hostnames folded into one tab. Only a hover when there is more than one -
371
+ * a single host is already the tab's own label. */
372
+ function TabTooltip({ hosts, members, verdict, children }: { hosts: string[]; members: string[]; verdict?: string; children: React.ReactNode }) {
373
+ // Hostnames when they really are hostnames, otherwise the route labels
374
+ // themselves - a Service port folded with a Pod is neither "2 hostnames" nor
375
+ // meaningfully summarised by dropping one of them.
376
+ const items = hosts.length === members.length && hosts.length > 0 ? hosts : members
377
+ const noun = items === hosts ? 'hostnames' : 'paths'
378
+ // Below the widescreen cutoff the row shows only a tone dot, so the verdict
379
+ // words must live here - a dot alone answers nothing on hover.
380
+ if (items.length < 2 && !verdict) return <>{children}</>
381
+ if (items.length < 2) {
382
+ return (
383
+ <Tooltip content={verdict} wrapperClassName="flex-none cursor-help">
384
+ {children}
385
+ </Tooltip>
386
+ )
387
+ }
388
+ return (
389
+ <Tooltip
390
+ content={
391
+ <span className="flex flex-col gap-0.5">
392
+ {verdict && <span className="font-semibold">{verdict}</span>}
393
+ <span className="font-semibold">
394
+ Same result on {items.length} {noun}
395
+ </span>
396
+ {items.map((h) => (
397
+ <span key={h} className="font-mono">
398
+ {h}
399
+ </span>
400
+ ))}
401
+ {/* The fold is presentational, and users rightly ask whether the
402
+ checks are separate - say the contract out loud. */}
403
+ <span className="mt-1 max-w-[42ch] text-theme-text-tertiary">
404
+ {items === hosts
405
+ ? 'Each hostname is tested separately — its own DNS lookup, TLS handshake and request. They share one tab only while their results are identical; a hostname that behaves differently gets its own.'
406
+ : 'Each path is tested separately; they share one tab only while their results are identical.'}
407
+ </span>
408
+ </span>
409
+ }
410
+ wrapperClassName="flex-none cursor-help"
411
+ >
412
+ {children}
413
+ </Tooltip>
414
+ )
415
+ }
416
+
417
+ /** Matches the `2xl:` breakpoint that shows the verdict chips - the tooltip
418
+ * duplicates the chip unless it only speaks when the chip is hidden, so the
419
+ * two MUST key on the same width. */
420
+ function useChipsVisible(): boolean {
421
+ const [wide, setWide] = useState(() => typeof window !== 'undefined' && window.matchMedia('(min-width: 1536px)').matches)
422
+ useEffect(() => {
423
+ const mq = window.matchMedia('(min-width: 1536px)')
424
+ const on = () => setWide(mq.matches)
425
+ mq.addEventListener('change', on)
426
+ return () => mq.removeEventListener('change', on)
427
+ }, [])
428
+ return wide
429
+ }
430
+
431
+ function ScenarioPicker({
432
+ scenarios,
433
+ activeKey,
434
+ onPick,
435
+ stale,
436
+ running,
437
+ }: {
438
+ scenarios: Scenario[]
439
+ activeKey?: string
440
+ onPick: (key: string) => void
441
+ stale: boolean
442
+ running: boolean
443
+ }) {
444
+ const chipsVisible = useChipsVisible()
445
+ return (
446
+ <div
447
+ className="absolute right-3 top-2 z-10 max-w-[46%] rounded-lg border border-theme-border bg-theme-elevated shadow-theme-md"
448
+ >
449
+ <div className="px-2.5 pb-0.5 pt-1.5 text-[9px] font-bold tracking-[0.07em] text-theme-text-tertiary">PATH</div>
450
+ {/* Rows, not tabs: a corner column scales to N paths - capped at ~5 rows
451
+ with a half-row peek, then scrolls, so many-port Services can't eat
452
+ the graph. Tone dot + mono label; the verdict CHIP only gets a column
453
+ on widescreen - below that the dot carries the tone and the words
454
+ live on the hover, because eight long chips drowned the labels. */}
455
+ <div className="flex max-h-[126px] flex-col overflow-y-auto pb-1">
456
+ {scenarios.map((s) => {
457
+ const tone = routeTone(s.primary, { stale, running })
458
+ const active = s.key === activeKey
459
+ const chip = routeChip(s.primary, { stale, running })
460
+ return (
461
+ <TabTooltip hosts={s.hosts} members={s.members} verdict={chipsVisible ? undefined : chip} key={s.key}>
462
+ <button
463
+ type="button"
464
+ onClick={() => onPick(s.key)}
465
+ aria-pressed={active}
466
+ className={`mx-1 flex cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 text-left transition-colors duration-150 ${
467
+ active ? 'selection selection-ring' : 'hover:bg-theme-hover'
468
+ }`}
469
+ >
470
+ <span className="inline-block shrink-0 rounded-full" style={{ width: 7, height: 7, background: SEV_COLOR[tone] }} />
471
+ <span className="min-w-0 max-w-[200px] truncate font-mono text-[11.5px] font-semibold text-theme-text-primary">{s.label}</span>
472
+ <span className={`badge-sm hidden shrink-0 whitespace-nowrap 2xl:inline-flex ${SEV_BADGE[tone]}`}>{chip}</span>
473
+ </button>
474
+ </TabTooltip>
475
+ )
476
+ })}
477
+ </div>
478
+ </div>
479
+ )
480
+ }
481
+
482
+ // ------------------------------------------------------------------ verdict
483
+
484
+ const LAYER_ORDER = ['dns', 'tcp', 'tls', 'http']
485
+
486
+ /**
487
+ * One row in the header's problem list.
488
+ *
489
+ * The list is deliberately VANTAGE-INVARIANT: everything in it is true no
490
+ * matter which capsule is selected, so a fault can never hide behind a click.
491
+ * Faults read off cluster state (a dead entry, a promoted finding) say what is
492
+ * wrong; a pointer row says another vantage saw something and takes you there.
493
+ * What a vantage saw stays on the board and in the inspector.
494
+ */
495
+ interface ProblemRow {
496
+ key: string
497
+ scope: string
498
+ subject: string
499
+ text: string
500
+ detail?: string
501
+ /** Graph node to ring on hover and select on click - a resource node, or an
502
+ * origin capsule (`origin:<id>`) for a pointer row. */
503
+ nodeId: string
504
+ severity: 'critical' | 'warning'
505
+ }
506
+
507
+ const SEV_RANK: Record<ProblemRow['severity'], number> = { critical: 0, warning: 1 }
508
+
509
+ /** Merges every vantage-invariant problem into ONE ranked list: the promoted
510
+ * diagnosis, the declared entries that cannot carry traffic, and a pointer to
511
+ * anything only another vantage saw. Two separate bands read as two separate
512
+ * problems when they were often the same one. */
513
+ export function problemRows(
514
+ trace: Trace,
515
+ origins: Origin[],
516
+ knownNodeIds: ReadonlySet<string>,
517
+ ): ProblemRow[] {
518
+ const rows: ProblemRow[] = []
519
+ const seen = new Set<string>()
520
+ const push = (r: ProblemRow) => {
521
+ const k = `${r.subject}\u0000${r.text}`
522
+ if (seen.has(k)) return
523
+ // Never address a node the graph does not draw: a Pod culprit resolves to
524
+ // the aggregate Pods node it lives in, and anything still unknown drops its
525
+ // pointer rather than offering a hover that rings nothing and a click that
526
+ // selects a section no inspector can resolve.
527
+ if (r.nodeId && !knownNodeIds.has(r.nodeId)) {
528
+ const pods = [...knownNodeIds].find((id) => id.startsWith('n:Pods/'))
529
+ if (r.nodeId.startsWith('n:Pod/') && pods) r = { ...r, nodeId: pods }
530
+ else r = { ...r, nodeId: '' }
531
+ }
532
+ seen.add(k)
533
+ // The caller's key names the row FAMILY and is not unique on its own - one
534
+ // entry can carry several findings, and one origin several failed routes.
535
+ // `k` is what `seen` just proved unique, so pairing them is what makes the
536
+ // React key safe rather than each caller having to get it right.
537
+ rows.push({ ...r, key: `${r.key}${k}` })
538
+ }
539
+ const refNode = (r?: ResourceRef) => (r ? `n:${r.kind}/${r.namespace ?? ''}/${r.name || 'pods'}` : '')
540
+
541
+ // The promoted fault. A COVERAGE-class diagnosis is not a fault - it restates
542
+ // the headline, which is generated from the same coverage state.
543
+ const d = trace.diagnosis
544
+ if (d?.summary && d.class !== 'coverage') {
545
+ const ref = d.culpritResource
546
+ push({
547
+ key: 'diagnosis',
548
+ scope: ref ? ref.kind.toUpperCase() : 'PATH',
549
+ subject: ref ? `${ref.kind} ${ref.name}` : trace.subject.name,
550
+ text: noteHeadline(d.summary),
551
+ detail: [d.summary, d.nextAction].filter(Boolean).join(' — '),
552
+ nodeId: refNode(ref) || refNode(trace.subject),
553
+ // The detector's own severity. Assuming critical turned every warning-tier
554
+ // prediction into a red it had not earned.
555
+ severity: d.severity === 'critical' ? 'critical' : 'warning',
556
+ })
557
+ }
558
+
559
+ for (const p of trace.entryProblems ?? []) {
560
+ push({
561
+ key: `entry-${p.resource.kind}-${p.resource.name}`,
562
+ scope: 'ENTRY',
563
+ subject: `${p.resource.kind} ${p.resource.name}`,
564
+ text: noteHeadline(p.summary),
565
+ detail: [p.summary, p.detail, p.action].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' — '),
566
+ nodeId: refNode(p.resource),
567
+ severity: p.severity === 'critical' ? 'critical' : 'warning',
568
+ })
569
+ }
570
+
571
+ // A fault some vantage hit, on ANY declared path. Deliberately independent of
572
+ // what is selected: the header is the resource, so a row that appears or
573
+ // vanishes as you click capsules would hide exactly the fault the reader has
574
+ // not thought to look for.
575
+ for (const o of origins) {
576
+ if (o.unsupported) continue
577
+ for (const r of trace.routes ?? []) {
578
+ const own = routeForOrigin(r, o.id, trace.runVantage)
579
+ if (!own || (own.outcome !== 'unreachable' && own.outcome !== 'server-error')) continue
580
+ push({
581
+ key: `vantage-${o.id}-${r.route}`,
582
+ scope: 'SEEN FROM',
583
+ subject: o.name,
584
+ text: own.evidence ? noteHeadline(own.evidence) : `could not get through to ${r.target || r.route}`,
585
+ detail: [own.evidence, `path: ${r.route}`].filter(Boolean).join(' — '),
586
+ nodeId: `origin:${o.id}`,
587
+ severity: 'warning',
588
+ })
589
+ }
590
+ }
591
+
592
+ return rows.sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity])
593
+ }
594
+
595
+ /** The header's problem list. Hover names the fault in full and rings it on the
596
+ * board; click focuses it there. */
597
+ /**
598
+ * What you are currently looking at, stated ON the board that changes. Keeping
599
+ * it here rather than in the header is the whole redesign: the header is the
600
+ * resource and never moves, this strip is the selection and always does, and
601
+ * the spatial split says so without anyone having to read a label.
602
+ */
603
+ function ViewingStrip({ verdict, scenario, multiPath }: { verdict: ReturnType<typeof buildVerdict>; scenario?: Scenario; multiPath: boolean }) {
604
+ const path = multiPath ? scenario?.primary.target || scenario?.label : undefined
605
+ return (
606
+ <div className="pointer-events-none absolute left-3 top-2 z-10 flex max-w-[50%] flex-wrap items-center gap-x-2 gap-y-1">
607
+ <span className="text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">VIEWING</span>
608
+ {path && <span className="truncate font-mono text-[11px] font-semibold text-theme-text-secondary">{path}</span>}
609
+ {path && <span className="text-theme-border">·</span>}
610
+ <span className="truncate font-mono text-[11px] font-semibold text-theme-text-secondary">{verdict.originName ?? 'this resource'}</span>
611
+ <span className={`badge-sm whitespace-nowrap ${SEV_BADGE[verdict.tone]}`}>{verdict.chipText}</span>
612
+ </div>
613
+ )
614
+ }
615
+
616
+ function ProblemList({
617
+ rows,
618
+ onHover,
619
+ onSelect,
620
+ }: {
621
+ rows: ProblemRow[]
622
+ onHover?: (nodeId?: string) => void
623
+ onSelect?: (nodeId: string) => void
624
+ }) {
625
+ if (rows.length === 0) return null
626
+ const shown = rows.slice(0, 3)
627
+ const rest = rows.length - shown.length
628
+ return (
629
+ <div className="mt-1.5 flex flex-col gap-1">
630
+ {shown.map((p) => (
631
+ <Tooltip key={p.key} content={p.detail || p.text} wrapperClassName="block">
632
+ <button
633
+ type="button"
634
+ onMouseEnter={() => onHover?.(p.nodeId)}
635
+ onMouseLeave={() => onHover?.(undefined)}
636
+ onFocus={() => onHover?.(p.nodeId)}
637
+ onBlur={() => onHover?.(undefined)}
638
+ onClick={() => onSelect?.(p.nodeId)}
639
+ aria-label={`${p.subject} — ${p.text}. Show it on the path.`}
640
+ className="flex w-full max-w-[92ch] cursor-pointer items-start gap-1.5 rounded-md px-2 py-1.5 text-left text-xs leading-relaxed text-pretty transition-colors hover:bg-theme-hover"
641
+ style={{
642
+ border: `1px solid var(${p.severity === 'critical' ? '--color-error' : '--color-warning'})`,
643
+ background: `color-mix(in srgb, var(${p.severity === 'critical' ? '--color-error' : '--color-warning'}) 10%, transparent)`,
644
+ }}
645
+ >
646
+ <span className="shrink-0" style={{ color: `var(${p.severity === 'critical' ? '--color-error-dark' : '--color-warning-dark'})` }}>▲</span>
647
+ <span className="min-w-0 flex-1 text-theme-text-primary">
648
+ <span className="text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">{p.scope} </span>
649
+ <span className="font-mono font-semibold">{p.subject}</span> — <span className="line-clamp-2">{p.text}</span>
650
+ </span>
651
+ </button>
652
+ </Tooltip>
653
+ ))}
654
+ {rest > 0 && <div className="text-[11px] text-theme-text-tertiary">+{rest} more problem{rest > 1 ? 's' : ''} — see the path below</div>}
655
+ </div>
656
+ )
657
+ }
658
+
659
+ /**
660
+ * The header. Everything in it describes THE RESOURCE and never changes when a
661
+ * vantage or path is selected - which is the whole point: the reader can learn
662
+ * once that this block is static and the board below is what moves. Anything
663
+ * scoped to the current selection lives on the board (the viewing strip and the
664
+ * capsules) and in the inspector.
665
+ */
666
+ function VerdictBand({
667
+ verdict,
668
+ trace,
669
+ actions,
670
+ runNonce,
671
+ problems,
672
+ onEntryHover,
673
+ onEntrySelect,
674
+ }: {
675
+ verdict: ReturnType<typeof buildVerdict>
676
+ trace: Trace
677
+ actions: React.ReactNode
678
+ runNonce?: number
679
+ problems: ProblemRow[]
680
+ onEntryHover?: (nodeId?: string) => void
681
+ onEntrySelect?: (nodeId: string) => void
682
+ }) {
683
+ // The check volume lives HERE, not only in the footer ledger: the band is
684
+ // the one line everyone reads, and a verdict with no visible work behind it
685
+ // asks to be taken on faith. Stated as work done, never as success - the
686
+ // count includes failed dials.
687
+ const stats = probeCheckStats(trace)
688
+ const layerBreakdown = LAYER_ORDER.filter((l) => stats.byLayer[l])
689
+ .map((l) => `${l.toUpperCase()} ${stats.byLayer[l]}`)
690
+ .join(' · ')
691
+ return (
692
+ <div className="flex items-start gap-3 border-b border-theme-border px-5 py-3">
693
+ <div className="min-w-0 flex-1">
694
+ {/* The headline covers the whole resource while the badge follows the
695
+ selected path + vantage. On one line the two read as a single
696
+ contradictory sentence ("Reached via API server … not tested … from
697
+ In-cluster probe"). Each scope gets its own labelled line; which of
698
+ the two should LEAD is a product call, but they must never blur. */}
699
+ {verdict.scopeLabel && (
700
+ <div className="mb-0.5 text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">{verdict.scopeLabel}</div>
701
+ )}
702
+ <div className="flex flex-wrap items-baseline gap-2">
703
+ <span className="text-[14.5px] font-semibold text-theme-text-primary">{verdict.title}</span>
704
+ <JustTestedNote nonce={runNonce} />
705
+ </div>
706
+
707
+ {stats.checks > 0 && (
708
+ <Tooltip
709
+ content={`${layerBreakdown}${stats.vantages > 1 ? ` — from ${stats.vantages} vantages` : ''}. Every check is a real dial; skipped ones are listed with their reasons, never counted. Each check waits up to ~1s, inside a 3s budget per run — a slow backend can time out and read as not tested.`}
710
+ wrapperClassName="mt-1 inline-flex cursor-help"
711
+ >
712
+ <span className="inline-flex items-center gap-1.5 font-mono text-[11px] text-theme-text-secondary">
713
+ {/* Accent, not green: checks include failures - this states WORK
714
+ done, never success. Emphasis is weight, not a new color. */}
715
+ <Activity className="h-3 w-3 flex-none" style={{ color: 'var(--accent-text)' }} />
716
+ <span>
717
+ <span className="font-semibold text-theme-text-primary">{stats.checks}</span> live check{stats.checks === 1 ? '' : 's'}
718
+ {stats.vantages > 1 ? ` from ${stats.vantages} vantages` : ''}
719
+ </span>
720
+ </span>
721
+ </Tooltip>
722
+ )}
723
+
724
+ {verdict.body && <div className="mt-1 max-w-[76ch] text-xs leading-relaxed text-theme-text-secondary text-pretty">{verdict.body}</div>}
725
+ <ProblemList rows={problems} onHover={onEntryHover} onSelect={onEntrySelect} />
726
+ </div>
727
+ <div className="flex flex-none gap-2">{actions}</div>
728
+ </div>
729
+ )
730
+ }
731
+
732
+ // ------------------------------------------------------------------ origins
733
+
734
+ /** The vantages Radar cannot run, stated rather than offered. These are real
735
+ * holes in the evidence, so they must stay on screen - but as ONE limit on the
736
+ * claim, not a list. A per-origin line charged the rail a row for each thing
737
+ * Radar can't do, teaching the reader its limitations before its findings. */
738
+ function UntestableNote({ origins }: { origins: Origin[] }) {
739
+ const out = origins.filter((o) => o.unsupported)
740
+ if (out.length === 0) return null
741
+ return (
742
+ <>
743
+ <span className="text-theme-border">|</span>
744
+ <Tooltip
745
+ content={
746
+ <span className="flex flex-col gap-1">
747
+ {out.map((o) => (
748
+ <span key={o.id}>
749
+ <span className="font-semibold">{o.name}</span> — {o.unavailable}
750
+ </span>
751
+ ))}
752
+ </span>
753
+ }
754
+ wrapperClassName="cursor-help"
755
+ >
756
+ <span className="text-theme-text-tertiary">
757
+ {out.map((o) => o.glyph).join(' ')} {untestableSummary(out)}
758
+ </span>
759
+ </Tooltip>
760
+ </>
761
+ )
762
+ }
763
+
764
+ /** One phrase for every vantage Radar can't run, named by what it would have
765
+ * proven rather than by Radar's internal origin names. */
766
+ function untestableSummary(out: Origin[]): string {
767
+ const phrase: Partial<Record<OriginId, string>> = {
768
+ caller: 'as your application',
769
+ external: 'from the public internet',
770
+ 'radar-incluster': 'as Radar inside the cluster',
771
+ }
772
+ const parts = out.map((o) => phrase[o.id] ?? o.name.toLowerCase())
773
+ if (parts.length === 1) return `Not tested ${parts[0]}.`
774
+ return `Not tested ${parts.slice(0, -1).join(', ')} or ${parts[parts.length - 1]}.`
775
+ }
776
+
777
+ // ---------------------------------------------------------------- inspector
778
+
779
+ /**
780
+ * The panel's one disclosure, matching drawer-components' Section: a rotating
781
+ * ChevronRight and a grid-template-rows transition. Hand-rolled "›"/"⌄" toggles
782
+ * neither looked like the rest of Radar nor animated, so a section appearing was
783
+ * an abrupt jump with no affordance saying it could be opened at all.
784
+ */
785
+ function Disclosure({
786
+ open,
787
+ onToggle,
788
+ label,
789
+ count,
790
+ children,
791
+ }: {
792
+ open: boolean
793
+ onToggle: () => void
794
+ label: string
795
+ count?: number
796
+ children: React.ReactNode
797
+ }) {
798
+ return (
799
+ <div>
800
+ <button
801
+ type="button"
802
+ onClick={onToggle}
803
+ aria-expanded={open}
804
+ className="flex w-full items-center gap-1.5 text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
805
+ >
806
+ <ChevronRight className={`h-3 w-3 flex-none transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
807
+ <span>{label}</span>
808
+ {count !== undefined && <span className="flex-1 text-right font-normal tracking-normal">{count}</span>}
809
+ </button>
810
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
811
+ <div className="overflow-hidden">{children}</div>
812
+ </div>
813
+ </div>
814
+ )
815
+ }
816
+
817
+ function Caveats({ items, defaultOpen = false }: { items: string[]; defaultOpen?: boolean }) {
818
+ const [open, setOpen] = useState(defaultOpen)
819
+ if (items.length === 0) return null
820
+ // Collapsed by default. These are limits on the CLAIM, and they are the same
821
+ // sentences for every result of a given kind - so at full height they pushed
822
+ // the evidence and the path below the fold on every single view to say
823
+ // something invariant. Quieter than a fault, and one click from being read.
824
+ return (
825
+ <div>
826
+ <Disclosure open={open} onToggle={() => setOpen((v) => !v)} label="WHAT THIS DOESN’T PROVE" count={items.length}>
827
+ {items.map((n, i) => (
828
+ <div key={i} className="mt-1 text-[10.5px] leading-snug text-theme-text-tertiary">
829
+ {n}
830
+ </div>
831
+ ))}
832
+ </Disclosure>
833
+ </div>
834
+ )
835
+ }
836
+
837
+ function InspectorPanel({
838
+ sidebar,
839
+ onCTA,
840
+ onOpen,
841
+ selected,
842
+ onSelect,
843
+ }: {
844
+ sidebar: Sidebar
845
+ onCTA: (c: InspectorCTA) => void
846
+ onOpen: (r: ResourceRef) => void
847
+ selected?: string
848
+ onSelect: (id: string) => void
849
+ }) {
850
+ const [scopeOpen, setScopeOpen] = useState(false)
851
+ const { path } = sidebar
852
+ return (
853
+ <div className="flex h-full flex-col gap-3 bg-theme-surface px-3.5 py-3 border-l border-theme-border">
854
+ {/* The diagnosis is ALWAYS here. Whether traffic got through must never
855
+ require a click - it is the question the tab exists to answer. */}
856
+ <div>
857
+ <div className="flex items-center gap-2">
858
+ <span className="text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">THIS PATH</span>
859
+ <div className="flex-1" />
860
+ <span className={`badge-sm whitespace-nowrap ${SEV_BADGE[path.chipTone]}`}>{path.chipText}</span>
861
+ </div>
862
+ <div className="mt-1.5 font-mono text-[12.5px] font-semibold leading-snug text-theme-text-primary">{path.title}</div>
863
+ {path.request && (
864
+ <div className="mt-1 flex items-baseline gap-1.5">
865
+ <span className="flex-none text-[9px] font-bold tracking-[0.04em] text-theme-text-tertiary">ASKED FOR</span>
866
+ <span className="min-w-0 flex-1 break-words font-mono text-[10.5px] text-theme-text-secondary">{path.request}</span>
867
+ </div>
868
+ )}
869
+ <div className="mt-1 text-[11.5px] leading-relaxed text-theme-text-secondary text-pretty">{path.body}</div>
870
+ </div>
871
+
872
+ {path.scope.length > 0 && (
873
+ <Disclosure open={scopeOpen} onToggle={() => setScopeOpen((v) => !v)} label="TEST DETAILS" count={path.scope.length}>
874
+ {path.scope.map((p, i) => (
875
+ <div key={i} className="mt-0.5 flex gap-2 border-b border-theme-border-subtle py-0.5">
876
+ <span className="w-[86px] flex-none pt-0.5 text-[9px] font-bold tracking-[0.04em] text-theme-text-tertiary">{p.k}</span>
877
+ <span className="flex-1 break-words font-mono text-[10px] leading-snug text-theme-text-secondary">{p.v}</span>
878
+ </div>
879
+ ))}
880
+ </Disclosure>
881
+ )}
882
+
883
+ {/* The action sits with the sentence that explains why it is offered. As
884
+ its own block at the very bottom it repeated that sentence, and put
885
+ the button a scroll away from the reason for it. */}
886
+ {(path.next.body || path.next.ctas.length > 0) && (
887
+ <div className={path.next.header ? 'rounded-md px-2.5 py-2' : ''} style={path.next.header ? { border: '1px solid var(--accent)', background: 'var(--accent-muted)' } : undefined}>
888
+ {path.next.header && (
889
+ <div className="mb-1 text-[9.5px] font-bold tracking-[0.05em]" style={{ color: 'var(--accent-text)' }}>
890
+ {path.next.header}
891
+ </div>
892
+ )}
893
+ {path.next.body && <div className="text-[11.5px] leading-snug text-theme-text-secondary text-pretty">{path.next.body}</div>}
894
+ {path.next.blocked && <div className="mt-1.5 text-[10.5px] leading-snug text-theme-text-tertiary">{path.next.blocked}</div>}
895
+ {path.next.ctas.length > 0 && (
896
+ <div className={`flex flex-wrap gap-1.5 ${path.next.body || path.next.header ? 'mt-2' : 'mt-1'}`}>
897
+ {path.next.ctas.map((c, i) => (
898
+ <Tooltip key={i} content={c.disabledReason ?? ''} wrapperClassName="cursor-help">
899
+ <button
900
+ type="button"
901
+ onClick={() => onCTA(c)}
902
+ disabled={!!c.disabledReason}
903
+ className={
904
+ c.primary && !c.disabledReason
905
+ ? 'btn-brand cursor-pointer whitespace-nowrap rounded-md px-2.5 py-1.5 text-[11px] font-semibold'
906
+ : 'cursor-pointer whitespace-nowrap rounded-md border border-theme-border bg-theme-base px-2.5 py-1.5 text-[11px] font-semibold text-theme-text-secondary disabled:cursor-not-allowed disabled:opacity-60'
907
+ }
908
+ >
909
+ {c.text}
910
+ </button>
911
+ </Tooltip>
912
+ ))}
913
+ </div>
914
+ )}
915
+ </div>
916
+ )}
917
+
918
+ {path.evidence.length > 0 && (
919
+ <div>
920
+ {/* Same count idiom as TEST DETAILS: the number of distinct
921
+ observations is itself a trust signal, stated in chrome-weight. */}
922
+ <div className="mb-1 flex items-center text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">
923
+ <span>WHAT WE SAW</span>
924
+ <span className="flex-1 text-right font-normal tracking-normal">{path.evidence.length}</span>
925
+ </div>
926
+ {path.evidence.map((e, i) => (
927
+ <div key={i} className="mb-1 flex items-baseline gap-1.5">
928
+ <MarkGlyph mark={e.mark} />
929
+ <span className="text-[11px] leading-snug text-theme-text-secondary">{e.text}</span>
930
+ </div>
931
+ ))}
932
+ </div>
933
+ )}
934
+
935
+ {/* The whole path, in order. Every hop is here whether or not it was
936
+ clicked; the break opens by default because it is the answer, and the
937
+ rest stay one line so the report never becomes a wall. */}
938
+ {sidebar.hops.length > 0 && (
939
+ <div className="border-t border-theme-border pt-3">
940
+ <div className="text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">ALONG THIS PATH</div>
941
+ {sidebar.hops.map((h) => (
942
+ <HopSection key={h.id} hop={h} selected={selected === h.id} onSelect={onSelect} onOpen={onOpen} />
943
+ ))}
944
+ </div>
945
+ )}
946
+ {/* Configured-but-bypassed resources: readable for orientation, outside
947
+ the journey - listing them under ALONG THIS PATH claimed a traversal
948
+ the selected vantage explicitly does not make. */}
949
+ {sidebar.context && (
950
+ <div className="border-t border-theme-border pt-3">
951
+ <div className="text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">{sidebar.context.label}</div>
952
+ {sidebar.context.hops.map((h) => (
953
+ <HopSection key={h.id} hop={h} selected={selected === h.id} onSelect={onSelect} onOpen={onOpen} />
954
+ ))}
955
+ </div>
956
+ )}
957
+ <Caveats items={path.notProve} />
958
+
959
+ </div>
960
+ )
961
+ }
962
+
963
+ const HOP_STATE_NOTE: Record<HopReport['state'], string> = {
964
+ break: 'the request stopped here',
965
+ before: 'reached',
966
+ after: 'never tried — something earlier stopped',
967
+ plain: '',
968
+ }
969
+
970
+ function HopSection({
971
+ hop,
972
+ selected,
973
+ onSelect,
974
+ onOpen,
975
+ }: {
976
+ hop: HopReport
977
+ selected: boolean
978
+ onSelect: (id: string) => void
979
+ onOpen: (r: ResourceRef) => void
980
+ }) {
981
+ const [openOverride, setOpenOverride] = useState<boolean | null>(null)
982
+ const open = openOverride ?? hop.expanded
983
+ const ref = useRef<HTMLDivElement>(null)
984
+ // A click on the graph scrolls its hop into view instead of replacing the
985
+ // panel - the gesture still means "I care about this one", it just no longer
986
+ // costs the reader the rest of the story.
987
+ //
988
+ // Selecting also CLEARS any manual collapse. A hop the reader had folded shut
989
+ // kept that override forever, so clicking its node in the graph highlighted a
990
+ // closed section and appeared to do nothing at all - asking to look at
991
+ // something has to be able to open it.
992
+ useEffect(() => {
993
+ if (!selected) return
994
+ setOpenOverride(null)
995
+ ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
996
+ }, [selected])
997
+ // Becoming the BREAK also clears a manual collapse: the break is the answer
998
+ // this panel exists to show, and a collapse made while reading a different
999
+ // scenario must not keep the answer shut when the reader switches to one
1000
+ // where this hop is where the request stopped.
1001
+ useEffect(() => {
1002
+ if (hop.state === 'break') setOpenOverride(null)
1003
+ }, [hop.state])
1004
+ return (
1005
+ <div
1006
+ ref={ref}
1007
+ // A flat tint plus a hairline ring, not a heavy left bar: the bar read as a
1008
+ // scrollbar against the panel edge and its background ran under the border.
1009
+ // `.selection` / `.selection-ring` are the repo's own tokens for exactly
1010
+ // this, so selection here looks like selection everywhere else.
1011
+ className={`mt-2 rounded-md px-2 py-1 transition-colors duration-200 ${selected ? 'selection selection-ring' : ''}`}
1012
+ >
1013
+ <button
1014
+ type="button"
1015
+ onClick={() => {
1016
+ setOpenOverride(!open)
1017
+ onSelect(hop.id)
1018
+ }}
1019
+ aria-expanded={open}
1020
+ className="flex w-full items-center gap-1.5 text-left"
1021
+ >
1022
+ <ChevronRight className={`h-3 w-3 flex-none text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
1023
+ <span className="text-[9px] font-bold tracking-[0.04em] text-theme-text-tertiary">{hop.kind}</span>
1024
+ <span className="min-w-0 flex-1 truncate font-mono text-[11.5px] font-semibold text-theme-text-primary">{hop.name}</span>
1025
+ <span className={`badge-sm whitespace-nowrap ${SEV_BADGE[hop.chipTone]}`}>{hop.chipText || HOP_STATE_NOTE[hop.state]}</span>
1026
+ </button>
1027
+ {!open && (hop.state !== 'plain' || hop.parallelCount) && (
1028
+ <div className="pl-[18px] text-[10px] leading-snug text-theme-text-tertiary">
1029
+ {[hop.parallelCount ? `one of ${hop.parallelCount} parallel entry points` : '', HOP_STATE_NOTE[hop.state]]
1030
+ .filter(Boolean)
1031
+ .join(' — ')}
1032
+ </div>
1033
+ )}
1034
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
1035
+ <div className="overflow-hidden">
1036
+ <div className="pb-1 pl-[18px]">
1037
+ {hop.openRef?.name && (
1038
+ <button type="button" onClick={() => onOpen(hop.openRef!)} className="text-[11px] text-accent-text hover:underline">
1039
+ Open ↗
1040
+ </button>
1041
+ )}
1042
+ <div className="mt-1 text-[11.5px] leading-relaxed text-theme-text-secondary text-pretty">{hop.body}</div>
1043
+ {hop.facts.map((f, i) => (
1044
+ <div key={i} className="mt-0.5 flex gap-2 border-b border-theme-border-subtle py-0.5">
1045
+ <span className="w-[86px] flex-none pt-0.5 text-[9px] font-bold tracking-[0.04em] text-theme-text-tertiary">{f.k}</span>
1046
+ <span className="flex-1 break-words font-mono text-[10px] leading-snug text-theme-text-secondary">{f.v}</span>
1047
+ </div>
1048
+ ))}
1049
+ {hop.anomalies && hop.anomalies.length > 0 && (
1050
+ <div className="mt-2">
1051
+ {hop.anomalies.map((a, i) => (
1052
+ <div key={i} className="mb-1 flex items-baseline gap-1.5">
1053
+ <span style={glyphStyle(a.mark)}>{markStyle(a.mark).glyph}</span>
1054
+ <span className="text-[10.5px] leading-snug text-theme-text-secondary">{a.text}</span>
1055
+ </div>
1056
+ ))}
1057
+ </div>
1058
+ )}
1059
+ {hop.rows && hop.rows.length > 0 && (
1060
+ <div className="mt-2 flex flex-col gap-0.5">
1061
+ {hop.rows.map((r) => (
1062
+ <Tooltip
1063
+ key={r.name}
1064
+ content={
1065
+ <>
1066
+ <span className="font-mono font-semibold">{r.name}</span>
1067
+ <span className="text-theme-text-tertiary"> — {r.detail} · {markHelp(r.mark)}</span>
1068
+ </>
1069
+ }
1070
+ wrapperClassName="w-full cursor-help"
1071
+ >
1072
+ <div className="flex w-full items-baseline gap-1.5">
1073
+ <span style={glyphStyle(r.mark)}>{markStyle(r.mark).glyph}</span>
1074
+ <span className="min-w-0 flex-1 truncate font-mono text-[10px] text-theme-text-secondary">{r.name}</span>
1075
+ <span className="min-w-0 flex-1 truncate text-right text-[9.5px] text-theme-text-tertiary">{r.detail}</span>
1076
+ </div>
1077
+ </Tooltip>
1078
+ ))}
1079
+ {!!hop.moreRows && <div className="text-[9.5px] text-theme-text-tertiary">+{hop.moreRows} more not shown</div>}
1080
+ </div>
1081
+ )}
1082
+ <div className="mt-2">
1083
+ <Caveats items={hop.notProve} />
1084
+ </div>
1085
+ </div>
1086
+ </div>
1087
+ </div>
1088
+ </div>
1089
+ )
1090
+ }
1091
+
1092
+ // ------------------------------------------------------------------- footer
1093
+
1094
+ function CoverageFooter({
1095
+ trace,
1096
+ testedAt,
1097
+ stale,
1098
+ origins,
1099
+ }: {
1100
+ trace: Trace
1101
+ testedAt?: Date
1102
+ stale: boolean
1103
+ origins: Origin[]
1104
+ }) {
1105
+ const c = trace.coverage
1106
+ const skips = trace.notTested ?? []
1107
+ // Only skips that actually cost coverage belong in the headline count - a
1108
+ // benign skip loses nothing and padding the number would overstate the gap.
1109
+ const realGaps = skips.filter((s) => s.reasonClass !== 'benign')
1110
+ // "skipped N · M not tested" read as one thing counted twice. They are
1111
+ // different levels: attempts made, versus paths with no evidence at all.
1112
+ const attempts = c && c.tested > 0 ? `${c.passed} got through · ${c.failed} failed${c.skipped ? ` · ${c.skipped} couldn’t be tried` : ''}` : ''
1113
+ // Derived breaks were never dialled, so they are counted apart from attempts -
1114
+ // folding them in reported requests that failed when none were sent.
1115
+ const derived = c?.derived ? `${c.derived} broken without testing` : ''
1116
+ const gaps = realGaps.length ? `${realGaps.length} path${realGaps.length === 1 ? '' : 's'} with no evidence` : ''
1117
+ // The KIND of proof must stay visible after the run, not only on the consent
1118
+ // dialog: a TCP-only pass proves a socket opened, never that the application
1119
+ // protocol works, and "got through" alone let the reader assume more.
1120
+ const mode = completedRequestMode(trace)
1121
+ const proofKind =
1122
+ mode === 'tcp'
1123
+ ? 'TCP connections only — application protocol not checked'
1124
+ : mode === 'mixed'
1125
+ ? 'some ports TCP-only — application protocol not checked there'
1126
+ : ''
1127
+ const coverageText =
1128
+ (c ? [attempts, derived, gaps].filter(Boolean).join(' · ') || 'nothing tested yet' : 'nothing tested yet') +
1129
+ (proofKind ? ` · ${proofKind}` : '')
1130
+ // The live-check volume moved up into the verdict band - the footer stays
1131
+ // the coverage ledger (routes, gaps, when), not the trust headline.
1132
+ return (
1133
+ <div className="flex flex-wrap items-center gap-2.5 border-t border-theme-border bg-theme-surface px-5 py-2 text-[11px] text-theme-text-tertiary">
1134
+ <span className="text-[9.5px] font-bold tracking-[0.07em]">WHAT WAS TESTED</span>
1135
+ <span className="font-mono text-theme-text-secondary">{coverageText}</span>
1136
+ {/* The vantages Radar can never run are a COVERAGE fact, so they belong on
1137
+ the coverage line - as their own band they were a third row of chrome
1138
+ restating what this one is for. */}
1139
+ <UntestableNote origins={origins} />
1140
+ <span className="text-theme-border">|</span>
1141
+ <span className="text-[9.5px] font-bold tracking-[0.07em]">WHEN</span>
1142
+ <span className="font-mono" style={{ color: stale ? 'var(--color-warning-dark)' : 'var(--text-secondary)' }}>
1143
+ {stale
1144
+ ? 'cluster state changed since this test — results excluded from the verdict'
1145
+ : testedAt
1146
+ ? `observed ${testedAt.toLocaleTimeString()}`
1147
+ : 'no test has been run'}
1148
+ </span>
1149
+ </div>
1150
+ )
1151
+ }
1152
+
1153
+ // -------------------------------------------------------------------- dev
1154
+
1155
+ function DevStateBar({ state, onPick }: { state: DevState | null; onPick: (s: DevState | null) => void }) {
1156
+ const chip = (active: boolean) =>
1157
+ `cursor-pointer whitespace-nowrap rounded-full border px-2.5 py-0.5 text-[11px] font-semibold ${
1158
+ active ? 'border-accent bg-accent-muted text-accent-text' : 'border-theme-border bg-theme-surface text-theme-text-secondary'
1159
+ }`
1160
+ return (
1161
+ <div className="flex flex-wrap items-center gap-1.5">
1162
+ <span className="text-[10px] font-bold tracking-[0.08em] text-theme-text-tertiary">DEV STATE</span>
1163
+ <button type="button" onClick={() => onPick(null)} className={chip(state === null)}>
1164
+ live
1165
+ </button>
1166
+ {DEV_STATES.map((s) => (
1167
+ <button key={s.id} type="button" onClick={() => onPick(s.id)} className={chip(state === s.id)}>
1168
+ {s.label}
1169
+ </button>
1170
+ ))}
1171
+ </div>
1172
+ )
1173
+ }