@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,667 @@
1
+ import type { Trace, RouteResult, ResourceRef } from './types'
2
+ import type { Mark, SevTone } from './reachMarks'
3
+ import { routeMark, routeChip, routeTone, routeAsSeenFrom, originRouteEvidence, routeForOrigin, traceInClusterRunnable } from './reachMarks'
4
+ import type { Origin, OriginId } from './reachOrigins'
5
+ import { strongestGap, actionableGap, originSkipReason } from './reachOrigins'
6
+ import { hopEvidenceFor, originProducedEvidence, type GraphNode } from './reachGraphModel'
7
+
8
+ // 'run-probes' re-runs the reachability probes. It is deliberately NOT
9
+ // 'refresh': the panel that offers it promises fresh evidence, and a static
10
+ // refetch collects none.
11
+ export type InspectorAction = 'run-in-cluster' | 'run-probes' | 'open-resource' | 'copy-command'
12
+
13
+ export interface InspectorCTA {
14
+ text: string
15
+ primary?: boolean
16
+ action?: InspectorAction
17
+ ref?: ResourceRef
18
+ command?: string
19
+ /** Set when the CTA describes something Radar cannot do - rendered inert. */
20
+ disabledReason?: string
21
+ }
22
+
23
+ /** Only nodes are selectable now. Edges carry no segment-local evidence, so
24
+ * clicking one could only ever repeat the path result - see `Sidebar`. */
25
+ export type Selection = string | undefined
26
+
27
+ /**
28
+ * What the sidebar shows.
29
+ *
30
+ * `path` is ALWAYS present: whether traffic got through, from where, with what
31
+ * caveats, and what to do next. That question must never require a click - it
32
+ * is the reason the tab exists. `resource` is ADDITIVE, appearing when a node
33
+ * is selected, and never replaces the diagnosis.
34
+ */
35
+ export interface Sidebar {
36
+ path: {
37
+ chipTone: SevTone
38
+ chipText: string
39
+ title: string
40
+ /** The concrete path under test. Always visible: it was inside the collapsed
41
+ * details, so the panel described a result without ever naming what was
42
+ * requested. */
43
+ request?: string
44
+ body: string
45
+ scope: { k: string; v: string }[]
46
+ evidence: { mark: Mark; text: string }[]
47
+ notProve: string[]
48
+ next: { header: string; body: string; blocked?: string; ctas: InspectorCTA[]; quiet?: boolean }
49
+ }
50
+ /** Every hop on the selected path, in order - the whole story for this path
51
+ * seen from this vantage, rather than a summary plus whichever node was last
52
+ * clicked. Reading beat clicking: understanding a path used to take three
53
+ * clicks whose panel meant something different after each one. */
54
+ hops: HopReport[]
55
+ /** Configured-but-bypassed resources: on screen for orientation, outside the
56
+ * journey. The label says WHY they are not part of this vantage's path. */
57
+ context?: { label: string; hops: HopReport[] }
58
+ }
59
+
60
+ export interface HopDetail {
61
+ kind: string
62
+ name: string
63
+ chipTone: SevTone
64
+ chipText: string
65
+ body: string
66
+ facts: { k: string; v: string }[]
67
+ rows?: { mark: Mark; name: string; detail: string }[]
68
+ moreRows?: number
69
+ anomalies?: { mark: Mark; text: string }[]
70
+ notProve: string[]
71
+ openRef?: ResourceRef
72
+ }
73
+
74
+ /** Configured-but-bypassed resources, grouped under one label naming WHY they
75
+ * are outside this vantage's journey. Their config sections stay readable;
76
+ * their journey-state words do not apply. */
77
+ function contextGroup(ctx: Ctx, byId: Map<string, GraphNode>): Sidebar['context'] {
78
+ const nodes = (ctx.contextNodeIds ?? []).map((id) => byId.get(id)).filter((n): n is GraphNode => !!n)
79
+ if (nodes.length === 0) return undefined
80
+ const label =
81
+ ctx.origin.id === 'apiserver'
82
+ ? 'CONFIGURED ENTRY — KUBERNETES RELAYED THE REQUEST PAST THIS'
83
+ : ctx.origin.id === 'incluster' || ctx.origin.id === 'radar-incluster'
84
+ ? 'CONFIGURED ENTRY — THE PROBE DIALS THE SERVICE DIRECTLY'
85
+ : 'PARALLEL ENTRY — NOT ON THIS ROUTE\u2019S PATH'
86
+ return {
87
+ label,
88
+ hops: nodes.map((n) => ({
89
+ ...resourceSection(n),
90
+ id: n.id,
91
+ state: 'plain' as const,
92
+ chipText: '',
93
+ expanded: false,
94
+ })),
95
+ }
96
+ }
97
+
98
+ /**
99
+ * A hop's place in the story.
100
+ *
101
+ * - `break` where the request is known to have stopped. Expanded by default;
102
+ * it is the answer.
103
+ * - `before` reached before that. Collapsed - it worked, so it is context.
104
+ * - `after` never tried, because something earlier stopped. Collapsed, and
105
+ * labelled so its silence is not read as health.
106
+ * - `plain` no break to order around.
107
+ */
108
+ export type HopState = 'before' | 'break' | 'after' | 'plain'
109
+
110
+ export interface HopReport extends HopDetail {
111
+ id: string
112
+ state: HopState
113
+ expanded: boolean
114
+ /** Set on entry hops when they are parallel members of one entry stage - the
115
+ * note reads "one of N parallel entry points" instead of implying sequence. */
116
+ parallelCount?: number
117
+ }
118
+
119
+ const NOT_DATAPLANE = 'Nothing about the normal network path. Kubernetes relayed this for us, so routing, network policy and the mesh were all skipped.'
120
+ const SYNTHETIC_IDENTITY =
121
+ 'That your app can reach it. This test ran from a throwaway Pod under the namespace’s default account with no token mounted — not as your application — so anything that checks who is calling may answer differently.'
122
+
123
+ function originScope(o: Origin, trace: Trace): { k: string; v: string }[] {
124
+ const runsIn: Record<OriginId, string> = {
125
+ incluster: `a throwaway Pod in ${trace.subject.namespace || 'the cluster'}`,
126
+ 'radar-incluster': 'Radar\u2019s own Pod',
127
+ apiserver: 'the kube-apiserver process',
128
+ local: 'your workstation (outside the cluster)',
129
+ caller: 'the application workload’s own Pod',
130
+ external: 'a client on the public internet',
131
+ }
132
+ return [
133
+ { k: 'TESTED FROM', v: o.name },
134
+ { k: 'RUNS IN', v: runsIn[o.id] },
135
+ // The laptop row's content is where the dial came FROM ("you dialled a
136
+ // public address…"), not who dialled - identity and network position are
137
+ // different facts and must not share a label.
138
+ { k: o.id === 'local' ? 'NETWORK POSITION' : 'IDENTITY', v: o.identity },
139
+ { k: 'MECHANISM', v: o.mech },
140
+ ]
141
+ }
142
+
143
+ /**
144
+ * The next-step prompt, driven by the strongest gap Radar can CLOSE. The
145
+ * unreachable ceiling is stated underneath as a caveat instead of being offered
146
+ * as an action - a button that can never be pressed is not a next step.
147
+ */
148
+ function gapNext(
149
+ origins: Origin[],
150
+ current: Origin,
151
+ namespace?: string,
152
+ multiPath?: boolean,
153
+ inClusterRunnable = true,
154
+ ): Sidebar['path']['next'] {
155
+ // The server can only test a route that carries a concrete in-cluster request.
156
+ // When none does, the run fails with "not supported for this subject" - so the
157
+ // panel must say that HERE, instead of recommending it as the strongest
158
+ // evidence available and letting the operator find out by clicking.
159
+ const notRunnable = 'No path on this resource has a request Radar can send from inside the cluster, so this test would have nothing to run.'
160
+ // No button here. The in-cluster run is offered ON the vantage capsule in the
161
+ // graph, which is the thing that would produce the missing evidence - and a
162
+ // third copy of the same control (header, panel, capsule) was one too many.
163
+ // The panel keeps the REASONING, which is what it is good at.
164
+ const inClusterCTA = (): InspectorCTA[] => []
165
+ const actionable = actionableGap(origins)
166
+ const ceiling = strongestGap(origins)
167
+ const ceilingNote = ceiling?.unsupported ? `Even then, ${ceiling.name.toLowerCase()} stays untested — ${ceiling.unavailable}` : undefined
168
+ const denied = origins.find((o) => o.mark === 'denied')
169
+ // A run is never scoped to the selected path - the server tests every declared
170
+ // path in one pass. Saying so here stops the picker above from reading as a
171
+ // filter on what the button will do.
172
+ const allPaths = multiPath ? ' The test covers every path on this resource, not only this one.' : ''
173
+
174
+ // Mid-run, a running origin is not a "gap" any more, so the no-gap branch
175
+ // below would claim Radar "already has the strongest evidence" BEFORE the
176
+ // result exists. Being in flight is its own state, and it is the whole truth
177
+ // about this panel right now.
178
+ const runningOrigin = origins.find((o) => o.mark === 'running')
179
+ if (runningOrigin) {
180
+ return {
181
+ header: 'TEST RUNNING',
182
+ body: `${runningOrigin.name} is testing now — the result lands here when it finishes.`,
183
+ ctas: [],
184
+ }
185
+ }
186
+
187
+ if (!actionable && denied) {
188
+ const ns = namespace || '<namespace>'
189
+ return {
190
+ header: 'ASK FOR THIS PERMISSION',
191
+ body: `Running an in-cluster test needs \`create\` on \`jobs\` in ${ns}. Grant it, or run the check from a workload you already control.`,
192
+ blocked: denied.unavailable,
193
+ ctas: [{ text: 'Copy the permission check', action: 'copy-command', command: `kubectl auth can-i create jobs -n ${ns}` }],
194
+ }
195
+ }
196
+ if (actionable && actionable.id === current.id) {
197
+ // You are looking at the vantage that is itself the gap.
198
+ return {
199
+ header: 'RUN THIS NEXT',
200
+ body: inClusterRunnable
201
+ ? `Nothing has been tested from ${actionable.name} yet, and it is the strongest evidence Radar can still collect.${allPaths}`
202
+ : `${notRunnable} Every declared path here was skipped before a request could be formed.`,
203
+ blocked: ceilingNote,
204
+ ctas: actionable.id === 'incluster' ? inClusterCTA() : [{ text: '⟳ Re-run', action: 'run-probes' }],
205
+ }
206
+ }
207
+ if (!actionable) {
208
+ // Terminal state: there is nothing to do, and a call-to-action box whose
209
+ // message is "no action" shouts its own irrelevance. One quiet line of
210
+ // closure; no Re-run (the header already owns that control - the same
211
+ // one-copy rule as inClusterCTA), and no ceiling caveat (WHAT THIS
212
+ // DOESN'T PROVE states it on this same pane).
213
+ return {
214
+ header: 'NO STRONGER TEST AVAILABLE',
215
+ body: `This is the strongest evidence Radar can collect for this path.${allPaths}`,
216
+ ctas: [],
217
+ quiet: true,
218
+ }
219
+ }
220
+ return {
221
+ header: 'RUN THIS NEXT',
222
+ body:
223
+ actionable.id === 'incluster' && !inClusterRunnable
224
+ ? `${notRunnable} Every declared path here was skipped before a request could be formed.`
225
+ : `${actionable.name} has not been used for this path, and is the strongest evidence Radar can still collect.${allPaths}`,
226
+ blocked: ceilingNote,
227
+ ctas: actionable.id === 'incluster' ? inClusterCTA() : [{ text: '⟳ Re-run', action: 'run-probes' }],
228
+ }
229
+ }
230
+
231
+ interface Ctx {
232
+ trace: Trace
233
+ route?: RouteResult
234
+ origin: Origin
235
+ origins: Origin[]
236
+ nodes: GraphNode[]
237
+ /** Where the path first broke, from the graph model. */
238
+ breakNodeId?: string
239
+ /** A Service-routing boundary: the node whose EXIT the request never got
240
+ * past. The break renders on that hop with exit-phrased copy - no node is
241
+ * blamed. */
242
+ breakAtExitOf?: string
243
+ /** Inline nodes that are NOT network hops (the workload). Never given
244
+ * journey states - a workload is neither "reached" nor a stopping place. */
245
+ nonNetworkNodeIds?: string[]
246
+ /** Configured-but-bypassed entries - context, never the journey. */
247
+ contextNodeIds?: string[]
248
+ /** Non-network nodes spliced into the display after a journey hop. */
249
+ interleave?: { id: string; afterId: string }[]
250
+ /** >1 when the journey's entries are parallel members of one stage. */
251
+ entryParallelCount?: number
252
+ /** The journey's entry-hop node ids, from the graph model. */
253
+ journeyEntryNodeIds?: string[]
254
+ /** The selected route's chain in traversal order, from the graph model. */
255
+ pathNodeIds?: string[]
256
+ stale?: boolean
257
+ running?: boolean
258
+ /** More than one scenario is on screen, so scope has to be stated explicitly. */
259
+ multiPath?: boolean
260
+ /** The HTTP path the run requests, as chosen in "what to test". */
261
+ httpPath?: string
262
+ }
263
+
264
+ /**
265
+ * Whether an in-cluster run has anything to send.
266
+ *
267
+ * The server tests a route only when it carries a concrete InClusterRequest; a
268
+ * route whose probes were all skipped never becomes one, so a subject can offer
269
+ * paths and still have nothing runnable. Knowing this from the trace is what
270
+ * lets the panel say so BEFORE the operator spends a Job on it.
271
+ */
272
+
273
+
274
+ /** Whether the diagnosis says nothing the headline has not already said. Both
275
+ * are generated, so they collide whenever the producer falls back to the same
276
+ * generic sentence - and a banner repeating the line above it reads as a second
277
+ * problem rather than the same one. */
278
+ function restatesTitle(summary: string | undefined, title: string): boolean {
279
+ const norm = (x: string) => x.trim().toLowerCase().replace(/[.!]$/, '')
280
+ return !!summary && !!title && norm(summary) === norm(title)
281
+ }
282
+
283
+ /** The persistent diagnosis: did traffic get through, from where, and what next. */
284
+ function pathSection(ctx: Ctx): Sidebar['path'] {
285
+ const { trace, route, origin, origins } = ctx
286
+ // The route outcome is merged across origins. Without this gate the panel
287
+ // rendered another vantage's success under the selected vantage's name - a
288
+ // permanently unavailable origin could read "a real request went through"
289
+ // while the graph beside it said "not routable". Same lie the graph already
290
+ // guards against, in the surface users actually read.
291
+ // This origin's OWN result when the producer sent one; the coarse
292
+ // "did this origin produce anything" gate only remains as the fallback.
293
+ const ev = originRouteEvidence(route, origin.id)
294
+ const asSeen = ev.kind === 'none' ? undefined : ev.result
295
+ // A config-derived break is true of every vantage and observed by none, so it
296
+ // reads as a configuration fact - never as this origin's failed dial.
297
+ const fromConfig = ev.kind === 'config'
298
+ // Which KIND of derived break. Declared-config is broken whatever the cluster
299
+ // is doing; cluster-state is true right now and changes when the workload
300
+ // does. Calling the second one a configuration failure sends the reader to
301
+ // edit YAML when the fix is to get Pods ready.
302
+ const basis = ev.kind === 'config' ? ev.result.basis : undefined
303
+ const hasEvidence = ev.kind === 'own' || (ev.kind === 'rollup' && originProducedEvidence(origin))
304
+ const mark: Mark = fromConfig
305
+ ? 'config'
306
+ : hasEvidence
307
+ ? asSeen
308
+ ? routeMark(asSeen, { stale: ctx.stale, running: ctx.running })
309
+ : 'untested'
310
+ : origin.mark
311
+
312
+ const notProve: string[] = []
313
+ if (origin.kind === 'synthetic') notProve.push(SYNTHETIC_IDENTITY)
314
+ if (origin.kind === 'relayed') notProve.push(NOT_DATAPLANE)
315
+ const hasFrontDoor = (trace.upstreams ?? []).length > 0
316
+ const external = origins.find((o) => o.id === 'external')
317
+ if (hasFrontDoor && external?.unsupported && origin.id !== 'external') {
318
+ // "No request has come in from outside" is only true until Radar's own
319
+ // machine dials the public entry and gets an answer - that request DID come
320
+ // from outside. What stays unproven then is narrower: a real user's request.
321
+ const outsideDialAnswered = (trace.upstreams ?? []).some((h) => {
322
+ const m = hopEvidenceFor(h, { id: 'local' }, trace.runVantage)?.mark
323
+ return m === 'proved' || m === 'answered'
324
+ })
325
+ notProve.push(
326
+ outsideDialAnswered
327
+ ? 'That real users can reach it — Radar dialled the public entry from outside and got an answer, but no real user request has been observed.'
328
+ : 'That people on the internet can reach it — no request has come in from outside.',
329
+ )
330
+ }
331
+
332
+ const evidence: { mark: Mark; text: string }[] = []
333
+ const seen = new Set<string>()
334
+ const add = (m: Mark, text: string) => {
335
+ const key = text.trim().toLowerCase()
336
+ if (!key || seen.has(key)) return
337
+ seen.add(key)
338
+ evidence.push({ mark: m, text })
339
+ }
340
+ if ((hasEvidence || fromConfig) && asSeen?.evidence) add(mark, asSeen.evidence)
341
+ // What this vantage saw at each HOP. The route is built from the backend's
342
+ // probes, so a laptop that dialled the front door and got an answer had all
343
+ // of it discarded and read as "no test has been run from here" - beside a
344
+ // graph drawing that very dial.
345
+ const hopSeen = ctx.nodes
346
+ .filter((n) => !n.isOrigin && n.hop)
347
+ .map((n) => ({ node: n, ev: hopEvidenceFor(n.hop, origin, trace.runVantage, { stale: ctx.stale, running: ctx.running }) }))
348
+ .filter((x): x is { node: GraphNode; ev: NonNullable<ReturnType<typeof hopEvidenceFor>> } => !!x.ev)
349
+ if (!hasEvidence && !fromConfig) {
350
+ for (const { node, ev: e } of hopSeen) add(e.mark, `${node.kind.toLowerCase()} ${node.name} — ${e.title || e.label}`)
351
+ }
352
+ // The one boundary two observations can establish. Stated as the reasoning
353
+ // that produced it, so it reads as evidence rather than as a verdict.
354
+ if (ev.kind === 'own' && routeForOrigin(route, origin.id)?.failedBoundary === 'service-routing') {
355
+ add('failed', 'the Pods answered directly, but the Service did not — so the Service’s own routing is what breaks')
356
+ }
357
+ // Localization facts are behind-the-gate (apiserver / direct-pod) evidence.
358
+ // They belong to the relayed origin, not to whichever origin is selected -
359
+ // listing them under the in-cluster probe credited it with observations it
360
+ // never made.
361
+ for (const f of origin.id === 'apiserver' && hasEvidence ? route?.localization ?? [] : []) {
362
+ const layer = f.layer.toUpperCase()
363
+ const detail = f.detail?.trim()
364
+ const body = !detail ? layer : detail.toUpperCase().startsWith(layer) ? detail : `${layer} · ${detail}`
365
+ add(f.ok ? 'proxied' : 'failed', `${body} — checked directly, past the entry point`)
366
+ }
367
+ if (evidence.length === 0) {
368
+ // A route the run SKIPPED still carries why. The reason is looked up from
369
+ // the skip rows by the exact identity that produced it, so it appears only
370
+ // under the vantage whose dial was skipped - never charging the in-cluster
371
+ // probe with the proxy's limits, or the laptop with the proxy's timeouts.
372
+ const skipReason = ev.kind === 'rollup' && asSeen?.outcome === 'not-tested' ? originSkipReason(trace, origin.id, route) : undefined
373
+ // An in-cluster run that was ATTEMPTED and never started is not "no test
374
+ // has been run" - the capsule preserves the attempt and this row must not
375
+ // erase it. The error itself is the observation.
376
+ const attemptError = origin.id === 'incluster' && origin.mark === 'blocked' ? origin.unavailable : undefined
377
+ add(
378
+ mark,
379
+ // The attempt error outranks skip rows: a prior Job's leftover skips
380
+ // would otherwise paint a fresh failed run (image pull, quota) with last
381
+ // run's reason, one pane from a capsule saying "test couldn't run".
382
+ attemptError ||
383
+ skipReason ||
384
+ (origin.unsupported
385
+ ? 'Radar cannot test from here, so nothing has been learned this way'
386
+ : origin.mark === 'denied'
387
+ ? 'not permitted to run this test'
388
+ : // A vantage that CANNOT be used states why (nothing dialable from
389
+ // the laptop, a skipped mechanism) - "no test has been run" reads
390
+ // as a test someone forgot, which is a different claim.
391
+ origin.unavailable || 'no test has been run from here'),
392
+ )
393
+ }
394
+
395
+ const failed = mark === 'failed'
396
+ // Only when it is about THIS path. A trace carries one diagnosis but many
397
+ // routes, so an unattributed or sibling-owned cause must not be rendered as
398
+ // the selected path's - the route's own evidence is what remains true.
399
+ const rawDiagnosis = trace.diagnosis
400
+ const diagnosis = rawDiagnosis && (!rawDiagnosis.route || rawDiagnosis.route === route?.route) ? rawDiagnosis : undefined
401
+ const reachedSomething = !hasEvidence && !fromConfig && hopSeen.some((x) => x.ev.mark !== 'failed')
402
+ const body = basis === 'cluster-state'
403
+ ? 'Nothing is ready to serve this path right now, so it cannot work from any vantage. No request was sent to establish that — it is read off the current state of the cluster, and it changes when the workload does.'
404
+ : fromConfig
405
+ ? 'The configuration itself is broken, so this path cannot work from any vantage. No request was sent to establish that — it is read off what is declared.'
406
+ : reachedSomething
407
+ ? 'This vantage did reach part of the path — see what it saw below. It has no result for this route as a whole, so the end-to-end journey from here is still unproven.'
408
+ : !hasEvidence
409
+ ? (origin.unavailable || 'Nothing has been tested from here, so this says nothing about whether traffic gets through.') +
410
+ // When NOTHING was tested anywhere, the health dots are the only colour
411
+ // on the board and read as a passed test - the one state where the
412
+ // dot/line split needs saying out loud, not just in the caption.
413
+ ((trace.coverage?.tested ?? 0) === 0 ? ' The dots on the graph show each resource’s own reported health — cluster state, not a test result.' : '')
414
+ : failed
415
+ ? 'This is the first confirmed failure. Everything after it was never tried, so there is nothing to report past this point.'
416
+ : mark === 'proved'
417
+ ? 'A real request went through and the target answered.'
418
+ : mark === 'proxied'
419
+ ? 'Kubernetes relayed a request and the target answered — which shows something is serving, not that the normal path works.'
420
+ : mark === 'untested'
421
+ ? 'Nothing has been tried from here yet. Configuration may look right, but that is intent, not proof.'
422
+ : mark === 'stale'
423
+ ? 'This result predates a change to the cluster, so it is set aside rather than trusted.'
424
+ : mark === 'running'
425
+ ? 'A test is running. Earlier results stay until new ones replace them.'
426
+ : // A proxy-only failure wears the same amber mark as a real answer,
427
+ // but nothing answered - saying "the target answered" here sent
428
+ // the reader to debug an application response that never existed.
429
+ asSeen?.outcome === 'unreachable' && asSeen?.confidence === 'indirect'
430
+ ? 'The relayed dial failed. The proxy bypasses the real path, so this does not condemn it — but nothing answered, and the real path is still untested.'
431
+ : 'The target answered, but not with what was asked for.'
432
+
433
+ return {
434
+ chipTone: asSeen ? routeTone(asSeen, { stale: ctx.stale, running: ctx.running }) : 'unknown',
435
+ chipText: asSeen ? routeChip(asSeen, { stale: ctx.stale, running: ctx.running }) : 'not tested',
436
+ title: `${origin.name} → ${route?.target || trace.subject.name}`,
437
+ request: route ? `${route.route}${ctx.httpPath && ctx.httpPath !== '/' ? ` · HTTP path ${ctx.httpPath}` : ''}` : undefined,
438
+ body,
439
+ scope: [...originScope(origin, trace), ...(route ? [{ k: 'PATH', v: route.route }] : [])],
440
+ evidence,
441
+ notProve,
442
+ next:
443
+ failed && diagnosis
444
+ ? {
445
+ header: 'LIKELY CAUSE',
446
+ body: diagnosis.summary + (diagnosis.nextAction ? ` ${diagnosis.nextAction}` : ''),
447
+ ctas: [
448
+ ...(diagnosis.culpritResource ? [{ text: 'Open the culprit', primary: true, action: 'open-resource' as InspectorAction, ref: diagnosis.culpritResource }] : []),
449
+ ...(diagnosis.command ? [{ text: 'Copy the command', action: 'copy-command' as InspectorAction, command: diagnosis.command }] : []),
450
+ ],
451
+ }
452
+ : gapNext(origins, origin, trace.subject.namespace, ctx.multiPath, traceInClusterRunnable(trace)),
453
+ }
454
+ }
455
+
456
+ /** The additive detail for a selected node. Never replaces the diagnosis. */
457
+ function resourceSection(node: GraphNode): HopDetail {
458
+ const hop = node.hop
459
+ const findings = hop?.findings ?? []
460
+
461
+ if (node.podRows) {
462
+ const roster = hop?.config?.pods ?? []
463
+ const total = hop?.config?.podTotal ?? roster.length
464
+ const ready = typeof hop?.meta?.ready === 'number' ? (hop.meta.ready as number) : roster.filter((p) => p.ready).length
465
+ const selected = typeof hop?.meta?.selected === 'number' ? (hop.meta.selected as number) : total
466
+ const publishNotReady = !!hop?.meta?.publishNotReadyAddresses
467
+ const notReady = publishNotReady ? [] : roster.filter((p) => !p.ready)
468
+ const omitted = total - roster.length
469
+ const notProve: string[] = []
470
+ if (omitted > 0) notProve.push(`The ${omitted} Pods that were not tested. Untested is not proven.`)
471
+ if (notReady.length > 0) notProve.push(`The ${notReady.length} not-ready Pod${notReady.length > 1 ? 's' : ''} — nothing was sent to them, so nothing was learned.`)
472
+ return {
473
+ kind: 'PODS',
474
+ name: `${ready} of ${selected} eligible`,
475
+ chipTone: node.tone,
476
+ chipText: 'backends',
477
+ body: publishNotReady
478
+ ? 'The Pods behind this Service. This Service is set to send traffic to Pods even before they report ready.'
479
+ : 'The Pods behind this Service. Kubernetes only sends traffic to the ones that report ready.',
480
+ facts: [
481
+ { k: 'MATCHING PODS', v: `${selected}` },
482
+ // Derived from readiness, NOT from observed delivery - "taking traffic"
483
+ // claimed evidence we do not have.
484
+ { k: 'ELIGIBLE', v: `${ready}` },
485
+ {
486
+ k: 'SITTING OUT',
487
+ v: publishNotReady ? 'none — not-ready Pods get traffic too' : notReady.length > 0 ? `${notReady.length} not ready` : 'none',
488
+ },
489
+ ],
490
+ rows: node.podRows,
491
+ moreRows: node.moreRows,
492
+ anomalies: node.anomalies,
493
+ notProve,
494
+ }
495
+ }
496
+
497
+ const c = hop?.config
498
+ const facts: { k: string; v: string }[] = []
499
+ if (c?.clusterIP) facts.push({ k: 'CLUSTER IP', v: c.clusterIP })
500
+ if (c?.serviceType) facts.push({ k: 'TYPE', v: c.serviceType })
501
+ if (c?.ports?.length) facts.push({ k: 'PORTS', v: c.ports.map((x) => `${x.port}→${x.targetPort ?? x.port}`).join(', ') })
502
+ if (c?.addresses?.length) facts.push({ k: 'ADDRESS', v: c.addresses.join(', ') })
503
+ if (c?.hostnames?.length) facts.push({ k: 'HOSTS', v: c.hostnames.join(', ') })
504
+ if (c?.selector) facts.push({ k: 'SELECTOR', v: Object.entries(c.selector).map(([k, v]) => `${k}=${v}`).join(', ') })
505
+ if (node.ref?.namespace) facts.push({ k: 'NAMESPACE', v: node.ref.namespace })
506
+
507
+ return {
508
+ kind: node.kind,
509
+ name: node.name,
510
+ chipTone: node.tone,
511
+ chipText: node.dim ? 'not on this path' : '',
512
+ body: node.dim
513
+ ? 'This entry point is attached to the resource but does not serve the host being tested.'
514
+ : findings.length > 0
515
+ ? findings[0].cause || findings[0].message
516
+ : 'Configuration and health of this resource.',
517
+ facts,
518
+ notProve: [],
519
+ openRef: node.ref,
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Builds the sidebar. The diagnosis is always computed; a node selection only
525
+ * appends to it.
526
+ */
527
+ export function buildSidebar(sel: Selection, ctx: Ctx): Sidebar {
528
+ const path = pathSection(ctx)
529
+ // The selected route's own chain, from the graph's traversal - NOT every node
530
+ // sorted by position. Sorting served siblings up as if they were sequential
531
+ // hops and included branches this route never touches.
532
+ const byId = new Map(ctx.nodes.map((n) => [n.id, n]))
533
+ const journey = (ctx.pathNodeIds ?? [])
534
+ .map((id) => byId.get(id))
535
+ .filter((n): n is GraphNode => !!n && !n.isOrigin)
536
+ // Display order interleaves non-network nodes (the workload) after their
537
+ // parent - present to read, absent from the journey's state machine.
538
+ const chain: GraphNode[] = []
539
+ for (const n of journey) {
540
+ chain.push(n)
541
+ for (const iv of ctx.interleave ?? []) {
542
+ if (iv.afterId === n.id) {
543
+ const wl = byId.get(iv.id)
544
+ if (wl) chain.push(wl)
545
+ }
546
+ }
547
+ }
548
+ const entryIds = new Set(ctx.journeyEntryNodeIds ?? [])
549
+ const nonNetwork = new Set(ctx.nonNetworkNodeIds ?? [])
550
+ // A boundary break anchors to the EXIT of its source hop (the Service);
551
+ // breakNodeId anchors to a landing node. Either way one index carries 'break'.
552
+ const anchorId = ctx.breakNodeId ?? ctx.breakAtExitOf
553
+ const breakIdx = anchorId ? chain.findIndex((n) => n.id === anchorId) : -1
554
+ // A break at ONE of N parallel entries orders nothing: a sibling entry is
555
+ // not "before" it, and the hops behind the stage were not necessarily
556
+ // stopped - the request may have gone through a sibling. Serial
557
+ // before/after semantics apply only when the break is outside the stage.
558
+ const breakInParallelStage =
559
+ breakIdx >= 0 && (ctx.entryParallelCount ?? 0) > 1 && entryIds.has(chain[breakIdx]?.id ?? '')
560
+ const stateOf = (i: number, id: string): HopState => {
561
+ // The workload is not a network hop: it is never "reached", never a place
562
+ // a request stops - whatever happens around it.
563
+ if (nonNetwork.has(id)) return 'plain'
564
+ if (breakIdx < 0) return 'plain'
565
+ if (breakInParallelStage) return i === breakIdx ? 'break' : 'plain'
566
+ if (i < breakIdx) return 'before'
567
+ return i === breakIdx ? 'break' : 'after'
568
+ }
569
+ // What to open when the reader has not chosen: the break if there is one,
570
+ // else the destination - the point of the path. Never everything at once,
571
+ // which is how a report becomes a wall.
572
+ const defaultOpen = breakIdx >= 0 ? breakIdx : chain.length - 1
573
+ return {
574
+ path,
575
+ hops: chain.map((n, i) => {
576
+ const section = resourceSection(n)
577
+ return {
578
+ ...section,
579
+ id: n.id,
580
+ state: stateOf(i, n.id),
581
+ // Exit-phrased for a boundary break: the SERVICE hop carries it, and
582
+ // "stopped here" would blame the hop itself for its exit's failure.
583
+ chipText:
584
+ i === breakIdx && ctx.breakAtExitOf === n.id
585
+ ? 'routing to its Pods breaks just past here'
586
+ : section.chipText,
587
+ parallelCount: entryIds.has(n.id) && (ctx.entryParallelCount ?? 0) > 1 ? ctx.entryParallelCount : undefined,
588
+ expanded: sel ? n.id === sel : i === defaultOpen,
589
+ }
590
+ }),
591
+ context: contextGroup(ctx, byId),
592
+ }
593
+ }
594
+
595
+ /** The headline verdict band. Derived from the selected scenario, never from an
596
+ * aggregate that could hide a failing route behind passing siblings. */
597
+ const VERDICT_TONE: Record<string, SevTone> = { healthy: 'healthy', degraded: 'degraded', broken: 'unhealthy', unknown: 'unknown' }
598
+
599
+ export function buildVerdict(
600
+ trace: Trace,
601
+ route: RouteResult | undefined,
602
+ opts: { stale?: boolean; running?: boolean; pathLabel?: string; originId?: string; originName?: string } = {},
603
+ ): {
604
+ tone: SevTone
605
+ chipText: string
606
+ /** Set when the resource has more than one path, so the reader can tell the
607
+ * route-scoped badge apart from the resource-wide headline beside it. */
608
+ chipScope?: string
609
+ scopeLabel?: string
610
+ title: string
611
+ problem?: string
612
+ body: string
613
+ } {
614
+ // With no route there is nothing to derive a tone from, but the backend has
615
+ // still reached a verdict (e.g. a config fault found without probing). Falling
616
+ // through to 'unknown' showed a grey dot on a resource the tracer called
617
+ // degraded.
618
+ // The band sits directly above the inspector, which reads the SELECTED
619
+ // origin's own result. Leaving the band on the merged rollup let the two
620
+ // contradict each other in adjacent panes - "could not get through" over
621
+ // "got through" - on exactly the disagreeing traces per-vantage evidence
622
+ // exists to represent.
623
+ const seen = opts.originId ? routeAsSeenFrom(route, opts.originId) : route
624
+ // A route exists but this origin has no row: the badge says "not tested",
625
+ // and it must wear the NEUTRAL tone - painting it with the resource-wide
626
+ // verdict colour (amber on a degraded trace) dressed a vantage-scoped
627
+ // "not tested" as a warning about that vantage. The verdict fallback stays
628
+ // for traces with no routes at all (a config fault found without probing).
629
+ const tone: SevTone = opts.running ? 'info' : seen ? routeTone(seen, opts) : route ? 'unknown' : VERDICT_TONE[trace.verdict] ?? 'unknown'
630
+ return {
631
+ tone,
632
+ chipText: opts.running ? 'testing' : seen ? routeChip(seen, opts) : 'not tested',
633
+ // Title, problem and coverage describe the WHOLE resource; tone and chip
634
+ // follow the selected path. With several paths on screen that difference is
635
+ // invisible unless each side says which scope it speaks for.
636
+ // The badge now follows the selected path AND vantage while the headline,
637
+ // problem and coverage stay resource-wide. Both scopes are legitimate; what
638
+ // is not legitimate is leaving the reader to guess which is which, so the
639
+ // badge names its own scope in full.
640
+ // Prefixed here rather than at the render site: with no pathLabel the old
641
+ // template produced the visible "for from Radar on your machine".
642
+ chipScope: [opts.pathLabel ? `for ${opts.pathLabel}` : '', opts.originName ? `from ${opts.originName}` : '']
643
+ .filter(Boolean)
644
+ .join(' · ') || undefined,
645
+ scopeLabel: opts.pathLabel || opts.originName ? 'THIS RESOURCE' : undefined,
646
+ // A stale screen previously led with the old headline ("Reachable...") and
647
+ // then said underneath that the result was excluded. That is a contradiction,
648
+ // not an exclusion.
649
+ title: opts.stale
650
+ ? 'This result is out of date — re-test'
651
+ : trace.headline || route?.route || `Reachability · ${trace.subject.name}`,
652
+ // The diagnosis is a named fault with a culprit and a next action - it
653
+ // answers "why not", where the headline only says how much was tested. It
654
+ // is called out rather than rendered as body prose, which made the more
655
+ // important fact read as an explanation of the less important one.
656
+ // A diagnosis that only restates the headline is not a second fact. The
657
+ // banner rendered "couldn't actively test any route from here" directly
658
+ // under a title saying exactly that.
659
+ problem: restatesTitle(
660
+ trace.diagnosis?.summary,
661
+ opts.stale ? '' : trace.headline || route?.route || `Reachability · ${trace.subject.name}`,
662
+ )
663
+ ? undefined
664
+ : trace.diagnosis?.summary,
665
+ body: trace.diagnosis ? '' : trace.reason || '',
666
+ }
667
+ }