@skyhook-io/k8s-ui 1.10.4 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.test.tsx +46 -0
  3. package/src/components/applications/ApplicationDetail.tsx +14 -15
  4. package/src/components/issues/IssuesView.tsx +9 -0
  5. package/src/components/issues/diagnostic.ts +3 -0
  6. package/src/components/issues/issues.test.ts +11 -0
  7. package/src/components/issues/severity.ts +3 -0
  8. package/src/components/issues/types.ts +3 -0
  9. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  10. package/src/components/resources/ResourcesView.tsx +259 -30
  11. package/src/components/resources/index.ts +2 -0
  12. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  13. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  14. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
  17. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  18. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  19. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  20. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  21. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  22. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  24. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  25. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  26. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  27. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  28. package/src/components/resources/renderers/index.ts +4 -0
  29. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  30. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  31. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  32. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  33. package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
  34. package/src/components/resources/resource-utils-cnpg.ts +373 -56
  35. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  36. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  37. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  38. package/src/components/resources/resource-utils-velero.ts +214 -22
  39. package/src/components/resources/resource-utils.ts +70 -3
  40. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  41. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  42. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  43. package/src/components/topology/K8sResourceNode.tsx +11 -0
  44. package/src/components/topology/TopologyGraph.tsx +85 -5
  45. package/src/components/trace/ReachabilityGraph.tsx +517 -0
  46. package/src/components/trace/ReachabilityView.tsx +1173 -0
  47. package/src/components/trace/TracePanel.test.ts +172 -0
  48. package/src/components/trace/TracePanel.tsx +558 -0
  49. package/src/components/trace/TraceSummary.test.ts +213 -0
  50. package/src/components/trace/TraceSummary.tsx +168 -0
  51. package/src/components/trace/inClusterSummary.test.ts +98 -0
  52. package/src/components/trace/inClusterSummary.ts +81 -0
  53. package/src/components/trace/index.ts +19 -0
  54. package/src/components/trace/podReach.test.ts +71 -0
  55. package/src/components/trace/podReach.ts +37 -0
  56. package/src/components/trace/probe-display.test.ts +107 -0
  57. package/src/components/trace/probe-display.ts +37 -0
  58. package/src/components/trace/problemRows.test.ts +107 -0
  59. package/src/components/trace/reachFixtures.ts +261 -0
  60. package/src/components/trace/reachGraphModel.test.ts +1423 -0
  61. package/src/components/trace/reachGraphModel.ts +1521 -0
  62. package/src/components/trace/reachInspector.test.ts +892 -0
  63. package/src/components/trace/reachInspector.ts +797 -0
  64. package/src/components/trace/reachMarks.test.ts +698 -0
  65. package/src/components/trace/reachMarks.ts +623 -0
  66. package/src/components/trace/reachOrigins.test.ts +392 -0
  67. package/src/components/trace/reachOrigins.ts +449 -0
  68. package/src/components/trace/reachVerdict.test.ts +537 -0
  69. package/src/components/trace/reachVerdict.ts +601 -0
  70. package/src/components/trace/traceFingerprint.test.ts +214 -0
  71. package/src/components/trace/traceFingerprint.ts +122 -0
  72. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  73. package/src/components/trace/traceToSubgraph.ts +463 -0
  74. package/src/components/trace/types.ts +414 -0
  75. package/src/components/ui/Badge.tsx +36 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  77. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  78. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  79. package/src/components/ui/index.ts +1 -0
  80. package/src/components/workload/WorkloadView.tsx +58 -5
  81. package/src/components/workload/index.ts +1 -1
  82. package/src/components/workload/reachabilityTab.test.ts +47 -0
  83. package/src/index.ts +5 -0
  84. package/src/theme/components.css +64 -0
  85. package/src/theme/variables.css +5 -0
  86. package/src/types/core.ts +8 -0
  87. package/src/utils/api-resources.ts +2 -0
  88. package/src/utils/application-history.test.ts +1 -1
  89. package/src/utils/applications.test.ts +63 -1
  90. package/src/utils/applications.ts +27 -0
  91. package/src/utils/inClusterConsent.test.ts +109 -0
  92. package/src/utils/inClusterConsent.ts +72 -0
  93. package/src/utils/index.ts +1 -0
  94. package/src/utils/navigation.test.ts +61 -0
  95. package/src/utils/navigation.ts +11 -9
  96. package/src/utils/pluralize.test.ts +80 -1
  97. package/src/utils/pluralize.ts +34 -0
  98. package/src/utils/resource-icons.test.ts +33 -0
  99. package/src/utils/resource-icons.ts +65 -2
