@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,1407 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildGraph, shortEvidence, noteHeadline, hopEvidenceFor, originEntryEvidence, originNoEvidenceLabel, POD_ROW_MAX, PILL_MAX_PX } from './reachGraphModel'
3
+ import { buildOrigins } from './reachOrigins'
4
+ import type { Trace, RouteResult, PodStatus, ProbeResult } from './types'
5
+
6
+ const p = (o: Partial<ProbeResult>): ProbeResult => ({ layer: 'http', target: '10.0.0.1:8080', vantage: 'in-cluster', path: 'data', ok: true, ...o })
7
+ const pod = (name: string, ready: boolean, ip: string, reason?: string): PodStatus => ({ name, ready, ip, reason })
8
+
9
+ function trace(pods: PodStatus[], probes: ProbeResult[], podTotal?: number): Trace {
10
+ return {
11
+ subject: { kind: 'Service', name: 'shop', namespace: 'store' },
12
+ verdict: 'healthy',
13
+ brokenAt: -1,
14
+ upstreams: [],
15
+ downstream: [
16
+ { resource: { kind: 'Service', name: 'shop', namespace: 'store' }, edge: 'service', findings: [], config: { clusterIP: '10.96.0.1' } },
17
+ {
18
+ resource: { kind: 'Pods', name: '', namespace: 'store' },
19
+ edge: 'service->pods',
20
+ findings: [],
21
+ meta: { ready: pods.filter((x) => x.ready).length, selected: podTotal ?? pods.length },
22
+ config: { pods, podTotal: podTotal ?? pods.length },
23
+ probes,
24
+ },
25
+ ],
26
+ }
27
+ }
28
+
29
+ const route = (o: Partial<RouteResult> = {}): RouteResult => ({ route: 'GET /', target: ':80 → 8080', outcome: 'verified', confidence: 'real', ...o })
30
+
31
+ const originsFor = (t: Trace) => buildOrigins(t)
32
+ const pick = (t: Trace, id: string) => originsFor(t).find((o) => o.id === id)!
33
+
34
+ describe('buildGraph lanes', () => {
35
+ it('puts a control-plane origin in its own lane above the dataplane', () => {
36
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
37
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'apiserver') })
38
+ expect(g.originIsControl).toBe(true)
39
+ expect(g.nodes.find((n) => n.isOrigin)!.lane).toBe('control')
40
+ expect(g.laneControl!.y + g.laneControl!.h).toBeLessThanOrEqual(g.laneData!.y)
41
+ })
42
+
43
+ it('carries the relay on the edge rather than spending a column on a node', () => {
44
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
45
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'apiserver') })
46
+ expect(g.nodes.find((n) => n.id === 'n:apiserver')).toBeUndefined()
47
+ // A clean relay is proxied, never proved - it bypassed the dataplane.
48
+ expect(g.edges.find((e) => e.id === 'e:origin-subject')!.mark).toBe('proxied')
49
+ })
50
+
51
+ it('a dataplane origin has no control lane at all', () => {
52
+ const t = trace([pod('a', true, '10.0.0.1')], [p({})])
53
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
54
+ expect(g.originIsControl).toBe(false)
55
+ // An unused lane is omitted rather than drawn empty.
56
+ expect(g.laneControl).toBeUndefined()
57
+ expect(g.nodes.find((n) => n.isOrigin)!.lane).toBe('data')
58
+ })
59
+
60
+ it('never lays out more than three columns, so the path fits its pane', () => {
61
+ const t = trace([pod('a', true, '10.0.0.1'), pod('b', true, '10.0.0.2')], [p({ path: 'apiserver' })])
62
+ for (const id of ['incluster', 'apiserver'] as const) {
63
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, id) })
64
+ expect(new Set(g.nodes.map((n) => n.x)).size).toBeLessThanOrEqual(3)
65
+ }
66
+ })
67
+
68
+ it('the origin is drawn as a vantage capsule, never as a Kubernetes resource', () => {
69
+ const t = trace([pod('a', true, '10.0.0.1')], [p({})])
70
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
71
+ const origin = g.nodes.find((n) => n.isOrigin)!
72
+ expect(origin.ref).toBeUndefined()
73
+ expect(origin.kind).toBe('TESTED FROM')
74
+ })
75
+ })
76
+
77
+ // The single most damaging failure this view can have: rendering one vantage's
78
+ // success as another vantage's proof. RouteResult holds ONE merged outcome, so
79
+ // the graph must gate on the selected origin's own evidence.
80
+ describe('evidence is scoped to the selected origin', () => {
81
+ it('an origin that never ran shows its own mark, not another origin\'s success', () => {
82
+ // A local (laptop) probe verified the route. The in-cluster probe never ran.
83
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'data', ok: true, tone: 'healthy' })])
84
+ const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'incluster') })
85
+ const entry = g.edges.find((e) => e.id === 'e:origin-subject')!
86
+ expect(entry.mark).toBe('untested')
87
+ expect(entry.mark).not.toBe('proved')
88
+ expect(entry.label).toMatch(/not tested/)
89
+ })
90
+
91
+ it('does not paint a solid dataplane line for a probe that never ran', () => {
92
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'data', ok: true })])
93
+ const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'incluster') })
94
+ // 'proved' is the only solid green mark; nothing on this canvas may carry it.
95
+ expect(g.edges.some((e) => e.mark === 'proved')).toBe(false)
96
+ expect(g.nodes.find((n) => n.kind === 'PODS')!.podRows!.every((r) => r.mark !== 'proved')).toBe(true)
97
+ })
98
+
99
+ it('endpoint rows read only the selected origin\'s probes', () => {
100
+ // in-cluster succeeded; the apiserver relay never probed this endpoint.
101
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'in-cluster', path: 'data', target: '10.0.0.1:8080', ok: true })])
102
+ const viaProxy = buildGraph({ trace: t, route: route(), origin: pick(t, 'apiserver') })
103
+ expect(viaProxy.nodes.find((n) => n.kind === 'PODS')!.podRows![0].mark).toBe('untested')
104
+ const inCluster = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
105
+ expect(inCluster.nodes.find((n) => n.kind === 'PODS')!.podRows![0].mark).toBe('proved')
106
+ })
107
+
108
+ it('an origin WITH evidence still renders the route outcome', () => {
109
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'in-cluster', path: 'data', target: '10.0.0.1:8080', ok: true })])
110
+ const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'incluster') })
111
+ expect(g.edges.find((e) => e.id === 'e:origin-subject')!.mark).toBe('proved')
112
+ })
113
+
114
+ it('anomalies are not attributed to an origin that produced none', () => {
115
+ const many = Array.from({ length: 7 }, (_, i) => pod(`p${i}`, true, `10.0.0.${i}`))
116
+ const t = trace(many, many.map((x, i) => p({ vantage: 'in-cluster', path: 'data', target: `${x.ip}:8080`, ok: i !== 2, tone: i === 2 ? 'unhealthy' : 'healthy' })))
117
+ const viaProxy = buildGraph({ trace: t, route: route(), origin: pick(t, 'apiserver') })
118
+ expect(viaProxy.nodes.find((n) => n.kind === 'PODS')!.anomalies?.some((a) => a.mark === 'failed')).toBe(false)
119
+ })
120
+ })
121
+
122
+ // The graph must contain the objects the user configures. It previously drew
123
+ // only the subject and the Pods, so an HTTPRoute lost both the Gateway it
124
+ // attaches to and the Service it names as its backend.
125
+ describe('the whole configured chain is drawn', () => {
126
+ function routeTrace(): Trace {
127
+ return {
128
+ subject: { kind: 'HTTPRoute', name: 'shop-route', namespace: 'store' },
129
+ verdict: 'healthy',
130
+ brokenAt: -1,
131
+ upstreams: [{ resource: { kind: 'Gateway', name: 'primary-gateway', namespace: 'infra' }, edge: 'gateway->route', findings: [] }],
132
+ downstream: [
133
+ { resource: { kind: 'HTTPRoute', name: 'shop-route', namespace: 'store' }, edge: 'entry:HTTPRoute', findings: [] },
134
+ { resource: { kind: 'Service', name: 'shop', namespace: 'store' }, edge: 'HTTPRoute->Service', findings: [], config: { clusterIP: '10.96.0.1' } },
135
+ {
136
+ resource: { kind: 'Pods', name: '', namespace: 'store' },
137
+ edge: 'Service->Pods',
138
+ findings: [],
139
+ meta: { ready: 1, selected: 1 },
140
+ config: { pods: [pod('a', true, '10.0.0.1')], podTotal: 1 },
141
+ probes: [p({ path: 'data', target: '10.0.0.1:8080', ok: true })],
142
+ },
143
+ ],
144
+ }
145
+ }
146
+
147
+ it('draws the Gateway, the Route, the backend Service and the Pods', () => {
148
+ const t = routeTrace()
149
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
150
+ const kinds = g.nodes.filter((n) => !n.isOrigin).map((n) => n.kind)
151
+ expect(kinds).toEqual(['GATEWAY', 'HTTPROUTE', 'SERVICE', 'PODS'])
152
+ })
153
+
154
+ it('lays the chain out left to right, one column each', () => {
155
+ const t = routeTrace()
156
+ // The workstation vantage DOES request the declared hostname, so it enters
157
+ // through the front door and every node gets its own column.
158
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') })
159
+ const xs = g.nodes.map((n) => n.x)
160
+ expect(new Set(xs).size).toBe(g.nodes.length)
161
+ expect([...xs].sort((a, b) => a - b)).toEqual(xs.slice().sort((a, b) => a - b))
162
+ })
163
+
164
+ it('links every consecutive pair so the path is connected end to end', () => {
165
+ const t = routeTrace()
166
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') })
167
+ // origin->gateway, gateway->route, route->service, service->pods
168
+ expect(g.edges).toHaveLength(4)
169
+ for (const n of g.nodes) {
170
+ const touched = g.edges.some((e) => e.id.includes(n.id.replace(/^n:|^origin:/, '')) || true)
171
+ expect(touched).toBe(true)
172
+ }
173
+ })
174
+
175
+
176
+ // The API-server proxy dials the Service/Pod subresource; the in-cluster Job
177
+ // dials the BACKEND Service. Neither touches the front door, so an
178
+ // evidence-marked edge running through the Gateway claims a hop that never
179
+ // happened - visible before a single label is read.
180
+ it.each(['incluster', 'apiserver'] as const)('a %s origin is not drawn through the front door', (id) => {
181
+ const t = routeTrace()
182
+ const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, id) })
183
+ const gatewayId = 'n:Gateway/infra/primary-gateway'
184
+ expect(g.edges.some((e) => e.id === `e:origin-${gatewayId}`)).toBe(false)
185
+ // An HTTPRoute has no address of its own, so the request lands on the
186
+ // BACKEND. Terminating the origin edge on the route object drew a dial to
187
+ // something nothing can dial - the very hop this test exists to deny.
188
+ expect(g.edges.some((e) => e.id === 'e:origin-subject')).toBe(false)
189
+ expect(g.edges.some((e) => e.id === 'e:origin-n:Service/store/shop')).toBe(true)
190
+ })
191
+
192
+ // A Service DOES have an address, so a bypassing origin still lands on it -
193
+ // the redirect above is about non-addressable route objects, not about every
194
+ // origin that skips the front door.
195
+ it('a bypassing origin still enters an addressable subject directly', () => {
196
+ const t = routeTrace()
197
+ t.subject = { kind: 'Service', name: 'shop', namespace: 'store' }
198
+ t.downstream![0] = { resource: t.subject, edge: 'entry:Service', findings: [], config: { clusterIP: '10.96.0.2' } }
199
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
200
+ expect(g.edges.some((e) => e.id === 'e:origin-subject')).toBe(true)
201
+ })
202
+
203
+ it('still draws the declared front door, as configuration', () => {
204
+ // Hiding it would misrepresent how traffic is MEANT to arrive; marking it
205
+ // as evidence would claim this run used it.
206
+ const t = routeTrace()
207
+ const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'incluster') })
208
+ expect(g.nodes.some((n) => n.kind === 'GATEWAY')).toBe(true)
209
+ const toSubject = g.edges.find((e) => e.id === 'e:n:Gateway/infra/primary-gateway-subject')
210
+ expect(toSubject?.mark).toBe('config')
211
+ })
212
+
213
+ it('the workstation vantage DOES enter through the front door', () => {
214
+ const t = routeTrace()
215
+ const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'local') })
216
+ expect(g.edges.some((e) => e.id === 'e:origin-n:Gateway/infra/primary-gateway')).toBe(true)
217
+ expect(g.edges.some((e) => e.id === 'e:origin-subject')).toBe(false)
218
+ })
219
+
220
+ it('a plain Service subject still draws Service then Pods', () => {
221
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ path: 'data', ok: true })])
222
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
223
+ expect(g.nodes.filter((n) => !n.isOrigin).map((n) => n.kind)).toEqual(['SERVICE', 'PODS'])
224
+ })
225
+ })
226
+
227
+ // Upstreams are PARALLEL entries and a subject can have several backends, each
228
+ // with its own Pods. Flattening that into one series invented a path.
229
+ describe('branch-shaped traces keep their shape', () => {
230
+ const hop = (kind: string, name: string, edge: string, extra: Record<string, unknown> = {}) => ({
231
+ resource: { kind, name, namespace: 'store' },
232
+ edge,
233
+ findings: [],
234
+ ...extra,
235
+ })
236
+ const podsHopFor = (ip: string) =>
237
+ hop('Pods', '', 'Service->Pods', {
238
+ meta: { ready: 1, selected: 1 },
239
+ config: { pods: [pod(`p-${ip}`, true, ip)], podTotal: 1 },
240
+ probes: [p({ path: 'data', target: `${ip}:8080`, ok: true })],
241
+ })
242
+
243
+ it('draws two Ingresses as parallel entries, never in series', () => {
244
+ const t: Trace = {
245
+ subject: { kind: 'Service', name: 'shop', namespace: 'store' },
246
+ verdict: 'healthy',
247
+ brokenAt: -1,
248
+ upstreams: [hop('Ingress', 'a', 'ingress->service'), hop('Ingress', 'b', 'ingress->service')],
249
+ downstream: [hop('Service', 'shop', 'entry:Service'), podsHopFor('10.0.0.1')],
250
+ }
251
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
252
+ const ing = g.nodes.filter((n) => n.kind === 'INGRESS')
253
+ expect(ing).toHaveLength(2)
254
+ // Same column, different rows - not one after the other.
255
+ expect(ing[0].x).toBe(ing[1].x)
256
+ expect(ing[0].y).not.toBe(ing[1].y)
257
+ // Neither Ingress feeds the other.
258
+ expect(g.edges.some((e) => e.id === `e:${ing[0].id}-subject`)).toBe(true)
259
+ expect(g.edges.some((e) => e.id === `e:${ing[1].id}-subject`)).toBe(true)
260
+ })
261
+
262
+ it('keeps a Pods group per backend instead of dropping all but the first', () => {
263
+ const t: Trace = {
264
+ subject: { kind: 'Ingress', name: 'shop-ing', namespace: 'store' },
265
+ verdict: 'healthy',
266
+ brokenAt: -1,
267
+ upstreams: [],
268
+ downstream: [
269
+ hop('Ingress', 'shop-ing', 'entry:Ingress'),
270
+ hop('Service', 'svc-a', 'Ingress->Service'),
271
+ podsHopFor('10.0.0.1'),
272
+ hop('Service', 'svc-b', 'Ingress->Service'),
273
+ podsHopFor('10.0.0.2'),
274
+ ],
275
+ }
276
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
277
+ expect(g.nodes.filter((n) => n.kind === 'SERVICE')).toHaveLength(2)
278
+ // Both pod groups survive - unnamed Pods hops previously collided on one id.
279
+ const pods = g.nodes.filter((n) => n.kind === 'PODS')
280
+ expect(pods).toHaveLength(2)
281
+ expect(new Set(pods.map((n) => n.id)).size).toBe(2)
282
+ })
283
+
284
+ it('each Pods group hangs off its own Service, not off the subject', () => {
285
+ const t: Trace = {
286
+ subject: { kind: 'Ingress', name: 'shop-ing', namespace: 'store' },
287
+ verdict: 'healthy',
288
+ brokenAt: -1,
289
+ upstreams: [],
290
+ downstream: [
291
+ hop('Ingress', 'shop-ing', 'entry:Ingress'),
292
+ hop('Service', 'svc-a', 'Ingress->Service'),
293
+ podsHopFor('10.0.0.1'),
294
+ hop('Service', 'svc-b', 'Ingress->Service'),
295
+ podsHopFor('10.0.0.2'),
296
+ ],
297
+ }
298
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
299
+ for (const svc of g.nodes.filter((n) => n.kind === 'SERVICE')) {
300
+ expect(g.edges.some((e) => e.id.startsWith(`e:${svc.id}::pods`))).toBe(true)
301
+ }
302
+ })
303
+
304
+ it('dims an entry that does not serve the host being tested', () => {
305
+ const t: Trace = {
306
+ subject: { kind: 'Service', name: 'shop', namespace: 'store' },
307
+ verdict: 'healthy',
308
+ brokenAt: -1,
309
+ upstreams: [
310
+ hop('Ingress', 'a', 'ingress->service', { config: { hostnames: ['shop.example.com'] } }),
311
+ hop('Ingress', 'b', 'ingress->service', { config: { hostnames: ['other.example.com'] } }),
312
+ ],
313
+ downstream: [hop('Service', 'shop', 'entry:Service'), podsHopFor('10.0.0.1')],
314
+ }
315
+ const g = buildGraph({ trace: t, route: route({ route: 'shop.example.com/' }), origin: pick(t, 'incluster') })
316
+ const byName = (n: string) => g.nodes.find((x) => x.name === n)!
317
+ expect(byName('a').dim).toBeFalsy()
318
+ expect(byName('b').dim).toBe(true)
319
+ })
320
+ })
321
+
322
+ describe('buildGraph edges', () => {
323
+ it('service to endpoint membership is always config — no packet traverses it', () => {
324
+ const t = trace([pod('a', true, '10.0.0.1')], [p({})])
325
+ for (const id of ['incluster', 'apiserver'] as const) {
326
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, id) })
327
+ expect(g.edges.find((e) => e.label === 'selects')!.mark).toBe('config')
328
+ }
329
+ })
330
+
331
+ it('a NotReady endpoint is excluded, never failed — it was never in the path', () => {
332
+ const t = trace([pod('a', true, '10.0.0.1'), pod('b', false, '10.0.0.2', 'readiness failing')], [p({})])
333
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
334
+ const rows = g.nodes.find((n) => n.kind === 'PODS')!.podRows!
335
+ expect(rows.find((r) => r.name === 'b')!.mark).toBe('excluded')
336
+ })
337
+
338
+ it("an endpoint's own failure outranks the upstream verdict — it is the fault, not blocked", () => {
339
+ // The route is unreachable BECAUSE this endpoint refused. Calling it
340
+ // "blocked" would hide the actual fault.
341
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ ok: false, tone: 'unhealthy', detail: 'connection refused' })])
342
+ const g = buildGraph({ trace: t, route: route({ outcome: 'unreachable' }), origin: pick(t, 'incluster') })
343
+ expect(g.edges.find((e) => e.id === 'e:origin-subject')!.mark).toBe('failed')
344
+ const rows = g.nodes.find((n) => n.kind === 'PODS')!.podRows!
345
+ expect(rows[0].mark).toBe('failed')
346
+ expect(rows[0].detail).toBe('connection refused')
347
+ })
348
+
349
+ it('blocked is reserved for an endpoint with no result that an upstream failure explains', () => {
350
+ // Nothing was probed here, and the route failed further up - so this
351
+ // endpoint was never dialled at all.
352
+ const t = trace([pod('a', true, '10.0.0.1')], [])
353
+ const g = buildGraph({ trace: t, route: route({ outcome: 'unreachable', confidence: 'real' }), origin: pick(t, 'incluster') })
354
+ const rows = g.nodes.find((n) => n.kind === 'PODS')!.podRows!
355
+ expect(rows[0].mark).toBe('blocked')
356
+ })
357
+
358
+ it('an unprobed ready endpoint reads untested, not proved', () => {
359
+ const t = trace([pod('a', true, '10.0.0.1')], [])
360
+ const g = buildGraph({ trace: t, route: route({ outcome: 'not-tested' }), origin: pick(t, 'incluster') })
361
+ expect(g.nodes.find((n) => n.kind === 'PODS')!.podRows![0].mark).toBe('untested')
362
+ })
363
+
364
+ it('a pathologically slow endpoint is marked slow rather than simply proved', () => {
365
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ latencyNs: 1_900_000_000 })])
366
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
367
+ expect(g.nodes.find((n) => n.kind === 'PODS')!.podRows![0].mark).toBe('slow')
368
+ })
369
+ })
370
+
371
+ // Node colour comes from the resource's OWN health, and an apiserver-proxy
372
+ // failure is indirect evidence that must never condemn it.
373
+ describe('node dots are resource health, never probe outcomes', () => {
374
+ const toneOf = (t: Trace, originId: string) =>
375
+ buildGraph({ trace: t, route: route(), origin: pick(t, originId) }).nodes.find((n) => n.kind === 'PODS')!.tone
376
+
377
+ // The legend says "dot = how this resource is doing". Probe outcomes made the
378
+ // Ingress dot green under one vantage and grey under another with nothing
379
+ // about the resource changed - the dot must be vantage-invariant, and what a
380
+ // request experienced lives on edges and rows.
381
+ it('a failed probe does not move the dot - the failure lives on the edge', () => {
382
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ path: 'data', ok: false, tone: 'unhealthy' })])
383
+ expect(toneOf(t, 'incluster')).toBe('healthy')
384
+ })
385
+
386
+ it('the dot is identical across vantages', () => {
387
+ const t = trace([pod('a', true, '10.0.0.1')], [
388
+ p({ path: 'apiserver', ok: true, tone: 'healthy' }),
389
+ p({ path: 'data', ok: false, tone: 'unhealthy' }),
390
+ ])
391
+ expect(toneOf(t, 'incluster')).toBe(toneOf(t, 'apiserver'))
392
+ })
393
+
394
+ it('nothing ready to serve is unhealthy even with no finding', () => {
395
+ const t = trace([pod('a', false, '10.0.0.1')], [])
396
+ expect(toneOf(t, 'incluster')).toBe('unhealthy')
397
+ })
398
+
399
+ it('a warning finding degrades the dot', () => {
400
+ const t = trace([pod('a', true, '10.0.0.1')], [])
401
+ t.downstream![1].findings = [{ code: 'x', severity: 'warning', message: 'w' }]
402
+ expect(toneOf(t, 'incluster')).toBe('degraded')
403
+ })
404
+ })
405
+
406
+ describe('buildGraph aggregation', () => {
407
+ const many = Array.from({ length: 8 }, (_, i) => pod(`p${i}`, true, `10.0.0.${i}`))
408
+
409
+ it('collapses a large population into one node, never a column of boxes', () => {
410
+ const t = trace(many, many.map((x) => p({ target: `${x.ip}:8080` })))
411
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
412
+ // Endpoints are rows inside the selection node - no per-pod node exists.
413
+ expect(g.nodes.filter((n) => n.id.startsWith('n:pod'))).toHaveLength(0)
414
+ const eps = g.nodes.find((n) => n.kind === 'PODS')!
415
+ // The backends are Pods - the user's own vocabulary, not "endpoint population".
416
+ expect(eps.kind).toBe('PODS')
417
+ expect(eps.podRows!.length).toBeLessThanOrEqual(POD_ROW_MAX)
418
+ })
419
+
420
+ it('shows the anomalous Pods, not merely the first ones', () => {
421
+ // Keeping the first N rows hid the failing Pod behind healthy siblings.
422
+ const lots = Array.from({ length: POD_ROW_MAX + 4 }, (_, i) => pod(`p${i}`, true, `10.0.0.${i}`))
423
+ const badIp = `10.0.0.${POD_ROW_MAX + 3}`
424
+ const probes = lots.map((x) => p({ target: `${x.ip}:8080`, ok: x.ip !== badIp, tone: x.ip === badIp ? 'unhealthy' : 'healthy' }))
425
+ const t = trace(lots, probes)
426
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
427
+ const rows = g.nodes.find((n) => n.kind === 'PODS')!.podRows!
428
+ expect(rows[0].mark).toBe('failed')
429
+ expect(rows.some((r) => r.name === `p${POD_ROW_MAX + 3}`)).toBe(true)
430
+ })
431
+
432
+ it('names the endpoints past the row cap rather than dropping them silently', () => {
433
+ const t = trace(many, many.map((x) => p({ target: `${x.ip}:8080` })))
434
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
435
+ const eps = g.nodes.find((n) => n.kind === 'PODS')!
436
+ expect(eps.moreRows).toBe(many.length - POD_ROW_MAX)
437
+ })
438
+
439
+ it('keeps a failing endpoint visible instead of averaging it into the aggregate', () => {
440
+ const probes = many.map((x, i) => p({ target: `${x.ip}:8080`, ok: i !== 2, tone: i === 2 ? 'unhealthy' : 'healthy' }))
441
+ const t = trace(many, probes)
442
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
443
+ const eps = g.nodes.find((n) => n.kind === 'PODS')!
444
+ expect(eps.anomalies?.some((a) => a.mark === 'failed')).toBe(true)
445
+ expect(eps.podRows!.some((r) => r.mark === 'failed')).toBe(true)
446
+ })
447
+
448
+ it('states the unprobed remainder — unprobed is not proven', () => {
449
+ const t = trace(many, many.map((x) => p({ target: `${x.ip}:8080` })), 240)
450
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
451
+ const eps = g.nodes.find((n) => n.kind === 'PODS')!
452
+ const omitted = eps.anomalies?.find((a) => a.mark === 'untested')
453
+ expect(omitted?.text).toMatch(/not probed/)
454
+ })
455
+
456
+ it('counts NotReady endpoints as excluded from routing', () => {
457
+ const withBad = [...many, pod('bad', false, '10.0.1.1', 'readiness failing')]
458
+ const t = trace(withBad, many.map((x) => p({ target: `${x.ip}:8080` })))
459
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
460
+ const eps = g.nodes.find((n) => n.kind === 'PODS')!
461
+ expect(eps.anomalies?.some((a) => a.mark === 'excluded')).toBe(true)
462
+ })
463
+
464
+ it('lists every endpoint as its own row below the aggregation threshold', () => {
465
+ const few = [pod('a', true, '10.0.0.1'), pod('b', true, '10.0.0.2')]
466
+ const t = trace(few, few.map((x) => p({ target: `${x.ip}:8080` })))
467
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
468
+ const eps = g.nodes.find((n) => n.kind === 'PODS')!
469
+ expect(eps.kind).toBe('PODS')
470
+ expect(eps.podRows).toHaveLength(2)
471
+ expect(eps.moreRows).toBe(0)
472
+ })
473
+ })
474
+
475
+ // The layout is computed rather than hand-placed precisely so that content of
476
+ // any length lays out without collisions. These pin that guarantee.
477
+ describe('layout collisions', () => {
478
+ const overlaps = (a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }) =>
479
+ a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
480
+
481
+ const scenarios: [string, Trace][] = [
482
+ ['single ready pod', trace([pod('a', true, '10.0.0.1')], [p({})])],
483
+ [
484
+ 'long names and many pods',
485
+ trace(
486
+ Array.from({ length: 4 }, (_, i) => pod(`a-very-long-workload-name-with-hash-${i}-abcdef123456`, i !== 3, `10.0.0.${i}`, 'readiness probe failing for 12m')),
487
+ [p({})],
488
+ ),
489
+ ],
490
+ ['no backends at all', trace([], [])],
491
+ ['large sampled population', trace(Array.from({ length: 9 }, (_, i) => pod(`p${i}`, true, `10.0.0.${i}`)), [p({})], 240)],
492
+ ]
493
+
494
+ for (const [name, t] of scenarios) {
495
+ for (const originId of ['incluster', 'apiserver'] as const) {
496
+ it(`never overlaps two nodes — ${name}, from ${originId}`, () => {
497
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, originId) })
498
+ for (let i = 0; i < g.nodes.length; i++) {
499
+ for (let j = i + 1; j < g.nodes.length; j++) {
500
+ expect(overlaps(g.nodes[i], g.nodes[j]), `${g.nodes[i].id} overlaps ${g.nodes[j].id}`).toBe(false)
501
+ }
502
+ }
503
+ })
504
+
505
+ // The pill's BOX must clear every node, not merely its centre point - a
506
+ // centre-only check passed while a wide pill overran its gutter onto the
507
+ // node beside it.
508
+ it(`keeps every edge pill's full box clear of every node — ${name}, from ${originId}`, () => {
509
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, originId) })
510
+ const PILL_H = 20
511
+ for (const e of g.edges) {
512
+ const box = { x: e.px - PILL_MAX_PX / 2, y: e.py - PILL_H / 2, w: PILL_MAX_PX, h: PILL_H }
513
+ for (const n of g.nodes) {
514
+ expect(overlaps(box, n), `pill ${e.id} overruns onto node ${n.id}`).toBe(false)
515
+ }
516
+ }
517
+ })
518
+ }
519
+ }
520
+
521
+ it('keeps every node inside the reported canvas', () => {
522
+ const t = trace([pod('a', true, '10.0.0.1'), pod('b', false, '10.0.0.2', 'x')], [p({})])
523
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'apiserver') })
524
+ for (const n of g.nodes) {
525
+ expect(n.x + n.w).toBeLessThanOrEqual(g.canvas.w)
526
+ expect(n.y + n.h).toBeLessThanOrEqual(g.canvas.h)
527
+ }
528
+ })
529
+
530
+ it('truncates a long edge label and keeps the full text as a title', () => {
531
+ const t = trace([pod('a', true, '10.0.0.1')], [p({})])
532
+ const long = 'an extremely long piece of probe evidence that would otherwise overrun its gutter entirely'
533
+ const g = buildGraph({ trace: t, route: route({ evidence: long }), origin: pick(t, 'incluster') })
534
+ const entry = g.edges.find((e) => e.id === 'e:origin-subject')!
535
+ expect(entry.label.length).toBeLessThan(long.length)
536
+ expect(entry.title).toBe(long)
537
+ })
538
+ })
539
+
540
+ describe('publishNotReadyAddresses', () => {
541
+ // With this set the dataplane routes to NotReady Pods, so none of them is
542
+ // excluded. Honouring it in the subtitle but not in the rows told the user a
543
+ // Pod was "never routed to" while it was in fact serving traffic.
544
+ const publishing = (pods: PodStatus[], probes: ProbeResult[]) => {
545
+ const t = trace(pods, probes)
546
+ t.downstream[1].meta = { ready: pods.length, selected: pods.length, publishNotReadyAddresses: true }
547
+ return t
548
+ }
549
+
550
+ it('does not call a NotReady endpoint excluded when not-ready addresses are published', () => {
551
+ const t = publishing([pod('a', false, '10.0.0.1', 'readiness failing')], [p({ path: 'data', target: '10.0.0.1:8080', ok: true })])
552
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
553
+ const rows = g.nodes.find((n) => n.kind === 'PODS')!.podRows!
554
+ expect(rows[0].mark).not.toBe('excluded')
555
+ expect(rows[0].detail).toMatch(/sent traffic anyway/)
556
+ })
557
+
558
+ it('reports no excluded endpoints in the population anomalies', () => {
559
+ const t = publishing([pod('a', false, '10.0.0.1', 'x'), pod('b', true, '10.0.0.2')], [p({ path: 'data', ok: true })])
560
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
561
+ expect(g.nodes.find((n) => n.kind === 'PODS')!.anomalies?.some((a) => a.mark === 'excluded')).toBe(false)
562
+ })
563
+
564
+ it('still excludes NotReady endpoints when the Service withholds them', () => {
565
+ const t = trace([pod('a', false, '10.0.0.1', 'x')], [p({ path: 'data', ok: true })])
566
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
567
+ expect(g.nodes.find((n) => n.kind === 'PODS')!.podRows![0].mark).toBe('excluded')
568
+ })
569
+
570
+ it('never renders the published count as a readiness count', () => {
571
+ const t = trace([pod('a', true, '10.0.0.1')], [p({})])
572
+ t.downstream[1].meta = { ready: 3, selected: 3, publishNotReadyAddresses: true }
573
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
574
+ const eps = g.nodes.find((n) => n.kind === 'PODS')!
575
+ expect(eps.sub).toMatch(/not-ready Pods are sent traffic too/)
576
+ // The "N of M ready" phrasing would be a lie here: `ready` is a PUBLISHED
577
+ // count when the Service publishes not-ready addresses.
578
+ expect(eps.sub).not.toMatch(/\d+ of \d+ ready/)
579
+ })
580
+ })
581
+
582
+ // The backend produces a parsed cause + action per hop. The graph consumed them
583
+ // only to pick a dot colour, so the one sentence answering "what is wrong with
584
+ // this hop" sat behind a click.
585
+ describe('hop findings are carried onto the node', () => {
586
+ const withFindings = (findings: Trace['downstream'][number]['findings']): Trace => ({
587
+ subject: { kind: 'Service', name: 'checkout', namespace: 'store' },
588
+ verdict: 'degraded',
589
+ brokenAt: -1,
590
+ upstreams: [],
591
+ downstream: [{ resource: { kind: 'Service', name: 'checkout', namespace: 'store' }, edge: 'entry:Service', findings }],
592
+ })
593
+
594
+ it('prefers the parsed cause, which is written to be short', () => {
595
+ const t = withFindings([
596
+ { code: 'svc:port', severity: 'warning', message: 'Service targetPort 80->:3006 matches no port the ready pods declare', cause: 'Service targetPort likely wrong', action: 'Confirm the targetPort' },
597
+ ])
598
+ const n = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') }).nodes.find((x) => x.kind === 'SERVICE')!
599
+ expect(n.notes?.[0].text).toBe('Service targetPort likely wrong')
600
+ // the long message and the remediation stay for the hover
601
+ expect(n.notes?.[0].detail).toMatch(/matches no port/)
602
+ expect(n.notes?.[0].detail).toMatch(/Confirm the targetPort/)
603
+ })
604
+
605
+ it('falls back to the message when no cause was parsed', () => {
606
+ const t = withFindings([{ code: 'svc:nopods', severity: 'warning', message: 'Selector matches no pods' }])
607
+ const n = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') }).nodes.find((x) => x.kind === 'SERVICE')!
608
+ expect(n.notes?.[0].text).toBe('Selector matches no pods')
609
+ })
610
+
611
+ it('leads with the worst severity and collapses the tail', () => {
612
+ const t = withFindings([
613
+ { code: 'a', severity: 'info', message: 'info one' },
614
+ { code: 'b', severity: 'critical', message: 'critical one' },
615
+ { code: 'c', severity: 'warning', message: 'warning one' },
616
+ ])
617
+ const n = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') }).nodes.find((x) => x.kind === 'SERVICE')!
618
+ expect(n.notes?.[0].text).toBe('critical one')
619
+ expect(n.notes?.[1].text).toBe('warning one')
620
+ expect(n.notes?.[2].text).toMatch(/\+1 more/)
621
+ })
622
+
623
+ it('reserves height for a note, so a node cannot grow into the row below', () => {
624
+ const t = withFindings([{ code: 'x', severity: 'warning', message: 'a'.repeat(120) }])
625
+ const withNote = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') }).nodes.find((x) => x.kind === 'SERVICE')!
626
+ const bare = buildGraph({ trace: withFindings([]), route: route(), origin: pick(withFindings([]), 'local') }).nodes.find((x) => x.kind === 'SERVICE')!
627
+ expect(withNote.h).toBeGreaterThan(bare.h)
628
+ })
629
+
630
+ // The stronger invariant now that notes are headlines: a node's height is
631
+ // bounded by the LAYOUT, not by how much the producer wrote. A message ten
632
+ // times longer must not make the box ten times taller.
633
+ it('does not grow with the length of the message', () => {
634
+ const node = (msg: string) => {
635
+ const t = withFindings([{ code: 'x', severity: 'warning', message: msg }])
636
+ return buildGraph({ trace: t, route: route(), origin: pick(t, 'local') }).nodes.find((x) => x.kind === 'SERVICE')!.h
637
+ }
638
+ expect(node('a'.repeat(400))).toBe(node('a'.repeat(120)))
639
+ })
640
+
641
+ it('a clean hop carries no notes at all', () => {
642
+ const t = withFindings([])
643
+ const n = buildGraph({ trace: t, route: route(), origin: pick(t, 'local') }).nodes.find((x) => x.kind === 'SERVICE')!
644
+ expect(n.notes ?? []).toHaveLength(0)
645
+ })
646
+ })
647
+
648
+ // On a Gateway with several attached routes, every branch rendered identically:
649
+ // changing the selected scenario changed nothing on screen, so there was no way
650
+ // to tell which branch you were diagnosing.
651
+ describe('fan-out branch focus', () => {
652
+ const routeHop = (name: string, host: string) => ({
653
+ resource: { kind: 'HTTPRoute', name, namespace: 'edge' },
654
+ edge: 'Gateway->HTTPRoute',
655
+ findings: [],
656
+ config: { hostnames: [host] },
657
+ })
658
+ const fanout = (): Trace => ({
659
+ subject: { kind: 'Gateway', name: 'primary-gateway', namespace: 'edge' },
660
+ verdict: 'degraded',
661
+ brokenAt: -1,
662
+ upstreams: [],
663
+ downstream: [routeHop('shop', 'shop.example.com'), routeHop('checkout', 'checkout.example.com'), routeHop('admin', 'admin.example.com')],
664
+ })
665
+ const branch = (t: Trace, r: RouteResult, name: string) =>
666
+ buildGraph({ trace: t, route: r, origin: pick(t, 'local') }).nodes.find((n) => n.name === name)!
667
+
668
+ it('marks the branch serving the selected host, and dims its siblings', () => {
669
+ const t = fanout()
670
+ const r = route({ route: 'checkout.example.com/', target: 'checkout-api:80' })
671
+ expect(branch(t, r, 'checkout').dim).toBeFalsy()
672
+ expect(branch(t, r, 'shop').dim).toBe(true)
673
+ expect(branch(t, r, 'admin').dim).toBe(true)
674
+ })
675
+
676
+ it('selecting a different scenario moves the focus', () => {
677
+ const t = fanout()
678
+ expect(branch(t, route({ route: 'shop.example.com/' }), 'shop').dim).toBeFalsy()
679
+ expect(branch(t, route({ route: 'shop.example.com/' }), 'checkout').dim).toBe(true)
680
+ })
681
+
682
+ it('says WHY a sibling is dimmed rather than just greying it', () => {
683
+ const t = fanout()
684
+ expect(branch(t, route({ route: 'checkout.example.com/' }), 'shop').sub).toMatch(/not on the selected path/)
685
+ })
686
+
687
+ it('dims the edge into an off-path branch too', () => {
688
+ const t = fanout()
689
+ const g = buildGraph({ trace: t, route: route({ route: 'checkout.example.com/' }), origin: pick(t, 'local') })
690
+ const off = g.edges.find((e) => e.id.includes('shop'))!
691
+ const on = g.edges.find((e) => e.id.includes('checkout'))!
692
+ expect(off.mark).toBe('excluded')
693
+ expect(on.mark).toBe('config')
694
+ })
695
+
696
+ it('dims NOTHING when the scenario matches no branch', () => {
697
+ // A graph with every branch greyed out says "none of this is relevant",
698
+ // which is worse than saying nothing.
699
+ const t = fanout()
700
+ const g = buildGraph({ trace: t, route: route({ route: 'unknown.example.com/', target: 'nope:80' }), origin: pick(t, 'local') })
701
+ expect(g.nodes.filter((n) => n.dim)).toHaveLength(0)
702
+ })
703
+
704
+ it('dims nothing when every branch matches', () => {
705
+ const t = fanout()
706
+ t.downstream = [routeHop('a', 'x.example.com'), routeHop('b', 'x.example.com')]
707
+ const g = buildGraph({ trace: t, route: route({ route: 'x.example.com/' }), origin: pick(t, 'local') })
708
+ expect(g.nodes.filter((n) => n.dim)).toHaveLength(0)
709
+ })
710
+
711
+ it('falls back to the backend NAME when the scenario names no host', () => {
712
+ const t = fanout()
713
+ const g = buildGraph({ trace: t, route: route({ route: ':80 -> 8080', target: 'checkout:80' }), origin: pick(t, 'local') })
714
+ expect(g.nodes.find((n) => n.name === 'checkout')!.dim).toBeFalsy()
715
+ expect(g.nodes.find((n) => n.name === 'shop')!.dim).toBe(true)
716
+ })
717
+ })
718
+
719
+ describe('exception-first collapsing of a wide fan-out', () => {
720
+ const rh = (name: string, host: string, findings: Trace['downstream'][number]['findings'] = []) => ({
721
+ resource: { kind: 'HTTPRoute', name, namespace: 'edge' },
722
+ edge: 'Gateway->HTTPRoute',
723
+ findings,
724
+ config: { hostnames: [host] },
725
+ })
726
+ const wide = (): Trace => ({
727
+ subject: { kind: 'Gateway', name: 'gw', namespace: 'edge' },
728
+ verdict: 'degraded',
729
+ brokenAt: -1,
730
+ upstreams: [],
731
+ downstream: [
732
+ rh('shop', 'shop.example.com'),
733
+ rh('checkout', 'checkout.example.com'),
734
+ rh('account', 'account.example.com'),
735
+ rh('admin', 'admin.example.com', [{ code: 'x', severity: 'warning', message: 'Not accepted by the parent Gateway' }]),
736
+ rh('docs', 'docs.example.com'),
737
+ rh('status', 'status.example.com'),
738
+ rh('api', 'api.example.com'),
739
+ ],
740
+ })
741
+
742
+ it('keeps the selected branch and anything with findings; collapses the quiet rest', () => {
743
+ const g = buildGraph({ trace: wide(), route: route({ route: 'checkout.example.com/' }), origin: pick(wide(), 'local') })
744
+ const names = g.nodes.map((n) => n.name)
745
+ expect(names).toContain('checkout') // selected
746
+ expect(names).toContain('admin') // has a finding
747
+ expect(names).toContain('5 more routes') // shop, account, docs, status, api
748
+ expect(names).not.toContain('shop')
749
+ })
750
+
751
+ it('collapses by relevance, never by position', () => {
752
+ // "api" sorts last but is the selected branch, so it must survive.
753
+ const g = buildGraph({ trace: wide(), route: route({ route: 'api.example.com/' }), origin: pick(wide(), 'local') })
754
+ expect(g.nodes.map((n) => n.name)).toContain('api')
755
+ })
756
+
757
+ it('says the collapsed rows are quiet, not truncated', () => {
758
+ const g = buildGraph({ trace: wide(), route: route({ route: 'checkout.example.com/' }), origin: pick(wide(), 'local') })
759
+ const more = g.nodes.find((n) => n.id === 'collapsed:backends')!
760
+ expect(more.sub).toMatch(/nothing found/)
761
+ expect(g.edges.find((e) => e.id === 'e:subject-collapsed')?.mark).toBe('config')
762
+ })
763
+
764
+ it('does not collapse a fan-out small enough to read whole', () => {
765
+ const t = wide()
766
+ t.downstream = t.downstream.slice(0, 3)
767
+ const g = buildGraph({ trace: t, route: route({ route: 'checkout.example.com/' }), origin: pick(t, 'local') })
768
+ expect(g.nodes.some((n) => n.id === 'collapsed:backends')).toBe(false)
769
+ expect(g.nodes.map((n) => n.name)).toContain('shop')
770
+ })
771
+
772
+ it('no node overlaps once collapsed', () => {
773
+ const g = buildGraph({ trace: wide(), route: route({ route: 'checkout.example.com/' }), origin: pick(wide(), 'local') })
774
+ for (let i = 0; i < g.nodes.length; i++)
775
+ for (let j = i + 1; j < g.nodes.length; j++) {
776
+ const a = g.nodes[i], b = g.nodes[j]
777
+ const hit = a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
778
+ expect(hit).toBe(false)
779
+ }
780
+ })
781
+ })
782
+
783
+ // End-to-end proof that the misattribution is gone: one trace, one route, two
784
+ // vantages that disagree - each origin must render its OWN truth.
785
+ describe('the graph reads the selected origin\'s own result', () => {
786
+ const disagreeing = (): RouteResult => ({
787
+ route: 'checkout.example.com/',
788
+ target: 'checkout:80',
789
+ // the merged rollup says the whole route failed...
790
+ outcome: 'unreachable',
791
+ confidence: 'real',
792
+ evidence: 'connection refused',
793
+ // ...while in-cluster actually succeeded.
794
+ byVantage: [
795
+ { vantage: 'in-cluster', path: 'data', outcome: 'verified', confidence: 'real', evidence: 'HTTP 200' },
796
+ { vantage: 'local', path: 'data', outcome: 'unreachable', confidence: 'real', evidence: 'connection refused' },
797
+ ],
798
+ })
799
+
800
+ const edgeMark = (originId: 'incluster' | 'local') => {
801
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: originId === 'incluster' ? 'in-cluster' : 'local', path: 'data', ok: true })])
802
+ const g = buildGraph({ trace: t, route: disagreeing(), origin: pick(t, originId) })
803
+ return g.edges.find((e) => e.id === 'e:origin-subject')!.mark
804
+ }
805
+
806
+ it('shows the in-cluster vantage as proved even though the rollup says unreachable', () => {
807
+ expect(edgeMark('incluster')).toBe('proved')
808
+ })
809
+
810
+ it('shows the laptop vantage as failed', () => {
811
+ expect(edgeMark('local')).toBe('failed')
812
+ })
813
+
814
+ it('does not mark Pods "blocked by an earlier failure" for the vantage that got through', () => {
815
+ // deliveryBlocked previously followed the merged outcome, so a working
816
+ // vantage's Pods were labelled as never-reached.
817
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'in-cluster', path: 'data', ok: true })])
818
+ const g = buildGraph({ trace: t, route: disagreeing(), origin: pick(t, 'incluster') })
819
+ const rows = g.nodes.find((n) => n.kind === 'PODS')!.podRows!
820
+ expect(rows[0].mark).not.toBe('blocked')
821
+ })
822
+ })
823
+
824
+ describe('one route\'s evidence never spreads across sibling backends', () => {
825
+ // A multi-backend Route: two backends, each with its own Pods hop - the shape
826
+ // the producer fan-out emits and the shape that makes cross-attribution
827
+ // possible in the first place.
828
+ const multiBackend = (): Trace => ({
829
+ subject: { kind: 'HTTPRoute', name: 'shop-route', namespace: 'store' },
830
+ verdict: 'degraded',
831
+ brokenAt: -1,
832
+ upstreams: [{ resource: { kind: 'Gateway', name: 'gw', namespace: 'infra' }, edge: 'gateway->route', findings: [] }],
833
+ downstream: [
834
+ { resource: { kind: 'HTTPRoute', name: 'shop-route', namespace: 'store' }, edge: 'entry:HTTPRoute', findings: [] },
835
+ { resource: { kind: 'Service', name: 'web', namespace: 'store' }, edge: 'HTTPRoute->Service', findings: [], config: { clusterIP: '10.96.0.1' } },
836
+ {
837
+ resource: { kind: 'Pods', name: '', namespace: 'store' },
838
+ edge: 'Service->Pods', findings: [], meta: { ready: 1, selected: 1 },
839
+ config: { pods: [pod('w1', true, '10.0.0.1')], podTotal: 1 },
840
+ },
841
+ { resource: { kind: 'Service', name: 'api', namespace: 'store' }, edge: 'HTTPRoute->Service', findings: [], config: { clusterIP: '10.96.0.2' } },
842
+ {
843
+ resource: { kind: 'Pods', name: '', namespace: 'store' },
844
+ edge: 'Service->Pods', findings: [], meta: { ready: 1, selected: 1 },
845
+ config: { pods: [pod('a1', true, '10.0.0.2')], podTotal: 1 },
846
+ },
847
+ ],
848
+ })
849
+
850
+ const brokenOnWeb = (): RouteResult => ({
851
+ route: 'shop.example.com/web', target: 'web:80', targetNamespace: 'store',
852
+ outcome: 'unreachable', confidence: 'real',
853
+ byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'unreachable', failedBoundary: 'service-routing' }],
854
+ })
855
+
856
+ // The producer establishes a boundary for ONE backend. Painting it on every
857
+ // Service->Pods edge condemns a sibling whose pods were never probed.
858
+ it('paints "breaks here" only on the branch that established it', () => {
859
+ const t = multiBackend()
860
+ const g = buildGraph({ trace: t, route: brokenOnWeb(), origin: pick(t, 'incluster') })
861
+ const broken = g.edges.filter((e) => e.label === 'breaks here')
862
+ expect(broken).toHaveLength(1)
863
+ expect(broken[0].id).toBe('e:n:Service/store/web::pods')
864
+ })
865
+
866
+ // Same leak, other edge: a bypassing origin dials the route's backend, not
867
+ // every backend the Route happens to declare.
868
+ it('lands the bypass edge only on the selected route\'s backend', () => {
869
+ const t = multiBackend()
870
+ const g = buildGraph({ trace: t, route: brokenOnWeb(), origin: pick(t, 'incluster') })
871
+ const dialled = g.edges.filter((e) => e.label === 'dialled directly')
872
+ expect(dialled).toHaveLength(1)
873
+ expect(dialled[0].id).toBe('e:origin-n:Service/store/web')
874
+ })
875
+
876
+ it('colours nothing when the owning branch cannot be identified', () => {
877
+ const t = multiBackend()
878
+ const orphan: RouteResult = {
879
+ route: 'shop.example.com/gone', target: 'gone:80', targetNamespace: 'store',
880
+ outcome: 'unreachable', confidence: 'real',
881
+ byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'unreachable', failedBoundary: 'service-routing' }],
882
+ }
883
+ const g = buildGraph({ trace: t, route: orphan, origin: pick(t, 'incluster') })
884
+ expect(g.edges.some((e) => e.label === 'breaks here')).toBe(false)
885
+ })
886
+ })
887
+
888
+ describe('evidence written for a hover is shortened for a pill', () => {
889
+ // The producer writes full explanations. Rendered whole, the edge pill
890
+ // truncated mid-word to "Connectior refused...." and the pod row ran outside
891
+ // its node box.
892
+ it('keeps only the first sentence', () => {
893
+ expect(shortEvidence('Connection refused. Nothing is listening on the port.')).toBe('Connection refused')
894
+ })
895
+
896
+ it('drops a trailing full stop', () => {
897
+ expect(shortEvidence('Connection refused.')).toBe('Connection refused')
898
+ })
899
+
900
+ // "·" separates parts of ONE fact, not sentences - cutting there would throw
901
+ // away the latency that makes the row worth reading.
902
+ it('leaves a dot-separated detail intact', () => {
903
+ expect(shortEvidence('HTTP 200 · 41 ms')).toBe('HTTP 200 · 41 ms')
904
+ })
905
+
906
+ it('leaves a decimal point alone', () => {
907
+ expect(shortEvidence('slow · 1.4 s')).toBe('slow · 1.4 s')
908
+ })
909
+
910
+ it('is safe on empty input', () => {
911
+ expect(shortEvidence(undefined)).toBe('')
912
+ })
913
+
914
+ it('shortens the edge pill but keeps the full text on the hover', () => {
915
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'in-cluster', path: 'data', ok: false, tone: 'unhealthy' })])
916
+ const long = 'Connection refused. Nothing is listening on the port.'
917
+ const g = buildGraph({
918
+ trace: t,
919
+ route: route({ outcome: 'unreachable', confidence: 'real', evidence: long }),
920
+ origin: pick(t, 'incluster'),
921
+ })
922
+ const edge = g.edges.find((e) => e.id === 'e:origin-subject')!
923
+ expect(edge.label).toBe('Connection refused')
924
+ // The explanation is moved to the hover, not thrown away.
925
+ expect(edge.title).toBe(long)
926
+ })
927
+ })
928
+
929
+ describe('the workload behind the Service appears in the graph', () => {
930
+ // The PRODUCER resolves the workload from the Pods' owner chain and sends it
931
+ // as the pods hop's config.workload. The UI never substitutes the workload
932
+ // the reader happened to open: a nil from the producer means the Pods have no
933
+ // single owner, and drawing the opened workload there would claim another
934
+ // workload's Pods for it.
935
+ const traceWithWorkload = (name = 'external-secrets-webhook') => {
936
+ const t = trace([pod('a', true, '10.0.0.1')], [p({})])
937
+ t.downstream![1].config!.workload = { kind: 'Deployment', name, namespace: 'store' }
938
+ return t
939
+ }
940
+ const withWorkload = () => {
941
+ const t = traceWithWorkload()
942
+ return buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
943
+ }
944
+
945
+ it('draws it between the Service and its Pods', () => {
946
+ const g = withWorkload()
947
+ const svc = g.nodes.find((n) => n.kind === 'SERVICE')!
948
+ const wl = g.nodes.find((n) => n.kind === 'DEPLOYMENT')!
949
+ const pods = g.nodes.find((n) => n.kind === 'PODS')!
950
+ expect(wl.name).toBe('external-secrets-webhook')
951
+ expect(svc.x).toBeLessThan(wl.x)
952
+ expect(wl.x).toBeLessThan(pods.x)
953
+ })
954
+
955
+ // It routes nothing, so neither of its edges may claim observed traffic.
956
+ it('declares both its edges rather than claiming traffic', () => {
957
+ const g = withWorkload()
958
+ const into = g.edges.find((e) => e.id.endsWith('-workload'))!
959
+ const outOf = g.edges.find((e) => e.label === 'runs')!
960
+ expect(into.mark).toBe('config')
961
+ expect(outOf.mark).toBe('config')
962
+ })
963
+
964
+ // The producer localises the break to the SERVICE's own routing. Inserting a
965
+ // node after the Service must not slide that blame onto the workload, which
966
+ // routes nothing.
967
+ it('a Service-routing break is a boundary span, and no node is blamed', () => {
968
+ const t = traceWithWorkload('web')
969
+ const broken = route({
970
+ outcome: 'unreachable',
971
+ confidence: 'real',
972
+ byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'unreachable', failedBoundary: 'service-routing' }],
973
+ })
974
+ const g = buildGraph({
975
+ trace: t,
976
+ route: broken,
977
+ origin: pick(t, 'incluster'),
978
+ })
979
+ // One observed break on the edge leaving the Service...
980
+ const breaks = g.edges.filter((e) => e.label === 'breaks here')
981
+ expect(breaks).toHaveLength(1)
982
+ expect(breaks[0].id).toMatch(/-workload$/)
983
+ expect(breaks[0].boundary).toBe('start')
984
+ // ...continued as the SAME break across the span the workload sits inside -
985
+ // failed colour, marked continuation, no pill (empty label), never a second
986
+ // failed observation.
987
+ const cont = g.edges.find((e) => e.boundary === 'continuation')!
988
+ expect(cont.mark).toBe('failed')
989
+ expect(cont.label).toBe('')
990
+ // The break anchors to the Service's EXIT. A halo may sit on the Service
991
+ // itself (the route edge INTO it failed) but never on the workload or the
992
+ // Pods - that would blame a node for its segment's failure.
993
+ expect(g.breakAtExitOf).toBe('n:Service/store/shop')
994
+ expect([undefined, g.breakAtExitOf]).toContain(g.breakNodeId)
995
+ expect(g.breakNodeId).not.toBe('n:workload')
996
+ expect(g.nonNetworkNodeIds).toContain('n:workload')
997
+ })
998
+
999
+ it('is absent when no workload is in scope', () => {
1000
+ const t = trace([pod('a', true, '10.0.0.1')], [p({})])
1001
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
1002
+ expect(g.nodes.some((n) => n.kind === 'DEPLOYMENT')).toBe(false)
1003
+ // The count lives on the name line; the sub stays silent when it would
1004
+ // only repeat it.
1005
+ expect(g.nodes.find((n) => n.kind === 'PODS')!.name).toMatch(/eligible/)
1006
+ })
1007
+ })
1008
+
1009
+ describe('only the vantage being exercised reads as running', () => {
1010
+ // The running flag used to reach every origin, so one probe run animated all
1011
+ // of them - the same "one vantage's state under another's name" this view
1012
+ // exists to prevent, in motion.
1013
+ const t = () => trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'data', ok: true })])
1014
+
1015
+ it('marks the in-cluster edge running and leaves the others alone', () => {
1016
+ const tr = t()
1017
+ const g = buildGraph({
1018
+ trace: tr,
1019
+ route: route(),
1020
+ origin: pick(tr, 'incluster'),
1021
+ origins: buildOrigins(tr),
1022
+ running: true,
1023
+ })
1024
+ const originEdges = g.edges.filter((e) => e.id.startsWith('e:origin'))
1025
+ expect(originEdges.some((e) => e.mark === 'running')).toBe(true)
1026
+ // The laptop probed and succeeded; a run elsewhere must not repaint it.
1027
+ const local = g.edges.find((e) => e.id.includes('local'))
1028
+ if (local) expect(local.mark).not.toBe('running')
1029
+ })
1030
+
1031
+ it('marks nothing running when no test is in flight', () => {
1032
+ const tr = t()
1033
+ const g = buildGraph({ trace: tr, route: route(), origin: pick(tr, 'incluster'), origins: buildOrigins(tr) })
1034
+ expect(g.edges.some((e) => e.mark === 'running')).toBe(false)
1035
+ })
1036
+ })
1037
+
1038
+ describe('a finding on a node is a headline, not the whole message', () => {
1039
+ // Rendered in full this was nine lines inside a graph node, burying the path
1040
+ // the node sits on.
1041
+ const gatewayMsg =
1042
+ "Accepted: NoMatchingListenerHostname - Gateway primary-gateway-system/primary-gateway listeners http, https: There were no hostname intersections between the HTTPRoute and this parent ref's Listener(s)."
1043
+
1044
+ it('translates a known condition reason into plain language', () => {
1045
+ // "Accepted: NoMatchingListenerHostname" is precise and user-hostile - it
1046
+ // reads as if the route WAS accepted. Known Gateway API reasons get plain
1047
+ // words; the raw message stays on the hover.
1048
+ expect(noteHeadline(gatewayMsg)).toBe('Not attached: no listener matches its hosts')
1049
+ })
1050
+
1051
+ // A colon usually introduces the very thing worth naming, so it is not a cut
1052
+ // point - cutting there would leave a bare "Accepted". An UNKNOWN reason
1053
+ // passes through untranslated rather than being guessed at.
1054
+ it('does not cut at a colon, and passes unknown reasons through', () => {
1055
+ expect(noteHeadline('Accepted: SomeVendorSpecificReason')).toBe('Accepted: SomeVendorSpecificReason')
1056
+ })
1057
+
1058
+ it('cuts at a sentence break too', () => {
1059
+ expect(noteHeadline('Connection refused. Nothing is listening on the port.')).toBe('Connection refused')
1060
+ })
1061
+
1062
+ it('truncates a headline with no break at all', () => {
1063
+ const out = noteHeadline('x'.repeat(120))
1064
+ expect(out.length).toBeLessThanOrEqual(46)
1065
+ expect(out.endsWith('…')).toBe(true)
1066
+ })
1067
+
1068
+ it('leaves a short message alone', () => {
1069
+ expect(noteHeadline('No ready endpoints')).toBe('No ready endpoints')
1070
+ })
1071
+
1072
+ it('puts the whole message on the hover', () => {
1073
+ const t = trace([pod('a', true, '10.0.0.1')], [p({})])
1074
+ t.downstream[0].findings = [{ code: 'gw:x', severity: 'warning', message: gatewayMsg }]
1075
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
1076
+ const note = g.nodes.find((n) => n.kind === 'SERVICE')!.notes![0]
1077
+ expect(note.text).toBe('Not attached: no listener matches its hosts')
1078
+ expect(note.detail).toBe(gatewayMsg)
1079
+ })
1080
+ })
1081
+
1082
+ describe('the selected vantage is a real scope boundary', () => {
1083
+ // Every drawn vantage contributes edges. Reading the break off all of them let
1084
+ // a workstation failure order the report while the in-cluster vantage the
1085
+ // reader had selected was succeeding.
1086
+ it('does not take the break from another vantage\'s failed edge', () => {
1087
+ const t = trace([pod('a', true, '10.0.0.1')], [
1088
+ p({ vantage: 'in-cluster', path: 'data', ok: true, tone: 'healthy' }),
1089
+ p({ vantage: 'local', path: 'data', ok: false, tone: 'unhealthy' }),
1090
+ ])
1091
+ const good = route({
1092
+ outcome: 'verified',
1093
+ confidence: 'real',
1094
+ byVantage: [
1095
+ { vantage: 'in-cluster', path: 'data', outcome: 'verified' },
1096
+ { vantage: 'local', path: 'data', outcome: 'unreachable' },
1097
+ ],
1098
+ })
1099
+ const g = buildGraph({ trace: t, route: good, origin: pick(t, 'incluster'), origins: buildOrigins(t) })
1100
+ expect(g.breakNodeId).toBeUndefined()
1101
+ })
1102
+
1103
+ it('still finds the break on the selected vantage\'s own edge', () => {
1104
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'in-cluster', path: 'data', ok: false, tone: 'unhealthy' })])
1105
+ const bad = route({ outcome: 'unreachable', confidence: 'real' })
1106
+ const g = buildGraph({ trace: t, route: bad, origin: pick(t, 'incluster'), origins: buildOrigins(t) })
1107
+ expect(g.breakNodeId).toBeDefined()
1108
+ })
1109
+
1110
+ // Sorting rendered nodes by position turned PARALLEL backends into what read
1111
+ // as a serial chain, and swept in branches the route never touches.
1112
+ it('reports the route\'s own chain in traversal order - the workload is display, not journey', () => {
1113
+ const t = trace([pod('a', true, '10.0.0.1')], [p({})])
1114
+ t.downstream![1].config!.workload = { kind: 'Deployment', name: 'web', namespace: 'store' }
1115
+ const g = buildGraph({
1116
+ trace: t,
1117
+ route: route(),
1118
+ origin: pick(t, 'incluster'),
1119
+ })
1120
+ const kinds = g.pathNodeIds.map((id) => g.nodes.find((n) => n.id === id)!.kind)
1121
+ // The Deployment renders inline but is NOT a hop the request traverses -
1122
+ // it rides in `interleave` for display order instead.
1123
+ expect(kinds).toEqual(['SERVICE', 'PODS'])
1124
+ expect(g.interleave).toEqual([{ id: 'n:workload', afterId: 'n:Service/store/shop' }])
1125
+ })
1126
+
1127
+ it('emits no duplicates and only nodes that exist', () => {
1128
+ const t = trace([pod('a', true, '10.0.0.1')], [p({})])
1129
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
1130
+ expect(new Set(g.pathNodeIds).size).toBe(g.pathNodeIds.length)
1131
+ for (const id of g.pathNodeIds) expect(g.nodes.some((n) => n.id === id)).toBe(true)
1132
+ })
1133
+ })
1134
+
1135
+ describe('only the relay may downgrade a proved result', () => {
1136
+ // A laptop is outside the cluster, but a request it sends to a real address
1137
+ // travels the real network - "proxied" means "bypassed the dataplane", which
1138
+ // is a claim about the API-server relay and nothing else.
1139
+ it('keeps a laptop success as proved', () => {
1140
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'data', ok: true, tone: 'healthy' })])
1141
+ const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'local') })
1142
+ expect(g.edges.find((e) => e.id === 'e:origin-subject')!.mark).toBe('proved')
1143
+ })
1144
+
1145
+ it('still downgrades the API-server relay', () => {
1146
+ const t = trace([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver', ok: true, tone: 'healthy' })])
1147
+ const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: pick(t, 'apiserver') })
1148
+ expect(g.edges.find((e) => e.id === 'e:origin-subject')!.mark).toBe('proxied')
1149
+ })
1150
+ })
1151
+
1152
+ // Re-derives an edge's cubic from its rendered path so a test can walk the
1153
+ // SAME curve the browser draws, rather than trusting a single reported point.
1154
+ function edgeCubic(e: { d: string }) {
1155
+ const m = e.d.match(/M([\d.-]+),([\d.-]+) C([\d.-]+),([\d.-]+) ([\d.-]+),([\d.-]+) ([\d.-]+),([\d.-]+)/)!
1156
+ const n = m.slice(1).map(Number)
1157
+ return { p0: [n[0], n[1]], p1: [n[2], n[3]], p2: [n[4], n[5]], p3: [n[6], n[7]] } as const
1158
+ }
1159
+ function cubicPoint(c: ReturnType<typeof edgeCubic>, t: number) {
1160
+ const u = 1 - t
1161
+ const f = (a: number, b: number, cc: number, d: number) => u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * cc + t * t * t * d
1162
+ return { x: f(c.p0[0], c.p1[0], c.p2[0], c.p3[0]), y: f(c.p0[1], c.p1[1], c.p2[1], c.p3[1]) }
1163
+ }
1164
+
1165
+ describe('an edge that skips a column routes around it', () => {
1166
+ // The in-cluster probe bypasses an HTTPRoute, so its edge spans from the
1167
+ // origin to the BACKEND - straight through the Route node sitting between
1168
+ // them, parking its pill on top of the very node it does not touch.
1169
+ const routeTrace = (): Trace => ({
1170
+ subject: { kind: 'HTTPRoute', name: 'shop-route', namespace: 'store' },
1171
+ verdict: 'healthy',
1172
+ brokenAt: -1,
1173
+ upstreams: [{ resource: { kind: 'Gateway', name: 'gw', namespace: 'infra' }, edge: 'gateway->route', findings: [] }],
1174
+ downstream: [
1175
+ {
1176
+ resource: { kind: 'HTTPRoute', name: 'shop-route', namespace: 'store' },
1177
+ edge: 'entry:HTTPRoute',
1178
+ // A REAL route carries findings, which make the node tall. A bare node
1179
+ // is short enough that a too-shallow detour still clears it - which is
1180
+ // exactly how a broken detour passed its first test.
1181
+ findings: [
1182
+ {
1183
+ code: 'gw:no-listener',
1184
+ severity: 'warning',
1185
+ message:
1186
+ "Accepted: NoMatchingListenerHostname - Gateway primary-gateway-system/primary-gateway listeners http, https: There were no hostname intersections between the HTTPRoute and this parent ref's Listener(s).",
1187
+ },
1188
+ ],
1189
+ },
1190
+ { resource: { kind: 'Service', name: 'shop', namespace: 'store' }, edge: 'HTTPRoute->Service', findings: [], config: { clusterIP: '10.96.0.1' } },
1191
+ {
1192
+ resource: { kind: 'Pods', name: '', namespace: 'store' },
1193
+ edge: 'Service->Pods', findings: [], meta: { ready: 1, selected: 1 },
1194
+ config: { pods: [pod('a', true, '10.0.0.1')], podTotal: 1 },
1195
+ },
1196
+ ],
1197
+ })
1198
+
1199
+ const bypassEdge = () => {
1200
+ const t = routeTrace()
1201
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster'), origins: buildOrigins(t) })
1202
+ const e = g.edges.find((x) => x.id === 'e:origin-n:Service/store/shop')!
1203
+ return { g, e }
1204
+ }
1205
+
1206
+ it('clears the node it passes, instead of crossing it', () => {
1207
+ const { g, e } = bypassEdge()
1208
+ const routeNode = g.nodes.find((n) => n.kind === 'HTTPROUTE')!
1209
+ // The pill rides the curve, so its height is a fair probe of where the line
1210
+ // actually goes at the point it crosses that column.
1211
+ expect(e.py).toBeGreaterThan(routeNode.y + routeNode.h)
1212
+ })
1213
+
1214
+ // The obstacle's HEIGHT is what broke the first attempt: a cubic reaches only
1215
+ // ~3/4 of the way to its control points, so clearance has to be solved for,
1216
+ // not assumed.
1217
+ it('clears a tall node by as much as a short one', () => {
1218
+ const { g, e } = bypassEdge()
1219
+ const routeNode = g.nodes.find((n) => n.kind === 'HTTPROUTE')!
1220
+ expect(routeNode.h).toBeGreaterThan(90)
1221
+ expect(e.py - (routeNode.y + routeNode.h)).toBeGreaterThan(8)
1222
+ })
1223
+
1224
+ // The API-server capsule sits in the control lane, well ABOVE the route it
1225
+ // bypasses. Forcing every detour downward sent it swooping under the node and
1226
+ // back up to the Service - longer, and it reads as a detour to nowhere.
1227
+ it('routes an edge coming from above OVER the node, not under it', () => {
1228
+ const t = routeTrace()
1229
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'apiserver'), origins: buildOrigins(t) })
1230
+ const e = g.edges.find((x) => x.id === 'e:origin-n:Service/store/shop')!
1231
+ const routeNode = g.nodes.find((n) => n.kind === 'HTTPROUTE')!
1232
+ expect(e.py).toBeLessThan(routeNode.y)
1233
+ })
1234
+
1235
+ it('still routes an edge coming from below UNDER the node', () => {
1236
+ const { g, e } = bypassEdge()
1237
+ const routeNode = g.nodes.find((n) => n.kind === 'HTTPROUTE')!
1238
+ expect(e.py).toBeGreaterThan(routeNode.y + routeNode.h)
1239
+ })
1240
+
1241
+ // The pill rides the curve's midpoint, so it can sit clear while the LINE
1242
+ // still grazes the node's far corner - an asymmetric edge has already
1243
+ // descended by the time it crosses there. Sample the whole span.
1244
+ it('clears the node along its entire width, not just at the midpoint', () => {
1245
+ const t = routeTrace()
1246
+ for (const originId of ['apiserver', 'incluster'] as const) {
1247
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, originId), origins: buildOrigins(t) })
1248
+ const e = g.edges.find((x) => x.id === 'e:origin-n:Service/store/shop')!
1249
+ const n = g.nodes.find((x) => x.kind === 'HTTPROUTE')!
1250
+ const c = edgeCubic(e)
1251
+ for (let i = 1; i < 20; i++) {
1252
+ const pt = cubicPoint(c, i / 20)
1253
+ if (pt.x < n.x || pt.x > n.x + n.w) continue
1254
+ const clears = pt.y < n.y || pt.y > n.y + n.h
1255
+ expect(clears).toBe(true)
1256
+ }
1257
+ }
1258
+ })
1259
+
1260
+ it('reserves canvas height for the detour', () => {
1261
+ const { g, e } = bypassEdge()
1262
+ expect(g.canvas.h).toBeGreaterThanOrEqual(e.py)
1263
+ })
1264
+
1265
+ // Only edges that actually skip something detour; an ordinary hop-to-hop edge
1266
+ // must stay where it was.
1267
+ it('leaves an adjacent-column edge alone', () => {
1268
+ const t = trace([pod('a', true, '10.0.0.1')], [p({})])
1269
+ const g = buildGraph({ trace: t, route: route(), origin: pick(t, 'incluster') })
1270
+ const e = g.edges.find((x) => x.label === 'selects')!
1271
+ const pods = g.nodes.find((n) => n.kind === 'PODS')!
1272
+ expect(e.py).toBeLessThan(pods.y + pods.h)
1273
+ })
1274
+ })
1275
+
1276
+ describe('an entry hop reports its own result, not the route rollup', () => {
1277
+ // The route is built from the BACKEND's probes. A laptop that dialled a public
1278
+ // Ingress and got an answer therefore had no route row at all, and the entry
1279
+ // edge - drawn from the rollup - read "not tested" while the trace held six
1280
+ // successful dials against that very Ingress.
1281
+ const withIngress = (): Trace => ({
1282
+ subject: { kind: 'Service', name: 'web', namespace: 'prod' },
1283
+ verdict: 'healthy',
1284
+ brokenAt: -1,
1285
+ upstreams: [
1286
+ {
1287
+ resource: { kind: 'Ingress', name: 'web', namespace: 'prod' },
1288
+ edge: 'ingress->service',
1289
+ findings: [],
1290
+ config: { hostnames: ['web.example.com'], addresses: ['34.23.121.209'] },
1291
+ probes: [
1292
+ { layer: 'tcp', target: '34.23.121.209:443', vantage: 'local', ok: true },
1293
+ { layer: 'http', target: 'web.example.com', vantage: 'local', ok: true, tone: 'reached', detail: 'HTTP 404 · reached' },
1294
+ ],
1295
+ },
1296
+ ],
1297
+ downstream: [
1298
+ {
1299
+ resource: { kind: 'Service', name: 'web', namespace: 'prod' },
1300
+ edge: 'service',
1301
+ findings: [],
1302
+ config: { clusterIP: '10.96.0.1' },
1303
+ // From a laptop the ClusterIP is only reachable through the relay.
1304
+ probes: [{ layer: 'http', target: 'web:80', vantage: 'local', path: 'apiserver', ok: true, tone: 'healthy', detail: 'HTTP 404' }],
1305
+ },
1306
+ {
1307
+ resource: { kind: 'Pods', name: '', namespace: 'prod' },
1308
+ edge: 'service->pods', findings: [], meta: { ready: 1, selected: 1 },
1309
+ config: { pods: [pod('a', true, '10.0.0.1')], podTotal: 1 },
1310
+ },
1311
+ ],
1312
+ })
1313
+
1314
+ // The route carries only the relayed backend result, so it has no local/data row.
1315
+ const routeViaRelay = () =>
1316
+ route({
1317
+ outcome: 'reached',
1318
+ confidence: 'indirect',
1319
+ byVantage: [{ vantage: 'local', path: 'apiserver', outcome: 'reached' }],
1320
+ })
1321
+
1322
+ it('marks the entry edge from the entry\'s own dials', () => {
1323
+ const t = withIngress()
1324
+ const g = buildGraph({ trace: t, route: routeViaRelay(), origin: pick(t, 'local'), origins: buildOrigins(t) })
1325
+ const e = g.edges.find((x) => x.id === 'e:origin-n:Ingress/prod/web')!
1326
+ // The dial ANSWERED with a 404 (tone 'reached') - real evidence the entry
1327
+ // serves, not a clean pass. It must not draw the same solid green as a 2xx:
1328
+ // that painted 'HTTP 404 · reached' as healthy at a glance.
1329
+ expect(e.mark).toBe('answered')
1330
+ expect(e.label).not.toMatch(/not tested/i)
1331
+ })
1332
+
1333
+ it('does not credit the entry to a vantage that never dialled it', () => {
1334
+ const t = withIngress()
1335
+ const g = buildGraph({ trace: t, route: routeViaRelay(), origin: pick(t, 'incluster'), origins: buildOrigins(t) })
1336
+ const e = g.edges.find((x) => x.id === 'e:origin-n:Ingress/prod/web')
1337
+ if (e) expect(e.mark).not.toBe('proved')
1338
+ })
1339
+
1340
+ // A proved glyph beside the words "not tested" is the contradiction this
1341
+ // whole change exists to remove; the pair must always agree.
1342
+ it('never pairs an evidence glyph with the words not tested', () => {
1343
+ const t = withIngress()
1344
+ const g = buildGraph({ trace: t, route: routeViaRelay(), origin: pick(t, 'local'), origins: buildOrigins(t) })
1345
+ const evidence: Mark[] = ['proved', 'answered', 'proxied', 'failed', 'slow']
1346
+ for (const e of g.edges) {
1347
+ if (evidence.includes(e.mark)) expect(e.label).not.toMatch(/^not tested$/i)
1348
+ }
1349
+ const capsule = g.nodes.find((n) => n.isOrigin)!
1350
+ const verdict = capsule.anomalies?.[0]
1351
+ if (verdict && evidence.includes(verdict.mark)) expect(verdict.text).not.toMatch(/^not tested$/i)
1352
+ })
1353
+
1354
+ // A hop this vantage never dialled is untested AT THAT HOP, whatever it
1355
+ // managed elsewhere - an HTTPRoute has no address to dial at all.
1356
+ it('marks an undialled hop untested even when the vantage reached others', () => {
1357
+ const t = withIngress()
1358
+ t.upstreams.push({
1359
+ resource: { kind: 'HTTPRoute', name: 'web', namespace: 'prod' },
1360
+ edge: 'route->service',
1361
+ findings: [],
1362
+ config: { hostnames: ['web.example.com'] },
1363
+ probes: [{ layer: 'tcp', target: '', vantage: 'local', ok: false, skipped: true, reason: 'route has no own address' }],
1364
+ })
1365
+ const g = buildGraph({ trace: t, route: routeViaRelay(), origin: pick(t, 'local'), origins: buildOrigins(t) })
1366
+ const e = g.edges.find((x) => x.id === 'e:origin-n:HTTPRoute/prod/web')!
1367
+ expect(e.mark).toBe('untested')
1368
+ expect(e.label).toMatch(/not tested/i)
1369
+ })
1370
+
1371
+ it('prefers the most specific layer that answered', () => {
1372
+ const t = withIngress()
1373
+ const ev = hopEvidenceFor(t.upstreams[0], buildOrigins(t).find((o) => o.id === 'local')!)!
1374
+ expect(ev.title).toMatch(/HTTP 404/)
1375
+ })
1376
+ })
1377
+
1378
+ describe('an all-skipped origin never claims a network verdict', () => {
1379
+ it('capsule and edge share one label, and it is not "not routable"', () => {
1380
+ const o = { id: 'apiserver', mark: 'blocked' } as never
1381
+ expect(originNoEvidenceLabel(o)).toBe('couldn’t test')
1382
+ const trace = {
1383
+ subject: { kind: 'Service', name: 'shop', namespace: 'store' },
1384
+ verdict: 'unknown',
1385
+ brokenAt: -1,
1386
+ upstreams: [],
1387
+ routes: [],
1388
+ downstream: [
1389
+ {
1390
+ resource: { kind: 'Service', name: 'shop', namespace: 'store' },
1391
+ edge: 'service',
1392
+ findings: [],
1393
+ probes: [{ layer: 'http', target: 'shop:443', vantage: 'local', path: 'apiserver', ok: false, skipped: true, reason: 'x' }],
1394
+ },
1395
+ ],
1396
+ } as never
1397
+ const ev = originEntryEvidence(trace, { route: 'r', target: 'shop:443', outcome: 'not-tested' } as never, {
1398
+ id: 'apiserver',
1399
+ mark: 'blocked',
1400
+ lane: 'control',
1401
+ } as never)
1402
+ expect(ev.label).toBe('couldn’t test')
1403
+ })
1404
+ it('an attempted in-cluster run that could not start keeps its own words', () => {
1405
+ expect(originNoEvidenceLabel({ id: 'incluster', mark: 'blocked', unavailable: 'image pull failed' } as never)).toBe('test couldn’t run')
1406
+ })
1407
+ })