@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.
- package/package.json +5 -5
- package/src/components/applications/ApplicationDetail.test.tsx +46 -0
- package/src/components/applications/ApplicationDetail.tsx +14 -15
- package/src/components/topology/K8sResourceNode.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +85 -5
- package/src/components/topology/layout.worker.ts +2 -1
- package/src/components/trace/ReachabilityGraph.tsx +503 -0
- package/src/components/trace/ReachabilityView.tsx +972 -0
- package/src/components/trace/TracePanel.test.ts +172 -0
- package/src/components/trace/TracePanel.tsx +558 -0
- package/src/components/trace/TraceSummary.test.ts +213 -0
- package/src/components/trace/TraceSummary.tsx +168 -0
- package/src/components/trace/inClusterSummary.test.ts +98 -0
- package/src/components/trace/inClusterSummary.ts +81 -0
- package/src/components/trace/index.ts +19 -0
- package/src/components/trace/podReach.test.ts +71 -0
- package/src/components/trace/podReach.ts +37 -0
- package/src/components/trace/probe-display.test.ts +107 -0
- package/src/components/trace/probe-display.ts +37 -0
- package/src/components/trace/reachFixtures.ts +261 -0
- package/src/components/trace/reachGraphModel.test.ts +1407 -0
- package/src/components/trace/reachGraphModel.ts +1521 -0
- package/src/components/trace/reachInspector.test.ts +669 -0
- package/src/components/trace/reachInspector.ts +667 -0
- package/src/components/trace/reachMarks.test.ts +667 -0
- package/src/components/trace/reachMarks.ts +579 -0
- package/src/components/trace/reachOrigins.test.ts +392 -0
- package/src/components/trace/reachOrigins.ts +437 -0
- package/src/components/trace/reachVerdict.test.ts +537 -0
- package/src/components/trace/reachVerdict.ts +601 -0
- package/src/components/trace/traceFingerprint.test.ts +214 -0
- package/src/components/trace/traceFingerprint.ts +122 -0
- package/src/components/trace/traceToSubgraph.test.ts +623 -0
- package/src/components/trace/traceToSubgraph.ts +463 -0
- package/src/components/trace/types.ts +389 -0
- package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
- package/src/components/ui/InClusterConsentDialog.tsx +141 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +58 -5
- package/src/components/workload/index.ts +1 -1
- package/src/components/workload/reachabilityTab.test.ts +47 -0
- package/src/index.ts +5 -0
- package/src/theme/components.css +38 -0
- package/src/theme/variables.css +5 -0
- package/src/types/core.ts +23 -0
- package/src/utils/application-history.test.ts +1 -1
- package/src/utils/applications.test.ts +63 -1
- package/src/utils/applications.ts +27 -0
- package/src/utils/asset-url.test.ts +113 -0
- package/src/utils/asset-url.ts +33 -1
- package/src/utils/inClusterConsent.test.ts +109 -0
- package/src/utils/inClusterConsent.ts +72 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +61 -0
- package/src/utils/navigation.ts +11 -9
- package/src/utils/pluralize.test.ts +80 -1
- 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
|
+
}
|