@@ -0,0 +1,1521 @@
1
+ import type { Trace, Hop, ResourceRef, RouteResult, PodStatus, ProbeResult } from './types'
2
+ import type { Mark, SevTone } from './reachMarks'
3
+ import { routeMark, isSlow, formatLatency, declaredHosts, hostMatches, routeHostOf, originRouteEvidence, routeForOrigin, traceInClusterRunnable } from './reachMarks'
4
+ import { originOf, type Origin } from './reachOrigins'
5
+ import { podProbeKey } from './podReach'
6
+
7
+ /**
8
+ * Layout constants. Positions are COMPUTED from content, never hand-placed:
9
+ * node boxes size to their text, so fixed coordinates collide the moment a name
10
+ * or a tag runs long. Columns are laid out left to right with a guaranteed
11
+ * gutter wide enough to hold an edge pill without touching either neighbour.
12
+ */
13
+ // Chains can reach five columns (origin -> Gateway -> Route -> Service -> Pods),
14
+ // so the gutter is tighter than a 3-column layout would allow.
15
+ const GUTTER = 98
16
+ /** The workload is worth having on the path and worth LESS width than a real
17
+ * network hop - its column and both its gutters are tightened. Still wide
18
+ * enough for the short edge pills that sit in them ("selects", "runs"). */
19
+ const WORKLOAD_GUTTER = 72
20
+ const WORKLOAD_W = 160
21
+ const ROW_GAP = 14
22
+ const LANE_PAD = { x: 14, top: 22, bottom: 14 }
23
+ const LANE_GAP = 20
24
+ /** Pills wrap to two lines, so the cap is what fits on two - not on one.
25
+ * At 16 it cut "HTTP 404 - reached" down to "HTTP 404...", which reports the
26
+ * status code and hides the only word that says what it meant. */
27
+ export const PILL_MAX_CHARS = 26
28
+ /** Hard cap on a pill's rendered width. MUST stay under GUTTER, or a pill
29
+ * overruns its gutter and lands on the node beside it. */
30
+ export const PILL_MAX_PX = 88
31
+
32
+ /** Columns are assigned in chain order. Every node belongs to exactly one,
33
+ * which is what guarantees left-to-right reading order and non-overlap. */
34
+ /** Width per column. The last column (Pods) is wider because it carries rows. */
35
+ const COL_W = { origin: 172, hop: 180, pods: 216 }
36
+
37
+ export interface GraphNode {
38
+ id: string
39
+ x: number
40
+ y: number
41
+ w: number
42
+ h: number
43
+ /** Small-caps type line, e.g. "SERVICE · PORT". */
44
+ kind: string
45
+ name: string
46
+ sub: string
47
+ /** The resource's OWN health. Never the path's truth - that lives on edges. */
48
+ tone: SevTone
49
+ tag?: string
50
+ /** The hop's OWN findings, headline-first. The graph used to consume these
51
+ * only to pick a dot colour, discarding the cause, action and remediation
52
+ * the backend had already produced - so the one sentence that answers
53
+ * "what is wrong with this hop" was a click away behind a coloured pixel. */
54
+ notes?: HopNote[]
55
+ anomalies?: { mark: Mark; text: string; title?: string }[]
56
+ /** Per-endpoint delivery results, rendered as rows inside the node.
57
+ * A column of pod boxes cost more width than the whole rest of the path and
58
+ * fit fewer of them; rows carry the same per-pod truth in less space. */
59
+ podRows?: PodRow[]
60
+ /** Endpoints beyond the row cap, named rather than silently dropped. */
61
+ moreRows?: number
62
+ dim?: boolean
63
+ ref?: ResourceRef
64
+ hop?: Hop
65
+ isOrigin?: boolean
66
+ /** An action offered ON the node, when the node IS the thing that would
67
+ * produce the missing evidence. Deliberately a button and never a
68
+ * click-the-capsule gesture: selecting a vantage is free, and this one
69
+ * creates Pods in the user's cluster. */
70
+ action?: { text: string; kind: 'run-in-cluster'; disabledReason?: string }
71
+ lane: 'control' | 'data'
72
+ }
73
+
74
+ export interface HopNote {
75
+ /** Resource-health severity, NOT a traffic Mark: findings describe the object,
76
+ * marks describe what happened to a request. Keeping the two vocabularies
77
+ * apart is why the dot and the edge never mean the same thing. */
78
+ severity: 'critical' | 'warning' | 'info'
79
+ /** Short headline - the parsed cause when there is one. */
80
+ text: string
81
+ /** Everything the row could not fit, for the hover. */
82
+ detail: string
83
+ }
84
+
85
+ export interface PodRow {
86
+ name: string
87
+ mark: Mark
88
+ detail: string
89
+ ref: ResourceRef
90
+ }
91
+
92
+ /** Rows shown before collapsing into a "+N more" line. */
93
+ export const POD_ROW_MAX = 6
94
+
95
+ /** Above this many sibling branches, only the selected one and the ones with
96
+ * findings stay expanded - the quiet remainder collapses to a single row. */
97
+ export const FAN_EXPANDED_MAX = 4
98
+
99
+ export interface GraphEdge {
100
+ id: string
101
+ /** SVG path data. */
102
+ d: string
103
+ mark: Mark
104
+ /** Set on the edges of a BOUNDARY span (a Service→Pods routing failure whose
105
+ * segment the workload node happens to sit inside): 'start' carries the one
106
+ * observed break; 'continuation' extends the same break across the span
107
+ * without claiming a second observation (rendered dashed, no pill). Break
108
+ * detection skips both - a boundary anchors to the exit of its source node
109
+ * (GraphModel.breakAtExitOf), never to a node halo. */
110
+ boundary?: 'start' | 'continuation'
111
+ label: string
112
+ /** The untruncated label. ALWAYS set: the pill has a pixel cap as well as a
113
+ * character cap, so text can be visually cut without `label` being shortened,
114
+ * and a hover that only appears sometimes is worse than one that always does. */
115
+ title: string
116
+ /** Pill centre, in canvas coordinates. Always inside a gutter. */
117
+ px: number
118
+ py: number
119
+ }
120
+
121
+ export interface LaneBox {
122
+ x: number
123
+ y: number
124
+ w: number
125
+ h: number
126
+ label: string
127
+ /** Hover text. The band label is the most prominent word in the graph and had
128
+ * no explanation anywhere. */
129
+ help: string
130
+ /** Lane tint + label colour. */
131
+ color: string
132
+ dashed?: boolean
133
+ }
134
+
135
+ export interface GraphModel {
136
+ nodes: GraphNode[]
137
+ edges: GraphEdge[]
138
+ /** The node the first FAILED edge lands on - the first place a request is
139
+ * known to have stopped. Derived from edge marks (path truth), never from
140
+ * node tone (resource health), because those are different questions, and
141
+ * only from the SELECTED vantage's edges. */
142
+ breakNodeId?: string
143
+ /** A Service-routing boundary break: the node whose EXIT the request never
144
+ * got past. Set instead of breakNodeId for this boundary - the failure is
145
+ * the segment between the Service and its Pods, and a halo on either node
146
+ * (or on the workload that merely sits inside the span) would blame a
147
+ * resource for its edge's failure. */
148
+ breakAtExitOf?: string
149
+ /** Nodes rendered inline that are NOT network hops (the workload that runs
150
+ * the Pods). Consumers must never give them journey states - a workload is
151
+ * neither "reached" nor a place a request stops. */
152
+ nonNetworkNodeIds?: string[]
153
+ /** Configured-but-bypassed resources: entries the selected vantage's request
154
+ * does not traverse (a relay went past them), or parallel entries the
155
+ * selected route does not use. Rendered as CONTEXT, never as the journey. */
156
+ contextNodeIds?: string[]
157
+ /** Non-network nodes to splice into the sidebar's display after a journey
158
+ * hop - the workload after its Service. Display order without polluting the
159
+ * journey list. */
160
+ interleave?: { id: string; afterId: string }[]
161
+ /** When >1, the journey's entry hops are PARALLEL members of one entry
162
+ * stage - consumers must say so rather than implying a sequence. */
163
+ entryParallelCount?: number
164
+ /** The journey's entry-hop node ids (subset of pathNodeIds). */
165
+ journeyEntryNodeIds?: string[]
166
+ /** The selected route's own chain, in traversal order. Consumers must read
167
+ * this rather than sorting rendered nodes by position: sorting flattens
168
+ * PARALLEL backends into what looks like a serial path, and sweeps in
169
+ * branches the selected route never touches. */
170
+ pathNodeIds: string[]
171
+ brackets: { d: string }[]
172
+ originIsControl: boolean
173
+ canvas: { w: number; h: number }
174
+ /** Lanes bound only their own nodes, so an unused half of the dataplane does
175
+ * not render as a large empty rectangle. */
176
+ laneControl?: LaneBox
177
+ laneData?: LaneBox
178
+ }
179
+
180
+ const refId = (r?: ResourceRef): string => (r ? `${r.kind}/${r.namespace ?? ''}/${r.name || 'pods'}` : '')
181
+
182
+ const isPodsHop = (h: Hop): boolean => h.resource?.kind === 'Pods' || /pods/i.test(h.edge ?? '')
183
+
184
+ function truncate(s: string, n = PILL_MAX_CHARS): string {
185
+ return s.length <= n ? s : `${s.slice(0, n - 1)}…`
186
+ }
187
+
188
+ /**
189
+ * The headline clause of a probe's evidence.
190
+ *
191
+ * The producer writes full explanations - "Connection refused. Nothing is
192
+ * listening on the port." - which are right for a hover and far too long for an
193
+ * edge pill or a node row: the pill truncated mid-word to "Connectior
194
+ * refused...." and the row ran outside its box. Only the FIRST sentence is
195
+ * taken, and only at a real sentence break, so a "·"-separated detail like
196
+ * "HTTP 200 · 41 ms" survives intact. The untruncated text always stays on the
197
+ * hover.
198
+ */
199
+ export function shortEvidence(s: string | undefined): string {
200
+ const t = (s ?? '').trim()
201
+ const cut = t.search(/\.\s/)
202
+ return (cut > 0 ? t.slice(0, cut) : t).replace(/\.$/, '').trim()
203
+ }
204
+
205
+ /** Estimated rendered height of a node box. The renderer uses fixed type sizes,
206
+ * so this stays accurate without a measure pass. */
207
+ function estHeight(n: {
208
+ anomalies?: unknown[]
209
+ notes?: { text: string }[]
210
+ podRows?: unknown[]
211
+ moreRows?: number
212
+ isOrigin?: boolean
213
+ sub?: string
214
+ action?: unknown
215
+ }): number {
216
+ const base = n.isOrigin ? 66 : 60
217
+ const anomalies = n.anomalies?.length ?? 0
218
+ // The action either shares the single status row (an origin capsule - the
219
+ // row grows a little) or stacks as its own full-width button. Uncounted, the
220
+ // rendered capsule grew straight out the bottom of its lane box.
221
+ const actionH = n.action ? (n.isOrigin && anomalies === 1 ? 7 : 28) : 0
222
+ const rows = n.podRows?.length ?? 0
223
+ // Long sub-lines wrap; approximate at ~34 characters per line.
224
+ const subLines = Math.max(1, Math.ceil((n.sub?.length ?? 0) / 34))
225
+ // Notes wrap too, and a wrapped note that the layout did not reserve room for
226
+ // is exactly how a node grows into the row beneath it.
227
+ const noteLines = (n.notes ?? []).reduce((sum, x) => sum + Math.max(1, Math.ceil(x.text.length / 26)), 0)
228
+ return (
229
+ base +
230
+ actionH +
231
+ (subLines - 1) * 13 +
232
+ (noteLines > 0 ? 8 + noteLines * 13 : 0) +
233
+ (anomalies > 0 ? 8 + anomalies * 17 : 0) +
234
+ (rows > 0 ? 8 + rows * 17 + (n.moreRows ? 15 : 0) : 0)
235
+ )
236
+ }
237
+
238
+ /** A graph edge's cubic, kept so a pill can be re-placed anywhere along it.
239
+ * cy1/cy2 are the control-point heights - equal to the endpoints for an
240
+ * ordinary edge, pulled aside for one that has to route around a node. */
241
+ interface Cubic {
242
+ x1: number
243
+ y1: number
244
+ x2: number
245
+ y2: number
246
+ dx: number
247
+ cy1: number
248
+ cy2: number
249
+ }
250
+
251
+ const cubicPath = (c: Cubic): string => `M${c.x1},${c.y1} C${c.x1 + c.dx},${c.cy1} ${c.x2 - c.dx},${c.cy2} ${c.x2},${c.y2}`
252
+
253
+ /** The point at fraction t along an edge's curve. Evaluates the SAME cubic the
254
+ * path draws, so a pill is on the line by construction. */
255
+ function bezierAt(c: Cubic, t: number): { x: number; y: number } {
256
+ const u = 1 - t
257
+ const f = (p0: number, p1: number, p2: number, p3: number) =>
258
+ u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3
259
+ return { x: f(c.x1, c.x1 + c.dx, c.x2 - c.dx, c.x2), y: f(c.y1, c.cy1, c.cy2, c.y2) }
260
+ }
261
+
262
+ /**
263
+ * What ONE origin actually observed at ONE hop.
264
+ *
265
+ * Every probe on the wire already names the hop it hit, the vantage that sent
266
+ * it and the mechanism it used - so an entry edge never needs to borrow the
267
+ * route's rollup to know how it went. It used to: a laptop that dialled a public
268
+ * Ingress and got an answer was drawn as "not tested", because the route is
269
+ * built from the SERVICE hop's probes and the Ingress dials never reach it.
270
+ *
271
+ * Returns undefined when this origin sent nothing here, so callers keep their
272
+ * existing fallback rather than inventing a verdict.
273
+ */
274
+ export function hopEvidenceFor(
275
+ hop: Hop | undefined,
276
+ origin: Pick<Origin, 'id'>,
277
+ runVantage?: string,
278
+ opts: { stale?: boolean; running?: boolean } = {},
279
+ ): { mark: Mark; label: string; title?: string } | undefined {
280
+ const live = probesFromOrigin(hop?.probes ?? [], origin, runVantage).filter((p) => !p.skipped)
281
+ if (live.length === 0) return undefined
282
+ if (opts.running) return { mark: 'running', label: 'testing now' }
283
+ if (opts.stale) return { mark: 'stale', label: 'out of date' }
284
+ const worst =
285
+ live.find((p) => !p.ok || p.tone === 'unhealthy') ??
286
+ live.find((p) => p.tone === 'degraded') ??
287
+ // The most specific layer that succeeded is the most informative: an HTTP
288
+ // answer says more than the TCP dial underneath it.
289
+ [...live].sort((a, b) => LAYER_RANK.indexOf(b.layer) - LAYER_RANK.indexOf(a.layer))[0]
290
+ const relayed = worst.path === 'apiserver'
291
+ // Tone 'reached' is the producer saying "answered, but with a 3xx/4xx/app-5xx"
292
+ // - real evidence the port serves, NOT a clean pass. Painting it the same
293
+ // solid green as a 2xx made 'HTTP 404 · reached' read as healthy at a glance;
294
+ // it is exactly the vocabulary's 'answered, but not with what we asked for'.
295
+ const mark: Mark =
296
+ !worst.ok || worst.tone === 'unhealthy'
297
+ ? 'failed'
298
+ : worst.tone === 'degraded' || worst.tone === 'reached'
299
+ ? 'answered'
300
+ : relayed
301
+ ? 'proxied'
302
+ : 'proved'
303
+ const detail = shortEvidence(worst.detail) || shortEvidence(worst.reason)
304
+ return { mark, label: detail || (relayed ? 'relayed by Kubernetes' : 'request'), title: worst.detail || worst.reason }
305
+ }
306
+
307
+ const LAYER_RANK = ['dns', 'tcp', 'tls', 'http']
308
+
309
+ /** The most telling thing one origin saw anywhere along the path - a failure if
310
+ * there is one, else the furthest it got. Used when the ROUTE has no row for
311
+ * this vantage but its probes plainly did something. */
312
+ function bestHopEvidence(trace: Trace, o: Origin, opts: { stale?: boolean; running?: boolean }) {
313
+ const hops = [...(trace.upstreams ?? []), ...(trace.downstream ?? [])]
314
+ const seen = hops.map((h) => hopEvidenceFor(h, o, trace.runVantage, opts)).filter((x): x is NonNullable<typeof x> => !!x)
315
+ return seen.find((x) => x.mark === 'failed') ?? seen[0]
316
+ }
317
+
318
+ /** Clearance between a detouring edge and the node it routes around. */
319
+ const DETOUR_GAP = 22
320
+
321
+ /**
322
+ * Slides overlapping pills along their own curves until they stop colliding.
323
+ *
324
+ * Several edges fanning into one node genuinely share a midpoint, so no fixed
325
+ * offset separates them - but the curves are furthest apart near their SOURCES,
326
+ * where each still hugs its own origin. Pulling a colliding pill back toward its
327
+ * source therefore both separates it and keeps it on its line, which putting the
328
+ * text on the origin capsule instead did not: that duplicated the edge and cost
329
+ * a row of height per vantage.
330
+ */
331
+ function separatePills(edges: GraphEdge[], geom: Map<string, Cubic>): void {
332
+ const W = PILL_MAX_PX + 14
333
+ const H = 26
334
+ const t = new Map<string, number>()
335
+ const withText = edges.filter((e) => e.label)
336
+ for (let pass = 0; pass < 6; pass++) {
337
+ let moved = false
338
+ for (let i = 0; i < withText.length; i++) {
339
+ for (let j = i + 1; j < withText.length; j++) {
340
+ const a = withText[i]
341
+ const b = withText[j]
342
+ if (Math.abs(a.px - b.px) >= W || Math.abs(a.py - b.py) >= H) continue
343
+ const c = geom.get(b.id)
344
+ if (!c) continue
345
+ // Direction-aware: edges leaving the SAME capsule are closest near
346
+ // their source, so always pulling backward walked the pill INTO the
347
+ // collision. Try both directions and keep whichever separates more.
348
+ const cur = t.get(b.id) ?? 0.5
349
+ const back = Math.max(0.16, cur - 0.09)
350
+ const fwd = Math.min(0.84, cur + 0.09)
351
+ const sep = (pt: { x: number; y: number }) => Math.max(Math.abs(pt.x - a.px) / W, Math.abs(pt.y - a.py) / H)
352
+ const pB = bezierAt(c, back)
353
+ const pF = bezierAt(c, fwd)
354
+ const next = sep(pF) > sep(pB) ? fwd : back
355
+ if (next === cur) continue
356
+ t.set(b.id, next)
357
+ const p = bezierAt(c, next)
358
+ b.px = p.x
359
+ b.py = p.y
360
+ moved = true
361
+ }
362
+ }
363
+ if (!moved) break
364
+ }
365
+ // Edges leaving the SAME capsule can run nearly coincident for their whole
366
+ // span - no point on either curve separates them. Residual colliders stack
367
+ // vertically as a last resort: slightly off the line beats unreadable.
368
+ for (let i = 0; i < withText.length; i++) {
369
+ for (let j = i + 1; j < withText.length; j++) {
370
+ const a = withText[i]
371
+ const b = withText[j]
372
+ if (Math.abs(a.px - b.px) >= W || Math.abs(a.py - b.py) >= H) continue
373
+ b.py = a.py + H + 2
374
+ }
375
+ }
376
+ }
377
+
378
+ /** Rendered per node before collapsing; the rest stay one click away. */
379
+ const HOP_NOTE_MAX = 2
380
+
381
+ /** Longest a note may be before it stops being a headline. */
382
+ const NOTE_MAX_CHARS = 46
383
+
384
+ /**
385
+ * The headline of a finding, for a box the size of a node.
386
+ *
387
+ * Producer messages are written to be read in full - "Accepted:
388
+ * NoMatchingListenerHostname - Gateway x/y listeners http, https: There were no
389
+ * hostname intersections..." is nine lines inside a graph node, which buries the
390
+ * path it is drawn on. Cut at the first real clause break so the CODE survives
391
+ * ("Accepted: NoMatchingListenerHostname"), and let the hover carry the rest.
392
+ */
393
+ /** Plain language for the Gateway API condition reasons an operator actually
394
+ * hits. "Accepted: NoMatchingListenerHostname" is precise condition data and
395
+ * user-hostile prose - it reads as if the route WAS accepted. The full raw
396
+ * message stays on the hover/detail for experts. */
397
+ const KNOWN_ROUTE_REASONS: Record<string, string> = {
398
+ NoMatchingListenerHostname: 'Not attached: no listener matches its hosts',
399
+ NoMatchingParent: 'Not attached: the Gateway has no matching listener',
400
+ NotAllowedByListeners: 'Not attached: Gateway disallows this namespace',
401
+ RefNotPermitted: 'Backend ref not permitted — missing ReferenceGrant',
402
+ BackendNotFound: 'Backend not found',
403
+ }
404
+
405
+ export function noteHeadline(msg: string): string {
406
+ const t = (msg ?? '').trim()
407
+ // " - " and ". " separate a headline from its explanation; ":" does not - it
408
+ // usually introduces the very thing worth naming.
409
+ const cut = t.search(/\s[-–—]\s|\.\s/)
410
+ let head = (cut > 0 ? t.slice(0, cut) : t).replace(/[.:]$/, '').trim()
411
+ const known = head.match(/^(?:Accepted|ResolvedRefs|Programmed):\s*(\w+)$/)
412
+ if (known && KNOWN_ROUTE_REASONS[known[1]]) head = KNOWN_ROUTE_REASONS[known[1]]
413
+ return head.length <= NOTE_MAX_CHARS ? head : `${head.slice(0, NOTE_MAX_CHARS - 1)}…`
414
+ }
415
+
416
+ /**
417
+ * A hop's findings as node rows: the parsed `cause` when the detector produced
418
+ * one (it is written to be short), else the raw message. The full text - and the
419
+ * remediation - stay in the inspector, so the graph carries the headline and
420
+ * never becomes the place you read paragraphs.
421
+ */
422
+ function hopNotes(hop?: Hop): HopNote[] {
423
+ const findings = hop?.findings ?? []
424
+ if (findings.length === 0) return []
425
+ const rank = { critical: 0, warning: 1, info: 2 } as const
426
+ const ordered = [...findings].sort((a, b) => (rank[a.severity] ?? 3) - (rank[b.severity] ?? 3))
427
+ const notes = ordered.slice(0, HOP_NOTE_MAX).map((f) => ({
428
+ severity: f.severity,
429
+ text: noteHeadline(f.cause || f.message),
430
+ // The WHOLE message, always, however short the headline got.
431
+ detail: [f.message, f.action || f.remediation].filter(Boolean).join(' — '),
432
+ }))
433
+ const hidden = ordered.length - notes.length
434
+ if (hidden > 0) {
435
+ notes.push({ severity: ordered[notes.length].severity, text: `+${hidden} more`, detail: 'Select this resource to read the rest.' })
436
+ }
437
+ return notes
438
+ }
439
+
440
+ /** Health from a hop's findings alone - the same rule the topology view uses:
441
+ * a critical finding is red only when nothing is serving. */
442
+ function hopTone(hop: Hop | undefined): SevTone {
443
+ if (!hop) return 'unknown'
444
+ const findings = hop.findings ?? []
445
+ const ready = typeof hop.meta?.ready === 'number' ? (hop.meta.ready as number) : undefined
446
+ const selected = typeof hop.meta?.selected === 'number' ? (hop.meta.selected as number) : undefined
447
+ const serving = typeof ready === 'number' && ready > 0
448
+ if (findings.some((f) => f.severity === 'critical')) return serving ? 'degraded' : 'unhealthy'
449
+ if (findings.some((f) => f.severity === 'warning')) return 'degraded'
450
+ // Nothing ready to serve is resource health even without a finding - the one
451
+ // state the old probe fallthrough was masking. publishNotReadyAddresses
452
+ // deliberately serves not-ready Pods, so it is not unhealthy there.
453
+ if (ready === 0 && (selected ?? 0) > 0 && !hop.meta?.publishNotReadyAddresses) return 'unhealthy'
454
+ // FINDINGS ONLY past this point - never probe outcomes. The dot is the
455
+ // resource's own health and must not change when the selected vantage does;
456
+ // what a request experienced lives on edges and rows, which are
457
+ // origin-scoped on purpose. A readable resource with no findings is healthy.
458
+ return 'healthy'
459
+ }
460
+
461
+ function probesForPod(pod: PodStatus, probes: ProbeResult[]): ProbeResult[] {
462
+ return probes.filter((p) => {
463
+ const key = podProbeKey(p.target)
464
+ return key === pod.name || (!!pod.ip && key === pod.ip)
465
+ })
466
+ }
467
+
468
+ /**
469
+ * Marks that mean this origin actually produced evidence for the scenario.
470
+ * Anything else (untested / denied / blocked / excluded / config) means it did
471
+ * not run, and must not inherit another origin's result.
472
+ */
473
+ const EVIDENCE_MARKS: Mark[] = ['proved', 'failed', 'answered', 'proxied', 'stale', 'running', 'slow']
474
+
475
+ export function originProducedEvidence(origin: Origin): boolean {
476
+ return EVIDENCE_MARKS.includes(origin.mark)
477
+ }
478
+
479
+ /**
480
+ * A hop's probes belong to whichever origin produced them. The graph renders
481
+ * ONE origin at a time, so it must read only that origin's probes - otherwise a
482
+ * vantage that never ran inherits another's result and a laptop's success is
483
+ * painted as a solid proved line inside the dataplane lane.
484
+ */
485
+ function probesFromOrigin(probes: ProbeResult[], origin: Pick<Origin, 'id'>, runVantage?: string): ProbeResult[] {
486
+ return probes.filter((p) => originOf(p, runVantage) === origin.id)
487
+ }
488
+
489
+ /**
490
+ * Anomalies held out of a population aggregate. Averaging them away is exactly
491
+ * how a single refusing endpoint that real users hit becomes invisible, so each
492
+ * category is counted and named rather than folded into a percentage.
493
+ */
494
+ function populationAnomalies(roster: PodStatus[], total: number, probes: ProbeResult[], publishNotReady: boolean): { mark: Mark; text: string }[] {
495
+ const out: { mark: Mark; text: string }[] = []
496
+ const failing = roster.filter((p) => p.ready && probesForPod(p, probes).some((x) => !x.skipped && (!x.ok || x.tone === 'unhealthy')))
497
+ if (failing.length > 0) {
498
+ out.push({ mark: 'failed', text: `${failing.length} endpoint${failing.length > 1 ? 's' : ''} refused the connection` })
499
+ }
500
+ const slow = roster.filter((p) => p.ready && probesForPod(p, probes).some((x) => !x.skipped && x.ok && isSlow(x)))
501
+ if (slow.length > 0) {
502
+ const worst = slow
503
+ .flatMap((p) => probesForPod(p, probes))
504
+ .filter((x) => !x.skipped && isSlow(x))
505
+ .sort((a, b) => (b.latencyNs ?? 0) - (a.latencyNs ?? 0))[0]
506
+ out.push({ mark: 'slow', text: `${slow.length} slow · ${formatLatency(worst?.latencyNs)}` })
507
+ }
508
+ // With publishNotReadyAddresses the dataplane routes to NotReady Pods too, so
509
+ // they are eligible endpoints - calling them "excluded, never routed to" is
510
+ // false precisely when someone is debugging a not-ready Pod that IS serving.
511
+ const notReady = publishNotReady ? [] : roster.filter((p) => !p.ready)
512
+ if (notReady.length > 0) {
513
+ out.push({ mark: 'excluded', text: `${notReady.length} NotReady — never routed to` })
514
+ }
515
+ // The roster is capped for payload size; anything past it is eligible but
516
+ // unobserved. Unprobed is not proven, so it must be stated, not implied.
517
+ const omitted = total - roster.length
518
+ if (omitted > 0) out.push({ mark: 'untested', text: `${omitted} eligible, not probed` })
519
+ return out
520
+ }
521
+
522
+ /**
523
+ * Short forms of each vantage's mechanism, for the capsule only.
524
+ *
525
+ * `sub` renders at 10px in a 172px box - about 28 characters per line - and
526
+ * every full mech string is 33-46, so each capsule wrapped to two lines and paid
527
+ * a row of height for it. The full wording still renders in the inspector under
528
+ * MECHANISM, where there is room for it.
529
+ */
530
+ const SHORT_MECH: Record<string, string> = {
531
+ local: 'your machine, your network',
532
+ apiserver: 'relayed by Kubernetes',
533
+ incluster: 'a throwaway Pod, in-cluster',
534
+ 'radar-incluster': 'Radar’s own Pod',
535
+ caller: 'your app’s own Pod',
536
+ external: 'from the public internet',
537
+ }
538
+
539
+ /** Short forms for the origin capsule's tag - the full wording would overrun
540
+ * the box and collide with the node beside it. */
541
+ const SHORT_KIND_TAG: Record<string, string> = {
542
+ SYNTHETIC: 'SYNTHETIC',
543
+ 'REAL CLIENT': 'REAL CLIENT',
544
+ 'AS A CLIENT': 'AS A CLIENT',
545
+ 'REAL CALLER': 'REAL CALLER',
546
+ 'RELAYED · NOT A CALLER': 'RELAYED',
547
+ }
548
+
549
+ interface Placed {
550
+ col: number
551
+ row: number
552
+ w: number
553
+ node: Omit<GraphNode, 'x' | 'y' | 'w' | 'h'>
554
+ }
555
+
556
+ interface BuildOpts {
557
+ trace: Trace
558
+ route?: RouteResult
559
+ origin: Origin
560
+ /** Every vantage that can actually run, so all of them are drawn at once and
561
+ * the comparison between them is visible rather than reconstructed across
562
+ * clicks. Defaults to just the selected one. */
563
+ origins?: Origin[]
564
+ stale?: boolean
565
+ running?: boolean
566
+ }
567
+
568
+ /**
569
+ * The words for an origin that produced no usable evidence for the scenario.
570
+ * ONE helper for the capsule and every edge pill - two call sites drifted into
571
+ * telling simultaneous different stories ("not routable" on the capsule beside
572
+ * "couldn't test" on the edge). "not routable" itself is retired: it read as a
573
+ * NETWORK verdict about the target, the one claim a vantage whose dials all
574
+ * skipped has no standing to make. 'blocked' means tried-and-couldn't - say
575
+ * that, and let the skip reason on hover carry the why. The in-cluster ATTEMPT
576
+ * whose probe never started keeps its own words: execution failure is its own
577
+ * state, and "not tested" would erase the attempt.
578
+ */
579
+ export function originNoEvidenceLabel(o: Pick<Origin, 'id' | 'mark' | 'unavailable'>): string {
580
+ if (o.mark === 'inconclusive') return 'ran — kept informational'
581
+ if (o.mark === 'denied') return 'not permitted'
582
+ if (o.id === 'incluster' && o.mark === 'blocked' && o.unavailable) return 'test couldn’t run'
583
+ if (o.mark === 'blocked') return 'couldn’t test'
584
+ return 'not tested'
585
+ }
586
+
587
+ /** One origin's own verdict on the selected route - the mark for its entry edge
588
+ * and the words for its pill. Computed per origin so a vantage that never ran
589
+ * is drawn as never-run beside one that succeeded, instead of the whole graph
590
+ * speaking for whichever is selected. */
591
+ export function originEntryEvidence(
592
+ trace: Trace,
593
+ route: RouteResult | undefined,
594
+ o: Origin,
595
+ opts: { stale?: boolean; running?: boolean } = {},
596
+ ): { mark: Mark; label: string; title?: string } {
597
+ // A vantage mid-run has no evidence YET, and the evidence gate below would
598
+ // swallow that - so being in flight is stated first. It is the whole truth
599
+ // about this origin right now.
600
+ if (opts.running) return { mark: 'running', label: 'testing now' }
601
+ const ev = originRouteEvidence(route, o.id, trace.runVantage)
602
+ const asSeen = ev.kind === 'none' ? undefined : ev.result
603
+ const fromConfig = ev.kind === 'config'
604
+ const hasEvidence = ev.kind === 'own' || (ev.kind === 'rollup' && originProducedEvidence(o))
605
+ const rm: Mark = asSeen ? routeMark(asSeen, opts) : 'untested'
606
+ // Only the API-server RELAY may downgrade a proved result. A laptop is also
607
+ // outside the cluster, but a request it sends to a real Ingress address goes
608
+ // over the real network and exercises the real path from the ingress inward -
609
+ // calling that "proxied" (which means "bypassed the dataplane") was false.
610
+ const relayed = o.id === 'apiserver'
611
+ // No row for this route does not mean this vantage did nothing - it may have
612
+ // dialled the front door. Report what it actually reached, so the capsule's
613
+ // words agree with its glyph instead of showing a proved dot beside the
614
+ // phrase "not tested".
615
+ const alongPath = !hasEvidence && !fromConfig ? bestHopEvidence(trace, o, opts) : undefined
616
+ const noEvidenceLabel = originNoEvidenceLabel(o)
617
+ const mark: Mark = fromConfig
618
+ ? 'config'
619
+ : !hasEvidence
620
+ ? alongPath?.mark ?? (o.mark === 'denied' || o.mark === 'blocked' ? o.mark : 'untested')
621
+ : relayed && rm === 'proved'
622
+ ? 'proxied'
623
+ : rm
624
+ const label = fromConfig
625
+ ? 'broken as configured'
626
+ : !hasEvidence
627
+ ? alongPath?.label ?? noEvidenceLabel
628
+ : shortEvidence(asSeen?.evidence) || (relayed ? 'relayed by Kubernetes' : 'request')
629
+ return { mark, label, title: hasEvidence || fromConfig ? asSeen?.evidence : alongPath?.title }
630
+ }
631
+
632
+ /**
633
+ * Builds the laned graph for ONE scenario seen from ONE origin.
634
+ *
635
+ * Selecting a different origin genuinely re-routes the graph rather than
636
+ * relabelling it: a control-plane origin enters the subject from the upper lane
637
+ * through an apiserver node, having bypassed kube-proxy, NetworkPolicy and the
638
+ * mesh entirely. Drawing both the same way would be the central lie this view
639
+ * exists to prevent.
640
+ */
641
+ export function buildGraph({ trace, route, origin, origins, stale, running }: BuildOpts): GraphModel {
642
+ const placed: Placed[] = []
643
+ const originIsControl = origin.lane === 'control'
644
+
645
+ const subjectId = refId(trace.subject)
646
+ const subjectNodeId = `n:${subjectId}`
647
+ const downstream = trace.downstream ?? []
648
+ const subjectHop = downstream.find((h) => refId(h.resource) === subjectId)
649
+ const upstreams = trace.upstreams ?? []
650
+
651
+ /**
652
+ * Upstreams are PARALLEL entry points, and a subject can have several
653
+ * backends each with their own Pods. Laying all of that out as one series
654
+ * invented a path that does not exist - two Ingresses read as
655
+ * "Ingress A then Ingress B", and only the first backend's Pods survived.
656
+ * Parentage follows the same rule the topology converter uses.
657
+ */
658
+ const backends: { hop: Hop; id: string }[] = []
659
+ const podGroups: { hop: Hop; id: string; parentId: string }[] = []
660
+ let lastServiceId = subjectNodeId
661
+ for (const dn of downstream) {
662
+ const base = refId(dn.resource)
663
+ if (base === subjectId) {
664
+ lastServiceId = subjectNodeId
665
+ continue
666
+ }
667
+ if (isPodsHop(dn)) {
668
+ // An unnamed Pods hop's id collapses to the same value for every backend,
669
+ // so it must be scoped to its owning Service or later groups are dropped.
670
+ podGroups.push({ hop: dn, id: dn.resource?.name ? `n:${base}` : `${lastServiceId}::pods`, parentId: lastServiceId })
671
+ } else {
672
+ const id = `n:${base}`
673
+ backends.push({ hop: dn, id })
674
+ lastServiceId = id
675
+ }
676
+ }
677
+
678
+ /** The scenario names a host; only the entries serving it are on this path.
679
+ * Shares its matcher with scenario grouping so dimming and grouping can never
680
+ * disagree about which front door serves a host. */
681
+ const routeHost = routeHostOf(route?.route ?? '')
682
+ const servesRoute = (h: Hop): boolean => !!routeHost && declaredHosts(h).some((d) => hostMatches(d, routeHost))
683
+ // Prefer this origin's OWN result over the merged rollup. Without it the graph
684
+ // painted whatever the worst vantage saw under the selected vantage's name -
685
+ // the central misattribution this view exists to prevent. originProducedEvidence
686
+ // remains the fallback gate for traces carrying no per-vantage breakdown.
687
+ const ev = originRouteEvidence(route, origin.id)
688
+ const asSeen = ev.kind === 'none' ? undefined : ev.result
689
+
690
+ const matched = upstreams.filter(servesRoute)
691
+ const activeUpstreams = matched.length > 0 ? matched : upstreams
692
+
693
+ /**
694
+ * Fan-out branches are peers, and until now every one of them rendered
695
+ * identically - so on a Gateway with seven attached routes, changing the
696
+ * selected scenario changed nothing on screen and there was no way to tell
697
+ * which branch you were diagnosing. Only entry points were ever dimmed.
698
+ *
699
+ * A branch is on the selected path when it serves the scenario's host or
700
+ * carries its backend. When NOTHING matches we dim nothing: a graph where
701
+ * every branch is greyed out says "none of this is relevant", which is worse
702
+ * than saying nothing at all.
703
+ */
704
+ const routeTarget = (route?.target ?? '').split(':')[0].trim().toLowerCase()
705
+ // Namespace is part of the identity: a Gateway API backendRef can point at a
706
+ // same-named Service in another namespace, and matching on name alone would
707
+ // focus the wrong branch.
708
+ const routeTargetNs = (route?.targetNamespace || trace.subject.namespace || '').toLowerCase()
709
+ const onSelectedPath = (h: Hop): boolean => {
710
+ if (servesRoute(h)) return true
711
+ const name = (h.resource?.name ?? '').toLowerCase()
712
+ if (!routeTarget || name !== routeTarget) return false
713
+ const ns = (h.resource?.namespace || '').toLowerCase()
714
+ return !ns || !routeTargetNs || ns === routeTargetNs
715
+ }
716
+ const matchedBackends = backends.filter((b) => onSelectedPath(b.hop))
717
+ const focusBranches = matchedBackends.length > 0 && matchedBackends.length < backends.length
718
+
719
+ /**
720
+ * Exception-first. A Gateway with seven attached routes rendered seven equally
721
+ * loud cards that overflowed the pane, and six of them were fine. What the
722
+ * operator needs is the selected branch and anything WRONG; the quiet
723
+ * remainder is context and can be one line.
724
+ *
725
+ * Collapsing is by relevance, never by position - dropping "the last three"
726
+ * would hide whichever route happened to sort late.
727
+ */
728
+ const worthExpanding = (b: { hop: Hop }): boolean =>
729
+ onSelectedPath(b.hop) || (b.hop.findings ?? []).length > 0
730
+ const expanded = backends.length > FAN_EXPANDED_MAX ? backends.filter(worthExpanding) : backends
731
+ const collapsed = backends.filter((b) => !expanded.includes(b))
732
+
733
+ const hopSub = (h: Hop): string => {
734
+ const c = h.config
735
+ if (c?.clusterIP) return `ClusterIP ${c.clusterIP}`
736
+ if (c?.addresses?.length) return c.addresses.join(', ')
737
+ if (c?.hostnames?.length) return c.hostnames.join(', ')
738
+ if (c?.serviceType) return c.serviceType
739
+ return h.resource?.namespace ?? ''
740
+ }
741
+
742
+ // ---- columns follow depth in the branch, not position in a list ----
743
+ const COL_ORIGIN = 0
744
+ const COL_ENTRY = 1
745
+ const colSubject = upstreams.length > 0 ? COL_ENTRY + 1 : COL_ENTRY
746
+ const colBackend = colSubject + 1
747
+ // The workload sits between the Service and its Pods, the way the topology
748
+ // view draws it. It routes nothing - both its edges are declared, not observed
749
+ // - but it is what the reader opened, and a network view that omits it makes
750
+ // them hold the connection in their head. Only in the single-chain case: a
751
+ // multi-backend entry has no single workload to place.
752
+ // Only the vantage actually being exercised reads as running. Passing the
753
+ // flag to every origin made all of them animate during one probe run - the
754
+ // same "one vantage's state shown under another's name" this view exists to
755
+ // prevent, in motion.
756
+ const isRunning = (id: string) => !!running && id === 'incluster'
757
+
758
+ const workloadNodeId = 'n:workload'
759
+ // The producer resolves this from the Pods' owner chain, so it is present for
760
+ // ANY subject - a Service traced on its own gets its Deployment too, not just
761
+ // the workload-scoped tab. Its absence is a statement, not a gap: the producer
762
+ // returns nil when the selected Pods have no single owner, and substituting
763
+ // the workload the reader happened to open would claim another workload's
764
+ // Pods for it.
765
+ const theWorkload = downstream.find(isPodsHop)?.config?.workload
766
+ const showWorkload = !!theWorkload && backends.length === 0
767
+ const colWorkload = showWorkload ? colSubject + 1 : -1
768
+ const colPods = backends.length > 0 ? colBackend + 1 : showWorkload ? colSubject + 2 : colSubject + 1
769
+
770
+ /**
771
+ * Two mechanisms never touch the front door, so drawing them through it is a
772
+ * lie the picture tells before any label is read:
773
+ *
774
+ * - the API-server proxy dials the Service/Pod subresource directly;
775
+ * - the in-cluster Job dials the BACKEND Service - internal/trace's own
776
+ * ProbeRequest doc says the in-cluster dial bypasses the front door, and
777
+ * the server only stamps its results onto downstream hops.
778
+ *
779
+ * Only the workstation vantage actually requests the declared hostname. When
780
+ * the origin bypasses, it enters beside the declared entries rather than
781
+ * through them: both then point at the subject, which is the truth - one
782
+ * exercised, one merely configured.
783
+ */
784
+ // Route kinds carry no address; a probe reaches their backends, never them.
785
+ const subjectIsAddressable = trace.subject.kind !== 'HTTPRoute' && trace.subject.kind !== 'GRPCRoute'
786
+ const skipsEntries = (id: string) => upstreams.length > 0 && (id === 'apiserver' || id === 'incluster')
787
+ const originSkipsEntries = skipsEntries(origin.id)
788
+
789
+ // Every runnable vantage is drawn, in ONE stable order that does not depend on
790
+ // which is selected. Placing the selected one first made the capsules swap
791
+ // places on every click, so the thing you just clicked moved out from under
792
+ // the cursor and the rail's spatial memory was lost.
793
+ const liveOrigins = (origins?.length ? origins : [origin]).filter((o) => !o.unsupported)
794
+ const rowInCol = new Map<number, number>([[COL_ENTRY, upstreams.length]])
795
+ const originPlacement = new Map<string, { col: number; skips: boolean }>()
796
+ for (const o of liveOrigins) {
797
+ const skips = skipsEntries(o.id)
798
+ const col = skips ? COL_ENTRY : COL_ORIGIN
799
+ const row = rowInCol.get(col) ?? 0
800
+ rowInCol.set(col, row + 1)
801
+ originPlacement.set(o.id, { col, skips })
802
+ // The verdict rides on the capsule rather than on the edge: several origins
803
+ // fan into one node, so their edge pills genuinely want the same point, and
804
+ // "what this vantage found" is a property of the vantage anyway.
805
+ const verdict = originEntryEvidence(trace, route, o, { stale, running: isRunning(o.id) })
806
+ placed.push({
807
+ col,
808
+ row,
809
+ w: COL_W.origin,
810
+ node: {
811
+ id: `origin:${o.id}`,
812
+ kind: 'TESTED FROM',
813
+ name: o.name,
814
+ sub: SHORT_MECH[o.id] ?? o.mech,
815
+ // title carries the untruncated evidence - the row truncates visually
816
+ // and a cut "HTTP 308 · reached, redirect…" with no hover hid the
817
+ // destination it redirected to. The vantage's own dial count rides
818
+ // along: zero pixels, and the hover says how much work stands behind
819
+ // the one-line verdict.
820
+ anomalies: [
821
+ {
822
+ mark: verdict.mark,
823
+ text: verdict.label,
824
+ title: (() => {
825
+ const base = verdict.title || verdict.label
826
+ const n = [...(trace.upstreams ?? []), ...(trace.downstream ?? [])]
827
+ .flatMap((h) => h.probes ?? [])
828
+ .filter((pr) => !pr.skipped && originOf(pr, trace.runVantage) === o.id).length
829
+ return n > 0 ? `${base} — ${n} check${n === 1 ? '' : 's'} from this vantage` : base
830
+ })(),
831
+ },
832
+ ],
833
+ // Offered where the gap is: this vantage says "not tested", and the
834
+ // control that would change that sits on it. Only when it has produced
835
+ // nothing yet - an origin with results has nothing to run.
836
+ action:
837
+ o.id === 'incluster' && !originProducedEvidence(o)
838
+ ? {
839
+ text: 'Run this test',
840
+ kind: 'run-in-cluster' as const,
841
+ // Fail closed: permission alone isn't enough - with no runnable
842
+ // request the click is a guaranteed no-op (redis: every port is
843
+ // non-HTTP until native-protocol probing lands).
844
+ disabledReason:
845
+ o.unavailable ??
846
+ (traceInClusterRunnable(trace)
847
+ ? undefined
848
+ : 'No path on this resource has a request Radar can send from inside the cluster.'),
849
+ }
850
+ : undefined,
851
+ tone: 'info',
852
+ tag: SHORT_KIND_TAG[o.kindTag] ?? o.kindTag,
853
+ isOrigin: true,
854
+ // Dimmed when it is not the one driving the rest of the graph. Its EDGE
855
+ // keeps full-strength marking - that mark is why it is drawn at all.
856
+ dim: o.id !== origin.id,
857
+ lane: o.lane === 'control' ? 'control' : 'data',
858
+ },
859
+ })
860
+ }
861
+ const others = liveOrigins.filter((o) => o.id !== origin.id)
862
+
863
+ upstreams.forEach((up, i) => {
864
+ const active = activeUpstreams.includes(up)
865
+ placed.push({
866
+ col: COL_ENTRY,
867
+ row: i,
868
+ w: COL_W.hop,
869
+ node: {
870
+ id: `n:${refId(up.resource)}`,
871
+ kind: (up.resource?.kind || 'ENTRY').toUpperCase(),
872
+ name: up.resource?.name ?? 'entry',
873
+ sub: active ? hopSub(up) : 'does not serve this host',
874
+ tone: hopTone(up),
875
+ notes: hopNotes(up),
876
+ // Entries that do not serve the selected host are shown, but dimmed -
877
+ // hiding them would misrepresent what is attached to this resource.
878
+ dim: !active,
879
+ ref: up.resource,
880
+ hop: up,
881
+ lane: 'data',
882
+ },
883
+ })
884
+ })
885
+
886
+ placed.push({
887
+ col: colSubject,
888
+ row: 0,
889
+ w: COL_W.hop,
890
+ node: {
891
+ id: subjectNodeId,
892
+ kind: (trace.subject.kind || 'SERVICE').toUpperCase(),
893
+ name: `${trace.subject.name}${route?.target ? ` ${route.target}` : ''}`,
894
+ sub: subjectHop ? hopSub(subjectHop) : trace.subject.namespace || '',
895
+ tone: hopTone(subjectHop),
896
+ notes: hopNotes(subjectHop),
897
+ ref: trace.subject,
898
+ hop: subjectHop,
899
+ lane: 'data',
900
+ },
901
+ })
902
+
903
+ expanded.forEach((b, i) => {
904
+ const onPath = !focusBranches || matchedBackends.includes(b)
905
+ placed.push({
906
+ col: colBackend,
907
+ row: i,
908
+ w: COL_W.hop,
909
+ node: {
910
+ id: b.id,
911
+ kind: (b.hop.resource?.kind || 'BACKEND').toUpperCase(),
912
+ name: b.hop.resource?.name ?? '',
913
+ sub: onPath ? hopSub(b.hop) : 'not on the selected path',
914
+ tone: hopTone(b.hop),
915
+ dim: !onPath,
916
+ notes: hopNotes(b.hop),
917
+ ref: b.hop.resource,
918
+ hop: b.hop,
919
+ lane: 'data',
920
+ },
921
+ })
922
+ })
923
+
924
+ if (collapsed.length > 0) {
925
+ placed.push({
926
+ col: colBackend,
927
+ row: expanded.length,
928
+ w: COL_W.hop,
929
+ node: {
930
+ id: 'collapsed:backends',
931
+ kind: `${collapsed.length} MORE`,
932
+ name: collapsed.length === 1 ? '1 more route' : `${collapsed.length} more routes`,
933
+ // Named so the row is a statement, not a truncation: these are quiet
934
+ // because nothing was found on them, not because they were dropped.
935
+ sub: 'nothing found · not on the selected path',
936
+ tone: 'unknown',
937
+ dim: true,
938
+ lane: 'data',
939
+ },
940
+ })
941
+ }
942
+
943
+ const deliveryBlocked = (asSeen ? routeMark(asSeen, { stale, running }) : 'untested') === 'failed'
944
+ if (showWorkload && theWorkload) {
945
+ placed.push({
946
+ col: colWorkload,
947
+ row: 0,
948
+ w: WORKLOAD_W,
949
+ node: {
950
+ id: workloadNodeId,
951
+ kind: theWorkload.kind.toUpperCase(),
952
+ name: theWorkload.name,
953
+ ref: theWorkload,
954
+ // Deliberately thin. This is a network view; the workload's own health,
955
+ // replicas and rollout state live on its other tabs.
956
+ sub: 'runs these Pods',
957
+ tone: 'unknown',
958
+ lane: 'data',
959
+ },
960
+ })
961
+ }
962
+
963
+ podGroups.forEach((g, i) => {
964
+ const hop = g.hop
965
+ const roster = hop.config?.pods ?? []
966
+ const total = hop.config?.podTotal ?? roster.length
967
+ const ready = typeof hop.meta?.ready === 'number' ? (hop.meta.ready as number) : roster.filter((p) => p.ready).length
968
+ const selected = typeof hop.meta?.selected === 'number' ? (hop.meta.selected as number) : total
969
+ const publishNotReady = !!hop.meta?.publishNotReadyAddresses
970
+ const probes = probesFromOrigin(hop.probes ?? [], origin, trace.runVantage)
971
+
972
+ // Anomaly-first: keeping the FIRST six rows hid the failing or excluded Pod
973
+ // behind five healthy ones, which is exactly the row worth showing.
974
+ const rank = (x: PodStatus): number => {
975
+ const mine = probesForPod(x, probes).filter((q) => !q.skipped)
976
+ if (mine.some((q) => !q.ok || q.tone === 'unhealthy')) return 0
977
+ if (mine.some(isSlow)) return 1
978
+ if (!x.ready) return 2
979
+ if (mine.length === 0) return 3
980
+ return 4
981
+ }
982
+ const ordered = [...roster].sort((a, b) => rank(a) - rank(b))
983
+ const podRows: PodRow[] = []
984
+ for (const p of ordered.slice(0, POD_ROW_MAX)) {
985
+ const mine = probesForPod(p, probes).filter((x) => !x.skipped)
986
+ const failed = mine.find((x) => !x.ok || x.tone === 'unhealthy')
987
+ let mark: Mark
988
+ let detail: string
989
+ if (!p.ready && !publishNotReady) {
990
+ mark = 'excluded'
991
+ detail = 'not ready — nothing sent here'
992
+ } else if (failed) {
993
+ mark = 'failed'
994
+ detail = failed.detail || failed.error || 'refused'
995
+ } else if (mine.length === 0 && deliveryBlocked) {
996
+ mark = 'blocked'
997
+ detail = 'not reached — failed earlier'
998
+ } else if (mine.length === 0) {
999
+ mark = 'untested'
1000
+ detail = 'not tested'
1001
+ } else {
1002
+ const slowest = mine.filter(isSlow).sort((a, b) => (b.latencyNs ?? 0) - (a.latencyNs ?? 0))[0]
1003
+ mark = slowest ? 'slow' : 'proved'
1004
+ const best = mine.find((x) => x.layer === 'http') ?? mine[0]
1005
+ detail = slowest ? `slow · ${formatLatency(slowest.latencyNs)}` : shortEvidence(best?.detail) || 'reached'
1006
+ }
1007
+ if (!p.ready && publishNotReady) detail = `${detail} · not ready, sent traffic anyway`
1008
+ // The Pods hop carries its own namespace, which differs from the subject's
1009
+ // for a cross-namespace backend - navigating by the subject's would open a
1010
+ // Pod that isn't there.
1011
+ podRows.push({
1012
+ name: p.name,
1013
+ mark,
1014
+ detail,
1015
+ ref: { kind: 'Pod', name: p.name, namespace: hop.resource?.namespace || trace.subject.namespace },
1016
+ })
1017
+ }
1018
+
1019
+ placed.push({
1020
+ col: colPods,
1021
+ row: i,
1022
+ w: COL_W.pods,
1023
+ node: {
1024
+ id: g.id,
1025
+ kind: 'PODS',
1026
+ // "taking traffic" claims observed delivery; readiness only establishes
1027
+ // ELIGIBILITY. The inspector already says "eligible" for this same
1028
+ // number - the graph must not contradict it.
1029
+ name: `${ready} of ${selected} eligible`,
1030
+ // The name line already counts eligibility - "every selected Pod is
1031
+ // eligible" under "1 of 1 eligible" said the same fact twice. The sub
1032
+ // only speaks when it adds something the count can't say.
1033
+ sub: publishNotReady
1034
+ ? 'not-ready Pods are sent traffic too'
1035
+ : ready === selected
1036
+ ? ''
1037
+ : `${selected - ready} not eligible`,
1038
+ tone: hopTone(hop),
1039
+ hop,
1040
+ notes: hopNotes(hop),
1041
+ anomalies: populationAnomalies(roster, total, probes, publishNotReady),
1042
+ podRows,
1043
+ moreRows: Math.max(0, roster.length - POD_ROW_MAX),
1044
+ lane: 'data',
1045
+ },
1046
+ })
1047
+ })
1048
+
1049
+ // ---- resolve geometry ----
1050
+ const usedCols = [...new Set(placed.map((p) => p.col))].sort((a, b) => a - b)
1051
+ const colX = new Map<number, number>()
1052
+ const colW = new Map<number, number>()
1053
+ const wlCol = placed.find((p) => p.node.id === workloadNodeId)?.col
1054
+ let x = 0
1055
+ for (let i = 0; i < usedCols.length; i++) {
1056
+ const c = usedCols[i]
1057
+ const w = Math.max(...placed.filter((p) => p.col === c).map((p) => p.w))
1058
+ colW.set(c, w)
1059
+ colX.set(c, x)
1060
+ // Both gutters AROUND the workload column tighten - it is a less
1061
+ // interesting object for a network view and was stretching the
1062
+ // Service→Deployment→Pods stretch into dead space.
1063
+ const gutter = c === wlCol || usedCols[i + 1] === wlCol ? WORKLOAD_GUTTER : GUTTER
1064
+ x += w + gutter
1065
+ }
1066
+
1067
+ const heightOf = (p: Placed) => estHeight(p.node)
1068
+
1069
+ const controlPlaced = placed.filter((p) => p.node.lane === 'control')
1070
+ const dataPlaced = placed.filter((p) => p.node.lane === 'data')
1071
+
1072
+ const nodes: GraphNode[] = []
1073
+ const pos = new Map<string, GraphNode>()
1074
+
1075
+ /**
1076
+ * Stacks one lane's nodes by column and returns its height.
1077
+ *
1078
+ * The control lane used to pin every node to a single y, which was fine while
1079
+ * only one vantage was ever drawn - and put two on top of each other the
1080
+ * moment they all became visible at once.
1081
+ */
1082
+ const layoutLane = (list: Placed[], top: number): number => {
1083
+ const byCol = new Map<number, Placed[]>()
1084
+ for (const p of list) {
1085
+ const arr = byCol.get(p.col) ?? []
1086
+ arr.push(p)
1087
+ byCol.set(p.col, arr)
1088
+ }
1089
+ const colHeights = new Map<number, number>()
1090
+ for (const [c, l] of byCol) {
1091
+ colHeights.set(c, l.reduce((sum, p) => sum + heightOf(p), 0) + (l.length - 1) * ROW_GAP)
1092
+ }
1093
+ const laneH = Math.max(0, ...colHeights.values())
1094
+ for (const [c, l] of byCol) {
1095
+ let y = top + (laneH - colHeights.get(c)!) / 2
1096
+ for (const p of [...l].sort((a, b) => a.row - b.row)) {
1097
+ const h = heightOf(p)
1098
+ const n: GraphNode = { ...p.node, x: colX.get(c)!, y, w: colW.get(c)!, h }
1099
+ nodes.push(n)
1100
+ pos.set(n.id, n)
1101
+ y += h + ROW_GAP
1102
+ }
1103
+ }
1104
+ return laneH
1105
+ }
1106
+
1107
+ const controlTop = controlPlaced.length > 0 ? LANE_PAD.top : 0
1108
+ const controlH = layoutLane(controlPlaced, controlTop)
1109
+ const dataTop = controlPlaced.length > 0 ? controlTop + controlH + LANE_PAD.bottom + LANE_GAP + LANE_PAD.top : LANE_PAD.top
1110
+ layoutLane(dataPlaced, dataTop)
1111
+
1112
+ // ---- edges ----
1113
+ const edges: GraphEdge[] = []
1114
+ const geom = new Map<string, Cubic>()
1115
+ const brackets: { d: string }[] = []
1116
+ const crossesLanes = new Set<string>()
1117
+
1118
+ // `full` is the untruncated text for the hover. Without it a shortened pill
1119
+ // label became the tooltip too, so the explanation was discarded rather than
1120
+ // moved somewhere it fits.
1121
+ /**
1122
+ * Where along an edge its pill sits, as a fraction of the curve.
1123
+ *
1124
+ * Every pill used to sit at the midpoint, which is fine for one edge and
1125
+ * collides the moment several fan into the same node: their midpoints are
1126
+ * genuinely the same point. Slotting moves them apart ALONG their own curve,
1127
+ * so each stays on the line it describes instead of floating beside it.
1128
+ */
1129
+ const pillT = (slot: number): number => {
1130
+ if (slot <= 0) return 0.5
1131
+ const step = 0.13
1132
+ // Alternate above/below the midpoint so a fan spreads both ways.
1133
+ const dir = slot % 2 === 1 ? -1 : 1
1134
+ const mag = Math.ceil(slot / 2) * step
1135
+ return Math.min(0.78, Math.max(0.22, 0.5 + dir * mag))
1136
+ }
1137
+
1138
+ const edgeTo = new Map<string, string>()
1139
+ // Edges belonging to a vantage OTHER than the selected one. They are drawn so
1140
+ // the comparison is visible, but they must never speak for the selection -
1141
+ // reading a break off them let a workstation failure order the report while
1142
+ // the in-cluster vantage the reader had chosen was succeeding.
1143
+ const foreignEdges = new Set<string>()
1144
+ const connect = (id: string, fromId: string, toId: string, mark: Mark, label: string, full?: string, slot = 0, boundarySpan?: 'start' | 'continuation') => {
1145
+ const a = pos.get(fromId)
1146
+ const b = pos.get(toId)
1147
+ if (!a || !b) return
1148
+ if (a.lane !== b.lane) crossesLanes.add(id)
1149
+ const x1 = a.x + a.w
1150
+ const y1 = a.y + a.h / 2
1151
+ const x2 = b.x
1152
+ const y2 = b.y + b.h / 2
1153
+ const dx = Math.max(30, (x2 - x1) * 0.45)
1154
+ // An edge that SKIPS a column runs straight through whatever sits in it -
1155
+ // the in-cluster probe bypassing an HTTPRoute drew its line, and parked its
1156
+ // pill, on top of the very node it does not touch. Route around instead:
1157
+ // detour under the obstacles when the curve would cross them.
1158
+ const lo = Math.min(y1, y2)
1159
+ const hi = Math.max(y1, y2)
1160
+ const blocked = [...pos.values()].filter(
1161
+ (n) =>
1162
+ n.id !== fromId &&
1163
+ n.id !== toId &&
1164
+ n.x > Math.min(x1, x2) &&
1165
+ n.x + n.w < Math.max(x1, x2) &&
1166
+ n.y < hi + DETOUR_GAP &&
1167
+ n.y + n.h > lo - DETOUR_GAP,
1168
+ )
1169
+ // A cubic only travels ~3/4 of the way toward its control points, so setting
1170
+ // them AT the clearance line leaves the curve short of it - fine for a short
1171
+ // node, straight through a tall one. Solve for the control height that puts
1172
+ // the CURVE itself past the obstacle:
1173
+ // y(0.5) = (y1 + y2)/8 + (3/4)·D ⇒ D = (clear - (y1 + y2)/8) / 0.75
1174
+ //
1175
+ // The SIDE is chosen, never assumed. An edge dropping from the control lane
1176
+ // is already above the obstacle; forcing it under sends it swooping below
1177
+ // and back up, which is longer and reads as a detour to nowhere. Go round
1178
+ // whichever way the line is already passing.
1179
+ const controlFor = (clear: number) => (clear - (y1 + y2) / 8) / 0.75
1180
+ let cy1 = y1
1181
+ let cy2 = y2
1182
+ if (blocked.length > 0) {
1183
+ const mid = (y1 + y2) / 2
1184
+ const above = Math.min(...blocked.map((n) => n.y)) - DETOUR_GAP
1185
+ const below = Math.max(...blocked.map((n) => n.y + n.h)) + DETOUR_GAP
1186
+ const goAbove = Math.abs(mid - above) < Math.abs(mid - below)
1187
+ const overTop = goAbove && controlFor(above) > LANE_PAD.top
1188
+
1189
+ // Clearing the curve's MIDPOINT is not the same as clearing the node. An
1190
+ // asymmetric edge has already descended by the time it crosses the node's
1191
+ // far edge, so it grazed the corner while the midpoint sat comfortably
1192
+ // clear. Sample the span the node actually occupies and push the control
1193
+ // until every sampled point clears it.
1194
+ const worstOverlap = (control: number): number => {
1195
+ const c: Cubic = { x1, y1, x2, y2, dx, cy1: control, cy2: control }
1196
+ let worst = 0
1197
+ for (let i = 1; i < 20; i++) {
1198
+ const pt = bezierAt(c, i / 20)
1199
+ for (const n of blocked) {
1200
+ if (pt.x < n.x - 2 || pt.x > n.x + n.w + 2) continue
1201
+ const need = overTop ? n.y - DETOUR_GAP : n.y + n.h + DETOUR_GAP
1202
+ const over = overTop ? pt.y - need : need - pt.y
1203
+ if (over > worst) worst = over
1204
+ }
1205
+ }
1206
+ return worst
1207
+ }
1208
+
1209
+ let control = controlFor(overTop ? above : below)
1210
+ for (let i = 0; i < 8; i++) {
1211
+ const over = worstOverlap(control)
1212
+ if (over <= 0.5) break
1213
+ control += overTop ? -over : over
1214
+ }
1215
+ cy1 = control
1216
+ cy2 = control
1217
+ }
1218
+ const cubic: Cubic = { x1, y1, x2, y2, dx, cy1, cy2 }
1219
+ // Evaluate the SAME cubic the path draws, so the pill is on the line by
1220
+ // construction rather than by a midpoint approximation that only holds when
1221
+ // the curve happens to be symmetric.
1222
+ const t = pillT(slot)
1223
+ const p = bezierAt(cubic, t)
1224
+ geom.set(id, cubic)
1225
+ edgeTo.set(id, toId)
1226
+ edges.push({
1227
+ id,
1228
+ d: cubicPath(cubic),
1229
+ mark,
1230
+ boundary: boundarySpan,
1231
+ label: truncate(label),
1232
+ title: full || label,
1233
+ px: p.x,
1234
+ py: p.y,
1235
+ })
1236
+ }
1237
+
1238
+ const originNodeId = `origin:${origin.id}`
1239
+ // 'own' is this origin's result. 'none' means the producer told us it did not
1240
+ // test this route, which outranks anything it did on OTHER routes - so the
1241
+ // coarse pooled gate only applies to legacy traces with no breakdown at all.
1242
+ const hasEvidence = ev.kind === 'own' || (ev.kind === 'rollup' && originProducedEvidence(origin))
1243
+ // Known broken from configuration: true of every vantage, dialled by none.
1244
+ const fromConfig = ev.kind === 'config'
1245
+ const routeMarkNow: Mark = asSeen ? routeMark(asSeen, { stale, running: isRunning(origin.id) }) : 'untested'
1246
+ // A relay can never read as proof: it bypassed the real network path however
1247
+ // clean the response was.
1248
+ const entryMark: Mark = isRunning(origin.id)
1249
+ ? 'running'
1250
+ : fromConfig
1251
+ ? 'config'
1252
+ : !hasEvidence
1253
+ ? origin.mark
1254
+ : origin.id === 'apiserver' && routeMarkNow === 'proved'
1255
+ ? 'proxied'
1256
+ : routeMarkNow
1257
+ const originBlocked = !!origin.unavailable && origin.mark === 'blocked'
1258
+ const noEvidenceLabel = originNoEvidenceLabel(origin)
1259
+ const entryLabel = isRunning(origin.id)
1260
+ ? 'testing now'
1261
+ : fromConfig
1262
+ ? 'broken as configured'
1263
+ : !hasEvidence
1264
+ ? noEvidenceLabel
1265
+ : shortEvidence(asSeen?.evidence) || (origin.id === 'apiserver' ? 'relayed by Kubernetes' : 'request')
1266
+ // The whole sentence, for the hover.
1267
+ const entryTitle = !hasEvidence && !fromConfig ? undefined : asSeen?.evidence || undefined
1268
+
1269
+ // Only the backends this route resolves to - shared by the selected origin's
1270
+ // bypass edge and by every other origin's.
1271
+ const bypassBackends = focusBranches ? matchedBackends : expanded
1272
+ // The request enters at the entry points that serve this host; everything
1273
+ // after is configuration, drawn dotted. There is no segment-local evidence to
1274
+ // claim otherwise.
1275
+ if (upstreams.length > 0 && !originSkipsEntries) {
1276
+ for (const up of upstreams) {
1277
+ const id = `n:${refId(up.resource)}`
1278
+ const active = activeUpstreams.includes(up)
1279
+ // This entry's OWN result for this origin, when it has one. The route
1280
+ // rollup is the fallback, not the source: it is built from the backend's
1281
+ // probes and cannot see a front-door dial.
1282
+ const own = active ? hopEvidenceFor(up, origin, trace.runVantage, { stale, running: isRunning(origin.id) }) : undefined
1283
+ connect(
1284
+ `e:origin-${id}`,
1285
+ originNodeId,
1286
+ id,
1287
+ originBlocked ? 'blocked' : active ? own?.mark ?? 'untested' : 'untested',
1288
+ active ? own?.label ?? 'not tested' : 'other host',
1289
+ active ? own?.title : undefined,
1290
+ )
1291
+ connect(`e:${id}-subject`, id, subjectNodeId, 'config', 'routes to')
1292
+ }
1293
+ } else {
1294
+ // The declared entries stay drawn, and stay CONFIG: they are how traffic is
1295
+ // meant to arrive, and this run did not use them.
1296
+ for (const up of upstreams) {
1297
+ connect(`e:${`n:${refId(up.resource)}`}-subject`, `n:${refId(up.resource)}`, subjectNodeId, 'config', 'routes to')
1298
+ }
1299
+ // An HTTPRoute/GRPCRoute has no address of its own - reachability lives on
1300
+ // the parent Gateway and the backend Service. A bypassing origin therefore
1301
+ // dials the BACKEND, and terminating its edge on the route object would draw
1302
+ // a request to something nothing can dial. Land it on the backends instead.
1303
+ // Only the backends this route actually resolves to. Landing the edge on
1304
+ // every expanded backend would spread ONE route's evidence across siblings
1305
+ // it says nothing about - the cross-backend leak the producer now refuses to
1306
+ // make, reintroduced a layer up.
1307
+ const bypassTargets = !subjectIsAddressable && originSkipsEntries ? bypassBackends.map((b) => b.id) : []
1308
+ if (bypassTargets.length > 0) {
1309
+ for (const id of bypassTargets) {
1310
+ connect(`e:origin-${id}`, originNodeId, id, originBlocked ? 'blocked' : entryMark, 'dialled directly')
1311
+ }
1312
+ } else {
1313
+ connect(
1314
+ 'e:origin-subject',
1315
+ originNodeId,
1316
+ subjectNodeId,
1317
+ originBlocked ? 'blocked' : entryMark,
1318
+ originSkipsEntries ? 'direct to backend' : entryLabel,
1319
+ originSkipsEntries ? undefined : entryTitle,
1320
+ )
1321
+ }
1322
+ }
1323
+ // Each unselected origin gets its own edge carrying ITS OWN mark AND its own
1324
+ // words. Without them the capsule is decoration and the comparison still costs
1325
+ // a click - which is the entire reason the vantages are drawn together. The
1326
+ // origins stack vertically, so their edge midpoints do too and the pills
1327
+ // separate on their own.
1328
+ others.forEach((o, oi) => {
1329
+ const p = originPlacement.get(o.id)
1330
+ const from = `origin:${o.id}`
1331
+ const mine = (id: string) => {
1332
+ foreignEdges.add(id)
1333
+ return id
1334
+ }
1335
+ const ev = originEntryEvidence(trace, route, o, { stale, running: isRunning(o.id) })
1336
+ const slot = oi + 1
1337
+ if (!p?.skips && upstreams.length > 0) {
1338
+ for (const up of activeUpstreams) {
1339
+ connect(mine(`e:origin-${o.id}-${refId(up.resource)}`), from, `n:${refId(up.resource)}`, ev.mark, '', ev.title || ev.label, slot)
1340
+ }
1341
+ return
1342
+ }
1343
+ if (!subjectIsAddressable && p?.skips) {
1344
+ for (const b of bypassBackends) connect(mine(`e:origin-${o.id}-${b.id}`), from, b.id, ev.mark, '', ev.title || ev.label, slot)
1345
+ return
1346
+ }
1347
+ connect(mine(`e:origin-${o.id}-subject`), from, subjectNodeId, ev.mark, '', ev.title || ev.label, slot)
1348
+ })
1349
+
1350
+ for (const b of expanded) {
1351
+ const onPath = !focusBranches || matchedBackends.includes(b)
1352
+ connect(`e:subject-${b.id}`, subjectNodeId, b.id, onPath ? 'config' : 'excluded', onPath ? 'sends to' : 'other host')
1353
+ }
1354
+ if (collapsed.length > 0) connect('e:subject-collapsed', subjectNodeId, 'collapsed:backends', 'config', 'also serves')
1355
+ // When the producer localized the break to the Service's own routing, the
1356
+ // Service->Pods edge is where it happened - packets reached the workload, so
1357
+ // what sits between them is what failed. Only ever drawn from a boundary the
1358
+ // producer actually established; an unlocalized failure colours nothing.
1359
+ const boundary = ev.kind === 'own' ? routeForOrigin(route, origin.id)?.failedBoundary : undefined
1360
+ // The producer establishes a boundary for ONE backend. Painting it on every
1361
+ // Service->Pods edge would condemn siblings whose pods were never probed -
1362
+ // the same cross-backend leak, one layer up from where it was just fixed.
1363
+ // Where the owning branch can't be identified, colour nothing.
1364
+ const boundaryParents =
1365
+ matchedBackends.length > 0
1366
+ ? new Set(matchedBackends.map((b) => b.id))
1367
+ : backends.length === 0
1368
+ ? new Set([subjectNodeId]) // single chain: the subject IS the Service
1369
+ : new Set<string>()
1370
+ let breakAtExitOf: string | undefined
1371
+ for (const g of podGroups) {
1372
+ const broke = boundary === 'service-routing' && boundaryParents.has(g.parentId)
1373
+ // A Service-routing break is a BOUNDARY, not a node: the request never got
1374
+ // past the Service's exit. The break anchors there (breakAtExitOf) with no
1375
+ // node halo - a halo on the workload blamed the Deployment for kube-proxy,
1376
+ // and one on the Pods blamed the endpoints for their Service's routing.
1377
+ if (broke) breakAtExitOf = g.parentId
1378
+ if (showWorkload) {
1379
+ const breakTitle =
1380
+ 'The Service\u2019s routing to its Pods fails here. The Deployment sits between them as the thing that runs the Pods \u2014 it is not a network hop and not the culprit.'
1381
+ connect(
1382
+ `e:${g.id}-workload`,
1383
+ g.parentId,
1384
+ workloadNodeId,
1385
+ broke ? 'failed' : 'config',
1386
+ broke ? 'breaks here' : 'selects',
1387
+ broke ? breakTitle : undefined,
1388
+ 0,
1389
+ broke ? 'start' : undefined,
1390
+ )
1391
+ // The continuation extends the SAME observed break across the span the
1392
+ // workload sits inside - dashed, no pill, never a second failed claim.
1393
+ connect(
1394
+ `e:${g.id}`,
1395
+ workloadNodeId,
1396
+ g.id,
1397
+ broke ? 'failed' : 'config',
1398
+ broke ? '' : 'runs',
1399
+ broke ? 'Continuation of the same break \u2014 the routing failure is between the Service and its Pods.' : undefined,
1400
+ 0,
1401
+ broke ? 'continuation' : undefined,
1402
+ )
1403
+ continue
1404
+ }
1405
+ connect(
1406
+ `e:${g.id}`,
1407
+ g.parentId,
1408
+ g.id,
1409
+ broke ? 'failed' : 'config',
1410
+ broke ? 'breaks here' : 'selects',
1411
+ undefined,
1412
+ 0,
1413
+ broke ? 'start' : undefined,
1414
+ )
1415
+ }
1416
+
1417
+ separatePills(edges, geom)
1418
+
1419
+ // ---- lane boxes: bound only their own nodes ----
1420
+ const boxFor = (list: GraphNode[], label: string, help: string, color: string, dashed?: boolean): LaneBox | undefined => {
1421
+ if (list.length === 0) return undefined
1422
+ const x0 = Math.min(...list.map((n) => n.x)) - LANE_PAD.x
1423
+ const x1 = Math.max(...list.map((n) => n.x + n.w)) + LANE_PAD.x
1424
+ const y0 = Math.min(...list.map((n) => n.y)) - LANE_PAD.top
1425
+ const y1 = Math.max(...list.map((n) => n.y + n.h)) + LANE_PAD.bottom
1426
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0, label, help, color, dashed }
1427
+ }
1428
+ const laneControl = boxFor(
1429
+ nodes.filter((n) => n.lane === 'control'),
1430
+ 'NOT FROM INSIDE THE CLUSTER',
1431
+ 'Neither request originates inside the cluster’s dataplane, but they differ: a dial from your machine sends real traffic from outside - through a real entry it exercises the path from the entry inward (routing, NetworkPolicy, mesh included). A relayed request is carried by the Kubernetes control plane and bypasses the traffic path entirely.',
1432
+ 'var(--color-info)',
1433
+ true,
1434
+ )
1435
+ const laneData = boxFor(
1436
+ nodes.filter((n) => n.lane === 'data'),
1437
+ 'FROM INSIDE THE CLUSTER',
1438
+ 'Started from a Pod inside the cluster, so the request goes through the cluster’s own routing, network policy and mesh. It dials the backend directly, so it says nothing about whether the front door works.',
1439
+ 'var(--accent-text)',
1440
+ )
1441
+
1442
+ // A cross-lane edge's midpoint lands on the dataplane lane's top edge, which
1443
+ // is exactly where the lane label sits. Park those pills in the gap between
1444
+ // the lanes - otherwise empty - so neither covers the other. This runs AFTER
1445
+ // separatePills and can restack pills it had separated (every crossing edge
1446
+ // gets the same y), so colliders are re-spread here: the first keeps the
1447
+ // gap centre, later ones step outward alternating above/below - slightly
1448
+ // into a lane beats two pills on top of each other.
1449
+ if (laneControl && laneData) {
1450
+ const gapCentre = (laneControl.y + laneControl.h + laneData.y) / 2
1451
+ const parked: GraphEdge[] = []
1452
+ for (const e of edges) {
1453
+ if (!crossesLanes.has(e.id)) continue
1454
+ e.py = gapCentre
1455
+ for (const other of parked) {
1456
+ if (Math.abs(other.px - e.px) < PILL_MAX_PX + 14 && Math.abs(other.py - e.py) < 26) {
1457
+ const step = Math.ceil(parked.length / 2) * 28
1458
+ e.py = gapCentre + (parked.length % 2 === 1 ? step : -step)
1459
+ }
1460
+ }
1461
+ parked.push(e)
1462
+ }
1463
+ }
1464
+
1465
+ // Detouring edges bow BELOW the nodes they route around, so the canvas has to
1466
+ // reserve room for the deepest of them or the curve clips at the bottom edge.
1467
+ const deepest = Math.max(0, ...edges.map((e) => e.py))
1468
+ const canvas = {
1469
+ w: Math.max(...nodes.map((n) => n.x + n.w), 300) + LANE_PAD.x + 4,
1470
+ h: Math.max(...nodes.map((n) => n.y + n.h), deepest, 200) + LANE_PAD.bottom + 4,
1471
+ }
1472
+
1473
+ // Leftmost failed edge = the first place a request is known to have stopped.
1474
+ // Column order, not array order, so it does not depend on the sequence edges
1475
+ // happened to be built in.
1476
+ const failed = edges
1477
+ .filter((e) => e.mark === 'failed' && !e.boundary && !foreignEdges.has(e.id))
1478
+ .map((e) => pos.get(edgeTo.get(e.id) ?? ''))
1479
+ .filter((n): n is GraphNode => !!n)
1480
+ .sort((a, b) => a.x - b.x)
1481
+ const breakNodeId = failed[0]?.id
1482
+
1483
+ // Traversal order, not screen order: entries the route arrives through, the
1484
+ // subject, the workload behind it, only the backends this route resolves to,
1485
+ // and only those backends' Pods.
1486
+ const selectedBackends = focusBranches ? matchedBackends : expanded
1487
+ const selectedBackendIds = new Set(selectedBackends.map((b) => b.id))
1488
+ // The journey is what the selected vantage's request traverses. A bypassing
1489
+ // origin (apiserver relay, in-cluster Job) traverses NO entry; a matched host
1490
+ // traverses only ITS entries; parallel non-matched entries are context. The
1491
+ // workload is rendered inline but is not a hop - it interleaves in display,
1492
+ // never in the journey.
1493
+ const journeyUpstreams = originSkipsEntries ? [] : activeUpstreams
1494
+ const contextNodeIds = upstreams
1495
+ .filter((up) => !journeyUpstreams.includes(up))
1496
+ .map((up) => `n:${refId(up.resource)}`)
1497
+ const pathNodeIds = [
1498
+ ...journeyUpstreams.map((up) => `n:${refId(up.resource)}`),
1499
+ subjectNodeId,
1500
+ ...selectedBackends.map((b) => b.id),
1501
+ ...podGroups.filter((g) => g.parentId === subjectNodeId || selectedBackendIds.has(g.parentId)).map((g) => g.id),
1502
+ ].filter((id, i, all) => pos.has(id) && all.indexOf(id) === i)
1503
+
1504
+ return {
1505
+ nodes,
1506
+ edges,
1507
+ brackets,
1508
+ originIsControl,
1509
+ canvas,
1510
+ laneControl,
1511
+ laneData,
1512
+ breakNodeId,
1513
+ breakAtExitOf,
1514
+ nonNetworkNodeIds: showWorkload ? [workloadNodeId] : undefined,
1515
+ contextNodeIds: contextNodeIds.length ? contextNodeIds : undefined,
1516
+ interleave: showWorkload ? [{ id: workloadNodeId, afterId: subjectNodeId }] : undefined,
1517
+ entryParallelCount: journeyUpstreams.length > 1 ? journeyUpstreams.length : undefined,
1518
+ journeyEntryNodeIds: journeyUpstreams.length ? journeyUpstreams.map((up) => `n:${refId(up.resource)}`) : undefined,
1519
+ pathNodeIds,
1520
+ }
1521
+ }