@skyhook-io/k8s-ui 1.10.3 → 1.10.5

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 (57) 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/topology/K8sResourceNode.tsx +5 -0
  5. package/src/components/topology/TopologyGraph.tsx +85 -5
  6. package/src/components/topology/layout.worker.ts +2 -1
  7. package/src/components/trace/ReachabilityGraph.tsx +503 -0
  8. package/src/components/trace/ReachabilityView.tsx +972 -0
  9. package/src/components/trace/TracePanel.test.ts +172 -0
  10. package/src/components/trace/TracePanel.tsx +558 -0
  11. package/src/components/trace/TraceSummary.test.ts +213 -0
  12. package/src/components/trace/TraceSummary.tsx +168 -0
  13. package/src/components/trace/inClusterSummary.test.ts +98 -0
  14. package/src/components/trace/inClusterSummary.ts +81 -0
  15. package/src/components/trace/index.ts +19 -0
  16. package/src/components/trace/podReach.test.ts +71 -0
  17. package/src/components/trace/podReach.ts +37 -0
  18. package/src/components/trace/probe-display.test.ts +107 -0
  19. package/src/components/trace/probe-display.ts +37 -0
  20. package/src/components/trace/reachFixtures.ts +261 -0
  21. package/src/components/trace/reachGraphModel.test.ts +1407 -0
  22. package/src/components/trace/reachGraphModel.ts +1521 -0
  23. package/src/components/trace/reachInspector.test.ts +669 -0
  24. package/src/components/trace/reachInspector.ts +667 -0
  25. package/src/components/trace/reachMarks.test.ts +667 -0
  26. package/src/components/trace/reachMarks.ts +579 -0
  27. package/src/components/trace/reachOrigins.test.ts +392 -0
  28. package/src/components/trace/reachOrigins.ts +437 -0
  29. package/src/components/trace/reachVerdict.test.ts +537 -0
  30. package/src/components/trace/reachVerdict.ts +601 -0
  31. package/src/components/trace/traceFingerprint.test.ts +214 -0
  32. package/src/components/trace/traceFingerprint.ts +122 -0
  33. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  34. package/src/components/trace/traceToSubgraph.ts +463 -0
  35. package/src/components/trace/types.ts +389 -0
  36. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  37. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  38. package/src/components/ui/index.ts +1 -0
  39. package/src/components/workload/WorkloadView.tsx +58 -5
  40. package/src/components/workload/index.ts +1 -1
  41. package/src/components/workload/reachabilityTab.test.ts +47 -0
  42. package/src/index.ts +5 -0
  43. package/src/theme/components.css +38 -0
  44. package/src/theme/variables.css +5 -0
  45. package/src/types/core.ts +23 -0
  46. package/src/utils/application-history.test.ts +1 -1
  47. package/src/utils/applications.test.ts +63 -1
  48. package/src/utils/applications.ts +27 -0
  49. package/src/utils/asset-url.test.ts +113 -0
  50. package/src/utils/asset-url.ts +33 -1
  51. package/src/utils/inClusterConsent.test.ts +109 -0
  52. package/src/utils/inClusterConsent.ts +72 -0
  53. package/src/utils/index.ts +2 -0
  54. package/src/utils/navigation.test.ts +61 -0
  55. package/src/utils/navigation.ts +11 -9
  56. package/src/utils/pluralize.test.ts +80 -1
  57. package/src/utils/pluralize.ts +34 -0
@@ -0,0 +1,972 @@
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 } 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, 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
+
194
+ const model = useMemo(
195
+ () => buildGraph({ trace, route, origin, origins, stale, running }),
196
+ [trace, route, origin, origins, stale, running],
197
+ )
198
+ const multiPath = scenarios.length > 1
199
+ const sidebar = useMemo(
200
+ () =>
201
+ buildSidebar(selection, {
202
+ trace,
203
+ route,
204
+ origin,
205
+ origins,
206
+ nodes: model.nodes,
207
+ breakNodeId: model.breakNodeId,
208
+ breakAtExitOf: model.breakAtExitOf,
209
+ nonNetworkNodeIds: model.nonNetworkNodeIds,
210
+ contextNodeIds: model.contextNodeIds,
211
+ interleave: model.interleave,
212
+ entryParallelCount: model.entryParallelCount,
213
+ journeyEntryNodeIds: model.journeyEntryNodeIds,
214
+ pathNodeIds: model.pathNodeIds,
215
+ stale,
216
+ running: selectedOriginRunning,
217
+ multiPath,
218
+ httpPath: props.probePath,
219
+ }),
220
+ [selection, trace, route, origin, origins, model, stale, selectedOriginRunning, multiPath, props.probePath],
221
+ )
222
+ const verdict = useMemo(
223
+ () => buildVerdict(trace, route, { stale, running: selectedOriginRunning, originId: origin?.id, originName: origin?.name, pathLabel: multiPath ? scenario?.primary.target || scenario?.label : undefined }),
224
+ [trace, route, origins, stale, running, multiPath, scenario, origin],
225
+ )
226
+
227
+ const onCTA = (cta: InspectorCTA) => {
228
+ if (cta.disabledReason) return
229
+ if (cta.action === 'run-in-cluster') onRunInCluster?.()
230
+ // onRefresh refetches the STATIC trace and collects no evidence - wrong for a
231
+ // CTA whose own copy offers to go and test something.
232
+ else if (cta.action === 'run-probes') onRunProbes?.()
233
+ else if (cta.action === 'open-resource' && cta.ref) onNavigateToResource?.(cta.ref)
234
+ else if (cta.action === 'copy-command' && cta.command) void navigator.clipboard?.writeText(cta.command)
235
+ }
236
+
237
+ return (
238
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-theme-border bg-theme-surface">
239
+ {/* The HTTP-path editor only makes sense when some declared route takes an
240
+ HTTP-shaped request - a TCP-only (redis/gRPC) subject would offer a
241
+ path field nothing would ever send. */}
242
+ <VerdictBand
243
+ verdict={verdict}
244
+ trace={trace}
245
+ runNonce={runNonce}
246
+ actions={
247
+ <ReachActions
248
+ {...props}
249
+ inClusterRunnable={traceInClusterRunnable(trace)}
250
+ inClusterTested={origins.some((o) => o.id === 'incluster' && o.mark !== 'untested')}
251
+ supportsHTTPPath={(trace.routes ?? []).some((r) => {
252
+ const p = r.inClusterRequest?.protocol
253
+ return p === 'http' || p === 'https' || (!p && !!r.inClusterRequest)
254
+ }) || !(trace.routes ?? []).some((r) => r.inClusterRequest?.protocol === 'tcp')}
255
+ />
256
+ }
257
+ />
258
+
259
+ {/* Three columns once there is room for them. The graph is the navigation
260
+ surface and the inspector the reading surface, so keeping them side by
261
+ side preserves the click-an-edge-then-read loop; below xl the inspector
262
+ wraps to its own full-width row rather than being clipped. */}
263
+ {/* The board fills the pane. Each column scrolls on its own so a long
264
+ origin list or inspector never pushes the graph out of view. */}
265
+ {/* Always three columns. Below xl the inspector used to wrap onto its own
266
+ full-width row UNDER the graph, which split one diagnosis into two
267
+ stacked, separately-scrolling regions and left neither enough room.
268
+ Narrow widths tighten the two rails instead - they are a picker and a
269
+ reading column, and both compress better than the path does. */}
270
+ {/* Wider than it was: the vantage rail used to own a column of its own, and
271
+ that width now belongs to the two panes that carry content. */}
272
+ {/* The inspector is the report; the graph rarely uses its full width.
273
+ Starved at ~336px, sentences wrapped into fragments and the next
274
+ action fell below the fold. */}
275
+ <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%)]">
276
+ {/* The vantages are IN the graph now, one node each, so the rail that
277
+ listed them again is gone and its width belongs to the path. */}
278
+ <div className="relative flex min-h-0 min-w-0 flex-col">
279
+ {/* The path picker lives ON the board, like the vantage capsules -
280
+ both selection axes (which path x from where) belong to the same
281
+ surface. As a full-width bar above the header it read as page
282
+ chrome outside the card, cost a row of height, and the eye skimmed
283
+ past the most important control on the page. Top-right is the
284
+ fitted graph's quiet corner. */}
285
+ {scenarios.length > 1 && (
286
+ <ScenarioPicker
287
+ scenarios={scenarios}
288
+ activeKey={scenario?.key}
289
+ onPick={(k) => {
290
+ const picked = scenarios.find((s) => s.key === k)
291
+ setSelectedRoute(picked ? routeIdentity(picked.primary) : null)
292
+ setSelection(undefined)
293
+ }}
294
+ stale={stale}
295
+ running={running}
296
+ />
297
+ )}
298
+ <ReachabilityGraph
299
+ model={model}
300
+ onAction={(a) => {
301
+ if (a.kind !== 'run-in-cluster') return
302
+ // Pin the vantage you just asked to exercise. Without this the
303
+ // origin stayed on whatever the default resolved to, and when the
304
+ // results arrived the default moved - so you were sent away from
305
+ // the very test you started.
306
+ setOriginId('incluster')
307
+ setSelection(undefined)
308
+ onRunInCluster?.()
309
+ }}
310
+ selected={selection}
311
+ onSelect={(id) => {
312
+ // An origin capsule selects the VANTAGE - the rest of the graph
313
+ // and the inspector re-route to it. Any other node selects the
314
+ // resource, as before.
315
+ const originId = id?.startsWith('origin:') ? (id.slice('origin:'.length) as OriginId) : undefined
316
+ if (originId) {
317
+ setOriginId(originId)
318
+ setSelection(undefined)
319
+ return
320
+ }
321
+ setSelection(id)
322
+ }}
323
+ />
324
+
325
+ </div>
326
+ <div className="min-h-0 overflow-y-auto [scrollbar-gutter:stable]">
327
+ <InspectorPanel
328
+ sidebar={sidebar}
329
+ onCTA={onCTA}
330
+ onOpen={(r) => onNavigateToResource?.(r)}
331
+ selected={selection}
332
+ onSelect={setSelection}
333
+ />
334
+ </div>
335
+ </div>
336
+
337
+ <CoverageFooter trace={trace} testedAt={testedAt} stale={stale} origins={origins} />
338
+ </div>
339
+ )
340
+ }
341
+
342
+ // ---------------------------------------------------------------- scenarios
343
+
344
+ /** The hostnames folded into one tab. Only a hover when there is more than one -
345
+ * a single host is already the tab's own label. */
346
+ function TabTooltip({ hosts, members, verdict, children }: { hosts: string[]; members: string[]; verdict?: string; children: React.ReactNode }) {
347
+ // Hostnames when they really are hostnames, otherwise the route labels
348
+ // themselves - a Service port folded with a Pod is neither "2 hostnames" nor
349
+ // meaningfully summarised by dropping one of them.
350
+ const items = hosts.length === members.length && hosts.length > 0 ? hosts : members
351
+ const noun = items === hosts ? 'hostnames' : 'paths'
352
+ // Below the widescreen cutoff the row shows only a tone dot, so the verdict
353
+ // words must live here - a dot alone answers nothing on hover.
354
+ if (items.length < 2 && !verdict) return <>{children}</>
355
+ if (items.length < 2) {
356
+ return (
357
+ <Tooltip content={verdict} wrapperClassName="flex-none cursor-help">
358
+ {children}
359
+ </Tooltip>
360
+ )
361
+ }
362
+ return (
363
+ <Tooltip
364
+ content={
365
+ <span className="flex flex-col gap-0.5">
366
+ {verdict && <span className="font-semibold">{verdict}</span>}
367
+ <span className="font-semibold">
368
+ Same result on {items.length} {noun}
369
+ </span>
370
+ {items.map((h) => (
371
+ <span key={h} className="font-mono">
372
+ {h}
373
+ </span>
374
+ ))}
375
+ {/* The fold is presentational, and users rightly ask whether the
376
+ checks are separate - say the contract out loud. */}
377
+ <span className="mt-1 max-w-[42ch] text-theme-text-tertiary">
378
+ {items === hosts
379
+ ? '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.'
380
+ : 'Each path is tested separately; they share one tab only while their results are identical.'}
381
+ </span>
382
+ </span>
383
+ }
384
+ wrapperClassName="flex-none cursor-help"
385
+ >
386
+ {children}
387
+ </Tooltip>
388
+ )
389
+ }
390
+
391
+ /** Matches the `2xl:` breakpoint that shows the verdict chips - the tooltip
392
+ * duplicates the chip unless it only speaks when the chip is hidden, so the
393
+ * two MUST key on the same width. */
394
+ function useChipsVisible(): boolean {
395
+ const [wide, setWide] = useState(() => typeof window !== 'undefined' && window.matchMedia('(min-width: 1536px)').matches)
396
+ useEffect(() => {
397
+ const mq = window.matchMedia('(min-width: 1536px)')
398
+ const on = () => setWide(mq.matches)
399
+ mq.addEventListener('change', on)
400
+ return () => mq.removeEventListener('change', on)
401
+ }, [])
402
+ return wide
403
+ }
404
+
405
+ function ScenarioPicker({
406
+ scenarios,
407
+ activeKey,
408
+ onPick,
409
+ stale,
410
+ running,
411
+ }: {
412
+ scenarios: Scenario[]
413
+ activeKey?: string
414
+ onPick: (key: string) => void
415
+ stale: boolean
416
+ running: boolean
417
+ }) {
418
+ const chipsVisible = useChipsVisible()
419
+ return (
420
+ <div
421
+ className="absolute right-3 top-2 z-10 max-w-[46%] rounded-lg border border-theme-border bg-theme-elevated shadow-theme-md"
422
+ >
423
+ <div className="px-2.5 pb-0.5 pt-1.5 text-[9px] font-bold tracking-[0.07em] text-theme-text-tertiary">PATH</div>
424
+ {/* Rows, not tabs: a corner column scales to N paths - capped at ~5 rows
425
+ with a half-row peek, then scrolls, so many-port Services can't eat
426
+ the graph. Tone dot + mono label; the verdict CHIP only gets a column
427
+ on widescreen - below that the dot carries the tone and the words
428
+ live on the hover, because eight long chips drowned the labels. */}
429
+ <div className="flex max-h-[126px] flex-col overflow-y-auto pb-1">
430
+ {scenarios.map((s) => {
431
+ const tone = routeTone(s.primary, { stale, running })
432
+ const active = s.key === activeKey
433
+ const chip = routeChip(s.primary, { stale, running })
434
+ return (
435
+ <TabTooltip hosts={s.hosts} members={s.members} verdict={chipsVisible ? undefined : chip} key={s.key}>
436
+ <button
437
+ type="button"
438
+ onClick={() => onPick(s.key)}
439
+ aria-pressed={active}
440
+ className={`mx-1 flex cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 text-left transition-colors duration-150 ${
441
+ active ? 'selection selection-ring' : 'hover:bg-theme-hover'
442
+ }`}
443
+ >
444
+ <span className="inline-block shrink-0 rounded-full" style={{ width: 7, height: 7, background: SEV_COLOR[tone] }} />
445
+ <span className="min-w-0 max-w-[200px] truncate font-mono text-[11.5px] font-semibold text-theme-text-primary">{s.label}</span>
446
+ <span className={`badge-sm hidden shrink-0 whitespace-nowrap 2xl:inline-flex ${SEV_BADGE[tone]}`}>{chip}</span>
447
+ </button>
448
+ </TabTooltip>
449
+ )
450
+ })}
451
+ </div>
452
+ </div>
453
+ )
454
+ }
455
+
456
+ // ------------------------------------------------------------------ verdict
457
+
458
+ const LAYER_ORDER = ['dns', 'tcp', 'tls', 'http']
459
+
460
+ function VerdictBand({
461
+ verdict,
462
+ trace,
463
+ actions,
464
+ runNonce,
465
+ }: {
466
+ verdict: ReturnType<typeof buildVerdict>
467
+ trace: Trace
468
+ actions: React.ReactNode
469
+ runNonce?: number
470
+ }) {
471
+ // The check volume lives HERE, not only in the footer ledger: the band is
472
+ // the one line everyone reads, and a verdict with no visible work behind it
473
+ // asks to be taken on faith. Stated as work done, never as success - the
474
+ // count includes failed dials.
475
+ const stats = probeCheckStats(trace)
476
+ const layerBreakdown = LAYER_ORDER.filter((l) => stats.byLayer[l])
477
+ .map((l) => `${l.toUpperCase()} ${stats.byLayer[l]}`)
478
+ .join(' · ')
479
+ return (
480
+ <div className="flex items-start gap-3 border-b border-theme-border px-5 py-3">
481
+ <div className="min-w-0 flex-1">
482
+ {/* The headline covers the whole resource while the badge follows the
483
+ selected path + vantage. On one line the two read as a single
484
+ contradictory sentence ("Reached via API server … not tested … from
485
+ In-cluster probe"). Each scope gets its own labelled line; which of
486
+ the two should LEAD is a product call, but they must never blur. */}
487
+ {verdict.scopeLabel && (
488
+ <div className="mb-0.5 text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">{verdict.scopeLabel}</div>
489
+ )}
490
+ <div className="flex flex-wrap items-baseline gap-2">
491
+ <span className="text-[14.5px] font-semibold text-theme-text-primary">{verdict.title}</span>
492
+ {!verdict.chipScope && <span className={`badge-sm whitespace-nowrap ${SEV_BADGE[verdict.tone]}`}>{verdict.chipText}</span>}
493
+ <JustTestedNote nonce={runNonce} />
494
+ </div>
495
+ {verdict.chipScope && (
496
+ <div className="mt-1 flex flex-wrap items-center gap-2">
497
+ <span className="text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">{verdict.chipScope.toUpperCase()}</span>
498
+ <span className={`badge-sm whitespace-nowrap ${SEV_BADGE[verdict.tone]}`}>{verdict.chipText}</span>
499
+ </div>
500
+ )}
501
+ {stats.checks > 0 && (
502
+ <Tooltip
503
+ 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.`}
504
+ wrapperClassName="mt-1 inline-flex cursor-help"
505
+ >
506
+ <span className="inline-flex items-center gap-1.5 font-mono text-[11px] text-theme-text-secondary">
507
+ {/* Accent, not green: checks include failures - this states WORK
508
+ done, never success. Emphasis is weight, not a new color. */}
509
+ <Activity className="h-3 w-3 flex-none" style={{ color: 'var(--accent-text)' }} />
510
+ <span>
511
+ <span className="font-semibold text-theme-text-primary">{stats.checks}</span> live check{stats.checks === 1 ? '' : 's'}
512
+ {stats.vantages > 1 ? ` from ${stats.vantages} vantages` : ''}
513
+ </span>
514
+ </span>
515
+ </Tooltip>
516
+ )}
517
+ {verdict.problem && (
518
+ <div
519
+ className="mt-1.5 flex max-w-[92ch] items-start gap-1.5 rounded-md px-2 py-1.5 text-xs leading-relaxed text-pretty"
520
+ style={{ border: '1px solid var(--color-warning)', background: 'color-mix(in srgb, var(--color-warning) 10%, transparent)' }}
521
+ >
522
+ <span className="shrink-0" style={{ color: 'var(--color-warning-dark)' }}>▲</span>
523
+ <span className="text-theme-text-primary">{verdict.problem}</span>
524
+ </div>
525
+ )}
526
+ {verdict.body && <div className="mt-1 max-w-[76ch] text-xs leading-relaxed text-theme-text-secondary text-pretty">{verdict.body}</div>}
527
+ </div>
528
+ <div className="flex flex-none gap-2">{actions}</div>
529
+ </div>
530
+ )
531
+ }
532
+
533
+ // ------------------------------------------------------------------ origins
534
+
535
+ /** The vantages Radar cannot run, stated rather than offered. These are real
536
+ * holes in the evidence, so they must stay on screen - but as ONE limit on the
537
+ * claim, not a list. A per-origin line charged the rail a row for each thing
538
+ * Radar can't do, teaching the reader its limitations before its findings. */
539
+ function UntestableNote({ origins }: { origins: Origin[] }) {
540
+ const out = origins.filter((o) => o.unsupported)
541
+ if (out.length === 0) return null
542
+ return (
543
+ <>
544
+ <span className="text-theme-border">|</span>
545
+ <Tooltip
546
+ content={
547
+ <span className="flex flex-col gap-1">
548
+ {out.map((o) => (
549
+ <span key={o.id}>
550
+ <span className="font-semibold">{o.name}</span> — {o.unavailable}
551
+ </span>
552
+ ))}
553
+ </span>
554
+ }
555
+ wrapperClassName="cursor-help"
556
+ >
557
+ <span className="text-theme-text-tertiary">
558
+ {out.map((o) => o.glyph).join(' ')} {untestableSummary(out)}
559
+ </span>
560
+ </Tooltip>
561
+ </>
562
+ )
563
+ }
564
+
565
+ /** One phrase for every vantage Radar can't run, named by what it would have
566
+ * proven rather than by Radar's internal origin names. */
567
+ function untestableSummary(out: Origin[]): string {
568
+ const phrase: Partial<Record<OriginId, string>> = {
569
+ caller: 'as your application',
570
+ external: 'from the public internet',
571
+ 'radar-incluster': 'as Radar inside the cluster',
572
+ }
573
+ const parts = out.map((o) => phrase[o.id] ?? o.name.toLowerCase())
574
+ if (parts.length === 1) return `Not tested ${parts[0]}.`
575
+ return `Not tested ${parts.slice(0, -1).join(', ')} or ${parts[parts.length - 1]}.`
576
+ }
577
+
578
+ // ---------------------------------------------------------------- inspector
579
+
580
+ /**
581
+ * The panel's one disclosure, matching drawer-components' Section: a rotating
582
+ * ChevronRight and a grid-template-rows transition. Hand-rolled "›"/"⌄" toggles
583
+ * neither looked like the rest of Radar nor animated, so a section appearing was
584
+ * an abrupt jump with no affordance saying it could be opened at all.
585
+ */
586
+ function Disclosure({
587
+ open,
588
+ onToggle,
589
+ label,
590
+ count,
591
+ children,
592
+ }: {
593
+ open: boolean
594
+ onToggle: () => void
595
+ label: string
596
+ count?: number
597
+ children: React.ReactNode
598
+ }) {
599
+ return (
600
+ <div>
601
+ <button
602
+ type="button"
603
+ onClick={onToggle}
604
+ aria-expanded={open}
605
+ 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"
606
+ >
607
+ <ChevronRight className={`h-3 w-3 flex-none transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
608
+ <span>{label}</span>
609
+ {count !== undefined && <span className="flex-1 text-right font-normal tracking-normal">{count}</span>}
610
+ </button>
611
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
612
+ <div className="overflow-hidden">{children}</div>
613
+ </div>
614
+ </div>
615
+ )
616
+ }
617
+
618
+ function Caveats({ items, defaultOpen = false }: { items: string[]; defaultOpen?: boolean }) {
619
+ const [open, setOpen] = useState(defaultOpen)
620
+ if (items.length === 0) return null
621
+ // Collapsed by default. These are limits on the CLAIM, and they are the same
622
+ // sentences for every result of a given kind - so at full height they pushed
623
+ // the evidence and the path below the fold on every single view to say
624
+ // something invariant. Quieter than a fault, and one click from being read.
625
+ return (
626
+ <div>
627
+ <Disclosure open={open} onToggle={() => setOpen((v) => !v)} label="WHAT THIS DOESN’T PROVE" count={items.length}>
628
+ {items.map((n, i) => (
629
+ <div key={i} className="mt-1 text-[10.5px] leading-snug text-theme-text-tertiary">
630
+ {n}
631
+ </div>
632
+ ))}
633
+ </Disclosure>
634
+ </div>
635
+ )
636
+ }
637
+
638
+ function InspectorPanel({
639
+ sidebar,
640
+ onCTA,
641
+ onOpen,
642
+ selected,
643
+ onSelect,
644
+ }: {
645
+ sidebar: Sidebar
646
+ onCTA: (c: InspectorCTA) => void
647
+ onOpen: (r: ResourceRef) => void
648
+ selected?: string
649
+ onSelect: (id: string) => void
650
+ }) {
651
+ const [scopeOpen, setScopeOpen] = useState(false)
652
+ const { path } = sidebar
653
+ return (
654
+ <div className="flex h-full flex-col gap-3 bg-theme-surface px-3.5 py-3 border-l border-theme-border">
655
+ {/* The diagnosis is ALWAYS here. Whether traffic got through must never
656
+ require a click - it is the question the tab exists to answer. */}
657
+ <div>
658
+ <div className="flex items-center gap-2">
659
+ <span className="text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">THIS PATH</span>
660
+ <div className="flex-1" />
661
+ <span className={`badge-sm whitespace-nowrap ${SEV_BADGE[path.chipTone]}`}>{path.chipText}</span>
662
+ </div>
663
+ <div className="mt-1.5 font-mono text-[12.5px] font-semibold leading-snug text-theme-text-primary">{path.title}</div>
664
+ {path.request && (
665
+ <div className="mt-1 flex items-baseline gap-1.5">
666
+ <span className="flex-none text-[9px] font-bold tracking-[0.04em] text-theme-text-tertiary">ASKED FOR</span>
667
+ <span className="min-w-0 flex-1 break-words font-mono text-[10.5px] text-theme-text-secondary">{path.request}</span>
668
+ </div>
669
+ )}
670
+ <div className="mt-1 text-[11.5px] leading-relaxed text-theme-text-secondary text-pretty">{path.body}</div>
671
+ </div>
672
+
673
+ {path.scope.length > 0 && (
674
+ <Disclosure open={scopeOpen} onToggle={() => setScopeOpen((v) => !v)} label="TEST DETAILS" count={path.scope.length}>
675
+ {path.scope.map((p, i) => (
676
+ <div key={i} className="mt-0.5 flex gap-2 border-b border-theme-border-subtle py-0.5">
677
+ <span className="w-[86px] flex-none pt-0.5 text-[9px] font-bold tracking-[0.04em] text-theme-text-tertiary">{p.k}</span>
678
+ <span className="flex-1 break-words font-mono text-[10px] leading-snug text-theme-text-secondary">{p.v}</span>
679
+ </div>
680
+ ))}
681
+ </Disclosure>
682
+ )}
683
+
684
+ {path.evidence.length > 0 && (
685
+ <div>
686
+ {/* Same count idiom as TEST DETAILS: the number of distinct
687
+ observations is itself a trust signal, stated in chrome-weight. */}
688
+ <div className="mb-1 flex items-center text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">
689
+ <span>WHAT WE SAW</span>
690
+ <span className="flex-1 text-right font-normal tracking-normal">{path.evidence.length}</span>
691
+ </div>
692
+ {path.evidence.map((e, i) => (
693
+ <div key={i} className="mb-1 flex items-baseline gap-1.5">
694
+ <MarkGlyph mark={e.mark} />
695
+ <span className="text-[11px] leading-snug text-theme-text-secondary">{e.text}</span>
696
+ </div>
697
+ ))}
698
+ </div>
699
+ )}
700
+
701
+ {/* The whole path, in order. Every hop is here whether or not it was
702
+ clicked; the break opens by default because it is the answer, and the
703
+ rest stay one line so the report never becomes a wall. */}
704
+ {sidebar.hops.length > 0 && (
705
+ <div className="border-t border-theme-border pt-3">
706
+ <div className="text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">ALONG THIS PATH</div>
707
+ {sidebar.hops.map((h) => (
708
+ <HopSection key={h.id} hop={h} selected={selected === h.id} onSelect={onSelect} onOpen={onOpen} />
709
+ ))}
710
+ </div>
711
+ )}
712
+ {/* Configured-but-bypassed resources: readable for orientation, outside
713
+ the journey - listing them under ALONG THIS PATH claimed a traversal
714
+ the selected vantage explicitly does not make. */}
715
+ {sidebar.context && (
716
+ <div className="border-t border-theme-border pt-3">
717
+ <div className="text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">{sidebar.context.label}</div>
718
+ {sidebar.context.hops.map((h) => (
719
+ <HopSection key={h.id} hop={h} selected={selected === h.id} onSelect={onSelect} onOpen={onOpen} />
720
+ ))}
721
+ </div>
722
+ )}
723
+ <Caveats items={path.notProve} />
724
+
725
+ {/* The terminal "nothing to do" state renders as one quiet line - the
726
+ callout styling is reserved for states that carry an actual next move. */}
727
+ <div
728
+ className={path.next.quiet ? 'border-t border-theme-border pt-2.5' : 'rounded-md px-2.5 py-2.5'}
729
+ style={path.next.quiet ? undefined : { border: '1px solid var(--accent)', background: 'var(--accent-muted)' }}
730
+ >
731
+ <div className="text-[9.5px] font-bold tracking-[0.05em]" style={{ color: path.next.quiet ? 'var(--text-tertiary)' : 'var(--accent-text)' }}>
732
+ {path.next.header}
733
+ </div>
734
+ <div className="mt-1 text-[11.5px] leading-snug text-theme-text-secondary text-pretty">{path.next.body}</div>
735
+ {path.next.blocked && <div className="mt-1.5 text-[10.5px] leading-snug text-theme-text-tertiary">{path.next.blocked}</div>}
736
+ {path.next.ctas.length > 0 && (
737
+ <div className="mt-2 flex flex-wrap gap-1.5">
738
+ {path.next.ctas.map((c, i) => (
739
+ <Tooltip key={i} content={c.disabledReason ?? ''} wrapperClassName="cursor-help">
740
+ <button
741
+ type="button"
742
+ onClick={() => onCTA(c)}
743
+ disabled={!!c.disabledReason}
744
+ className={
745
+ c.primary && !c.disabledReason
746
+ ? 'btn-brand cursor-pointer whitespace-nowrap rounded-md px-2.5 py-1.5 text-[11px] font-semibold'
747
+ : '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'
748
+ }
749
+ >
750
+ {c.text}
751
+ </button>
752
+ </Tooltip>
753
+ ))}
754
+ </div>
755
+ )}
756
+ </div>
757
+
758
+ </div>
759
+ )
760
+ }
761
+
762
+ const HOP_STATE_NOTE: Record<HopReport['state'], string> = {
763
+ break: 'the request stopped here',
764
+ before: 'reached',
765
+ after: 'never tried — something earlier stopped',
766
+ plain: '',
767
+ }
768
+
769
+ function HopSection({
770
+ hop,
771
+ selected,
772
+ onSelect,
773
+ onOpen,
774
+ }: {
775
+ hop: HopReport
776
+ selected: boolean
777
+ onSelect: (id: string) => void
778
+ onOpen: (r: ResourceRef) => void
779
+ }) {
780
+ const [openOverride, setOpenOverride] = useState<boolean | null>(null)
781
+ const open = openOverride ?? hop.expanded
782
+ const ref = useRef<HTMLDivElement>(null)
783
+ // A click on the graph scrolls its hop into view instead of replacing the
784
+ // panel - the gesture still means "I care about this one", it just no longer
785
+ // costs the reader the rest of the story.
786
+ //
787
+ // Selecting also CLEARS any manual collapse. A hop the reader had folded shut
788
+ // kept that override forever, so clicking its node in the graph highlighted a
789
+ // closed section and appeared to do nothing at all - asking to look at
790
+ // something has to be able to open it.
791
+ useEffect(() => {
792
+ if (!selected) return
793
+ setOpenOverride(null)
794
+ ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
795
+ }, [selected])
796
+ // Becoming the BREAK also clears a manual collapse: the break is the answer
797
+ // this panel exists to show, and a collapse made while reading a different
798
+ // scenario must not keep the answer shut when the reader switches to one
799
+ // where this hop is where the request stopped.
800
+ useEffect(() => {
801
+ if (hop.state === 'break') setOpenOverride(null)
802
+ }, [hop.state])
803
+ return (
804
+ <div
805
+ ref={ref}
806
+ // A flat tint plus a hairline ring, not a heavy left bar: the bar read as a
807
+ // scrollbar against the panel edge and its background ran under the border.
808
+ // `.selection` / `.selection-ring` are the repo's own tokens for exactly
809
+ // this, so selection here looks like selection everywhere else.
810
+ className={`mt-2 rounded-md px-2 py-1 transition-colors duration-200 ${selected ? 'selection selection-ring' : ''}`}
811
+ >
812
+ <button
813
+ type="button"
814
+ onClick={() => {
815
+ setOpenOverride(!open)
816
+ onSelect(hop.id)
817
+ }}
818
+ aria-expanded={open}
819
+ className="flex w-full items-center gap-1.5 text-left"
820
+ >
821
+ <ChevronRight className={`h-3 w-3 flex-none text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
822
+ <span className="text-[9px] font-bold tracking-[0.04em] text-theme-text-tertiary">{hop.kind}</span>
823
+ <span className="min-w-0 flex-1 truncate font-mono text-[11.5px] font-semibold text-theme-text-primary">{hop.name}</span>
824
+ <span className={`badge-sm whitespace-nowrap ${SEV_BADGE[hop.chipTone]}`}>{hop.chipText || HOP_STATE_NOTE[hop.state]}</span>
825
+ </button>
826
+ {!open && (hop.state !== 'plain' || hop.parallelCount) && (
827
+ <div className="pl-[18px] text-[10px] leading-snug text-theme-text-tertiary">
828
+ {[hop.parallelCount ? `one of ${hop.parallelCount} parallel entry points` : '', HOP_STATE_NOTE[hop.state]]
829
+ .filter(Boolean)
830
+ .join(' — ')}
831
+ </div>
832
+ )}
833
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
834
+ <div className="overflow-hidden">
835
+ <div className="pb-1 pl-[18px]">
836
+ {hop.openRef?.name && (
837
+ <button type="button" onClick={() => onOpen(hop.openRef!)} className="text-[11px] text-accent-text hover:underline">
838
+ Open ↗
839
+ </button>
840
+ )}
841
+ <div className="mt-1 text-[11.5px] leading-relaxed text-theme-text-secondary text-pretty">{hop.body}</div>
842
+ {hop.facts.map((f, i) => (
843
+ <div key={i} className="mt-0.5 flex gap-2 border-b border-theme-border-subtle py-0.5">
844
+ <span className="w-[86px] flex-none pt-0.5 text-[9px] font-bold tracking-[0.04em] text-theme-text-tertiary">{f.k}</span>
845
+ <span className="flex-1 break-words font-mono text-[10px] leading-snug text-theme-text-secondary">{f.v}</span>
846
+ </div>
847
+ ))}
848
+ {hop.anomalies && hop.anomalies.length > 0 && (
849
+ <div className="mt-2">
850
+ {hop.anomalies.map((a, i) => (
851
+ <div key={i} className="mb-1 flex items-baseline gap-1.5">
852
+ <span style={glyphStyle(a.mark)}>{markStyle(a.mark).glyph}</span>
853
+ <span className="text-[10.5px] leading-snug text-theme-text-secondary">{a.text}</span>
854
+ </div>
855
+ ))}
856
+ </div>
857
+ )}
858
+ {hop.rows && hop.rows.length > 0 && (
859
+ <div className="mt-2 flex flex-col gap-0.5">
860
+ {hop.rows.map((r) => (
861
+ <Tooltip
862
+ key={r.name}
863
+ content={
864
+ <>
865
+ <span className="font-mono font-semibold">{r.name}</span>
866
+ <span className="text-theme-text-tertiary"> — {r.detail} · {markHelp(r.mark)}</span>
867
+ </>
868
+ }
869
+ wrapperClassName="w-full cursor-help"
870
+ >
871
+ <div className="flex w-full items-baseline gap-1.5">
872
+ <span style={glyphStyle(r.mark)}>{markStyle(r.mark).glyph}</span>
873
+ <span className="min-w-0 flex-1 truncate font-mono text-[10px] text-theme-text-secondary">{r.name}</span>
874
+ <span className="min-w-0 flex-1 truncate text-right text-[9.5px] text-theme-text-tertiary">{r.detail}</span>
875
+ </div>
876
+ </Tooltip>
877
+ ))}
878
+ {!!hop.moreRows && <div className="text-[9.5px] text-theme-text-tertiary">+{hop.moreRows} more not shown</div>}
879
+ </div>
880
+ )}
881
+ <div className="mt-2">
882
+ <Caveats items={hop.notProve} />
883
+ </div>
884
+ </div>
885
+ </div>
886
+ </div>
887
+ </div>
888
+ )
889
+ }
890
+
891
+ // ------------------------------------------------------------------- footer
892
+
893
+ function CoverageFooter({
894
+ trace,
895
+ testedAt,
896
+ stale,
897
+ origins,
898
+ }: {
899
+ trace: Trace
900
+ testedAt?: Date
901
+ stale: boolean
902
+ origins: Origin[]
903
+ }) {
904
+ const c = trace.coverage
905
+ const skips = trace.notTested ?? []
906
+ // Only skips that actually cost coverage belong in the headline count - a
907
+ // benign skip loses nothing and padding the number would overstate the gap.
908
+ const realGaps = skips.filter((s) => s.reasonClass !== 'benign')
909
+ // "skipped N · M not tested" read as one thing counted twice. They are
910
+ // different levels: attempts made, versus paths with no evidence at all.
911
+ const attempts = c && c.tested > 0 ? `${c.passed} got through · ${c.failed} failed${c.skipped ? ` · ${c.skipped} couldn’t be tried` : ''}` : ''
912
+ // Derived breaks were never dialled, so they are counted apart from attempts -
913
+ // folding them in reported requests that failed when none were sent.
914
+ const derived = c?.derived ? `${c.derived} broken without testing` : ''
915
+ const gaps = realGaps.length ? `${realGaps.length} path${realGaps.length === 1 ? '' : 's'} with no evidence` : ''
916
+ // The KIND of proof must stay visible after the run, not only on the consent
917
+ // dialog: a TCP-only pass proves a socket opened, never that the application
918
+ // protocol works, and "got through" alone let the reader assume more.
919
+ const mode = completedRequestMode(trace)
920
+ const proofKind =
921
+ mode === 'tcp'
922
+ ? 'TCP connections only — application protocol not checked'
923
+ : mode === 'mixed'
924
+ ? 'some ports TCP-only — application protocol not checked there'
925
+ : ''
926
+ const coverageText =
927
+ (c ? [attempts, derived, gaps].filter(Boolean).join(' · ') || 'nothing tested yet' : 'nothing tested yet') +
928
+ (proofKind ? ` · ${proofKind}` : '')
929
+ // The live-check volume moved up into the verdict band - the footer stays
930
+ // the coverage ledger (routes, gaps, when), not the trust headline.
931
+ return (
932
+ <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">
933
+ <span className="text-[9.5px] font-bold tracking-[0.07em]">WHAT WAS TESTED</span>
934
+ <span className="font-mono text-theme-text-secondary">{coverageText}</span>
935
+ {/* The vantages Radar can never run are a COVERAGE fact, so they belong on
936
+ the coverage line - as their own band they were a third row of chrome
937
+ restating what this one is for. */}
938
+ <UntestableNote origins={origins} />
939
+ <span className="text-theme-border">|</span>
940
+ <span className="text-[9.5px] font-bold tracking-[0.07em]">WHEN</span>
941
+ <span className="font-mono" style={{ color: stale ? 'var(--color-warning-dark)' : 'var(--text-secondary)' }}>
942
+ {stale
943
+ ? 'cluster state changed since this test — results excluded from the verdict'
944
+ : testedAt
945
+ ? `observed ${testedAt.toLocaleTimeString()}`
946
+ : 'no test has been run'}
947
+ </span>
948
+ </div>
949
+ )
950
+ }
951
+
952
+ // -------------------------------------------------------------------- dev
953
+
954
+ function DevStateBar({ state, onPick }: { state: DevState | null; onPick: (s: DevState | null) => void }) {
955
+ const chip = (active: boolean) =>
956
+ `cursor-pointer whitespace-nowrap rounded-full border px-2.5 py-0.5 text-[11px] font-semibold ${
957
+ active ? 'border-accent bg-accent-muted text-accent-text' : 'border-theme-border bg-theme-surface text-theme-text-secondary'
958
+ }`
959
+ return (
960
+ <div className="flex flex-wrap items-center gap-1.5">
961
+ <span className="text-[10px] font-bold tracking-[0.08em] text-theme-text-tertiary">DEV STATE</span>
962
+ <button type="button" onClick={() => onPick(null)} className={chip(state === null)}>
963
+ live
964
+ </button>
965
+ {DEV_STATES.map((s) => (
966
+ <button key={s.id} type="button" onClick={() => onPick(s.id)} className={chip(state === s.id)}>
967
+ {s.label}
968
+ </button>
969
+ ))}
970
+ </div>
971
+ )
972
+ }