@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,623 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { traceToSubgraph, outcomeToStatus } from './traceToSubgraph'
3
+ import { reachVerdict } from './reachVerdict'
4
+ import type { Trace } from './types'
5
+
6
+ const baseTrace = (o: Partial<Trace>): Trace => ({
7
+ subject: { kind: 'Service', namespace: 'prod', name: 'echo' },
8
+ upstreams: [],
9
+ downstream: [],
10
+ verdict: 'healthy',
11
+ brokenAt: -1,
12
+ ...o,
13
+ })
14
+
15
+ describe('outcomeToStatus - node = own health, NOT vantage', () => {
16
+ it('verified over REAL traffic is healthy (green)', () => {
17
+ expect(outcomeToStatus('verified', 'real')).toBe('healthy')
18
+ })
19
+ it('verified via the API proxy (indirect) is STILL healthy - the resource responded; the vantage caveat lives on the edge', () => {
20
+ expect(outcomeToStatus('verified', 'indirect')).toBe('healthy')
21
+ })
22
+ it('reached is healthy (it responded); server-error is degraded (app 5xx); unreachable is unhealthy; not-tested is unknown', () => {
23
+ expect(outcomeToStatus('reached', 'indirect')).toBe('healthy')
24
+ expect(outcomeToStatus('server-error', 'real')).toBe('degraded')
25
+ expect(outcomeToStatus('unreachable', 'real')).toBe('unhealthy')
26
+ expect(outcomeToStatus('not-tested')).toBe('unknown')
27
+ })
28
+ })
29
+
30
+ describe('traceToSubgraph', () => {
31
+ it('maps 2 upstream ingresses + service + pods into nodes + routes-to edges', () => {
32
+ const t = baseTrace({
33
+ upstreams: [
34
+ { resource: { kind: 'Ingress', namespace: 'prod', name: 'a' }, edge: 'Ingress->Service', findings: [] },
35
+ { resource: { kind: 'Ingress', namespace: 'prod', name: 'b' }, edge: 'Ingress->Service', findings: [] },
36
+ ],
37
+ downstream: [
38
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] },
39
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [] },
40
+ ],
41
+ routes: [{ route: 'echo', target: 'echo:80', outcome: 'verified', confidence: 'real' }],
42
+ })
43
+ const topo = traceToSubgraph(t)
44
+ // Ingress a, Ingress b, Service echo, Pods = 4 nodes
45
+ expect(topo.nodes).toHaveLength(4)
46
+ const svc = topo.nodes.find((n) => n.kind === 'Service')
47
+ expect(svc?.status).toBe('healthy') // verified+real
48
+ // each ingress → service, service → pods = 3 edges, all routes-to
49
+ expect(topo.edges).toHaveLength(3)
50
+ expect(topo.edges.every((e) => e.type === 'routes-to')).toBe(true)
51
+ const svcId = svc!.id
52
+ expect(topo.edges.filter((e) => e.target === svcId)).toHaveLength(2) // two ingresses converge
53
+ })
54
+
55
+ it('a proxy-only reached service node stays HEALTHY - vantage lives on the edge, not the node', () => {
56
+ const t = baseTrace({
57
+ verdict: 'unknown',
58
+ downstream: [
59
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] },
60
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [] },
61
+ ],
62
+ routes: [{ route: 'echo', target: 'echo:80', outcome: 'verified', confidence: 'indirect' }],
63
+ })
64
+ const topo = traceToSubgraph(t)
65
+ const svc = topo.nodes.find((n) => n.kind === 'Service')
66
+ // The service RESPONDED (just via the proxy) → its own health is fine → green.
67
+ // The "real in-cluster traffic not confirmed" caveat belongs to the PATH (the
68
+ // edge style + the verdict), never to recoloring the node amber.
69
+ expect(svc?.status).toBe('healthy')
70
+ expect(svc?.status).not.toBe('degraded')
71
+ })
72
+
73
+ it('flags a critical-finding hop as unhealthy (the break)', () => {
74
+ const t = baseTrace({
75
+ verdict: 'broken',
76
+ brokenAt: 1,
77
+ downstream: [
78
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] },
79
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [{ code: 'problem:x', severity: 'critical', message: '0/2 ready' }] },
80
+ ],
81
+ routes: [{ route: 'echo', target: 'echo:80', outcome: 'unreachable', confidence: 'real' }],
82
+ })
83
+ const topo = traceToSubgraph(t)
84
+ expect(topo.nodes.find((n) => n.kind === 'Service')?.status).toBe('unhealthy') // unreachable route
85
+ expect(topo.nodes.find((n) => n.kind === 'Pods')?.status).toBe('unhealthy') // critical finding
86
+ })
87
+
88
+ it('Service "0/N selected pods ready" is a backend SYMPTOM → Service amber (wired, no backend); the red fault sits on the Pods node', () => {
89
+ const t = baseTrace({
90
+ verdict: 'broken',
91
+ brokenAt: 0,
92
+ downstream: [
93
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [
94
+ { code: 'problem:0/1 selected pods ready', severity: 'critical', message: '0/1 selected pods ready' },
95
+ ] },
96
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', meta: { ready: 0, selected: 1 }, findings: [
97
+ { code: 'problem:CrashLoopBackOff', severity: 'critical', message: 'CrashLoopBackOff' },
98
+ ] },
99
+ ],
100
+ // Indirect (proxy-only) unreachable → the route doesn't independently condemn
101
+ // the Service node; only the finding does, and that's the symptom we cap.
102
+ routes: [{ route: 'echo', target: 'echo:80', outcome: 'unreachable', confidence: 'indirect' }],
103
+ })
104
+ const topo = traceToSubgraph(t)
105
+ expect(topo.nodes.find((n) => n.kind === 'Service')?.status).toBe('degraded') // wired, no backend - NOT red
106
+ expect(topo.nodes.find((n) => n.kind === 'Pods')?.status).toBe('unhealthy') // the real fault
107
+ })
108
+
109
+ it('partial-ready backend (ready>0, one pod crashing) → Pods node amber, NOT red - it still serves traffic', () => {
110
+ const t = baseTrace({
111
+ verdict: 'degraded',
112
+ brokenAt: -1,
113
+ downstream: [
114
+ { resource: { kind: 'Service', namespace: 'prod', name: 'dual' }, edge: 'entry:Service', findings: [] },
115
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', meta: { ready: 1, selected: 2 }, findings: [
116
+ { code: 'problem:CrashLoopBackOff', severity: 'critical', message: 'CrashLoopBackOff' },
117
+ ] },
118
+ ],
119
+ routes: [{ route: 'dual', outcome: 'reached', confidence: 'indirect' }],
120
+ })
121
+ const topo = traceToSubgraph(t)
122
+ expect(topo.nodes.find((n) => n.kind === 'Pods')?.status).toBe('degraded') // serves via the ready pod - not the false red
123
+ })
124
+
125
+ it('does NOT color the Pods node for a NetworkPolicy finding (path concern, not own-health)', () => {
126
+ // A would-deny is a PATH restriction; the pod itself answered HTTP 200. The
127
+ // node must stay healthy - the policy lives in the hop detail + edge, never
128
+ // the node dot. Painting it amber would over-claim the pod is unwell.
129
+ const t = baseTrace({
130
+ verdict: 'healthy',
131
+ brokenAt: -1,
132
+ downstream: [
133
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [], probes: [{ layer: 'http', target: 'echo:80', vantage: 'local', path: 'apiserver', ok: true }] },
134
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [{ code: 'netpol:would-deny', severity: 'warning', message: 'A cluster network rule would block traffic on :80' }], probes: [{ layer: 'http', target: 'pod port 80', vantage: 'local', path: 'apiserver', ok: true }] },
135
+ ],
136
+ routes: [{ route: 'echo', target: 'echo:80', outcome: 'reached', confidence: '' }],
137
+ })
138
+ const topo = traceToSubgraph(t)
139
+ expect(topo.nodes.find((n) => n.kind === 'Pods')?.status).toBe('healthy')
140
+ })
141
+ })
142
+
143
+ describe('traceToSubgraph - router (Ingress/Gateway) node = front-door OWN health', () => {
144
+ const router = (routes: Trace['routes']): Trace =>
145
+ baseTrace({
146
+ subject: { kind: 'Ingress', namespace: 'prod', name: 'gw' },
147
+ verdict: 'degraded',
148
+ downstream: [{ resource: { kind: 'Ingress', namespace: 'prod', name: 'gw' }, edge: 'entry:Ingress', findings: [] }],
149
+ routes,
150
+ })
151
+ const routerStatus = (t: Trace) => traceToSubgraph(t).nodes.find((n) => n.kind === 'Ingress')?.status
152
+
153
+ it('all routes server-error (5xx) → front door is amber, NOT red (traffic flowed, backend errored)', () => {
154
+ expect(routerStatus(router([
155
+ { route: '/a', target: 'a:80', outcome: 'server-error' },
156
+ { route: '/b', target: 'b:80', outcome: 'server-error' },
157
+ ]))).toBe('degraded')
158
+ })
159
+
160
+ it('benign scale-to-zero routes → front door NOT red (dormant ≠ broken)', () => {
161
+ expect(routerStatus(router([
162
+ { route: '/a', target: 'a:80', outcome: 'unreachable', benign: true },
163
+ ]))).not.toBe('unhealthy')
164
+ })
165
+
166
+ it('every route a genuine (non-benign) unreachable → front door red', () => {
167
+ expect(routerStatus(router([
168
+ { route: '/a', target: 'a:80', outcome: 'unreachable' },
169
+ { route: '/b', target: 'b:80', outcome: 'unreachable' },
170
+ ]))).toBe('unhealthy')
171
+ })
172
+
173
+ it('a Service subject scaled to zero (benign unreachable) is amber, NOT red', () => {
174
+ const t = baseTrace({
175
+ subject: { kind: 'Service', namespace: 'prod', name: 'echo' },
176
+ verdict: 'degraded',
177
+ downstream: [{ resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] }],
178
+ routes: [{ route: 'echo', target: 'echo:80', outcome: 'unreachable', benign: true }],
179
+ })
180
+ expect(traceToSubgraph(t).nodes.find((n) => n.kind === 'Service')?.status).not.toBe('unhealthy')
181
+ })
182
+
183
+ it('a benign scale-to-0 route does NOT color its edge red nor cascade-block the pods behind it', () => {
184
+ const t = baseTrace({
185
+ subject: { kind: 'Ingress', namespace: 'prod', name: 'ing' },
186
+ verdict: 'degraded',
187
+ downstream: [
188
+ { resource: { kind: 'Service', namespace: 'prod', name: 'dormant' }, edge: 'Ingress->Service', findings: [] },
189
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [] },
190
+ ],
191
+ routes: [{ route: '/x', target: 'dormant:80', outcome: 'unreachable', benign: true }],
192
+ })
193
+ const topo = traceToSubgraph(t)
194
+ const svc = topo.nodes.find((n) => n.kind === 'Service')!
195
+ expect(topo.edges.find((e) => e.target === svc.id)?.reachOutcome).not.toBe('unreachable')
196
+ const pods = topo.nodes.find((n) => n.kind === 'Pods')!
197
+ expect(topo.edges.find((e) => e.target === pods.id)?.reachOutcome).not.toBe('blocked')
198
+ })
199
+ })
200
+
201
+ const multiTrace = (): Trace =>
202
+ baseTrace({
203
+ subject: { kind: 'Ingress', namespace: 'prod', name: 'multi' },
204
+ verdict: 'degraded',
205
+ downstream: [
206
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'Ingress->Service', findings: [] },
207
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [] },
208
+ { resource: { kind: 'Service', namespace: 'prod', name: 'ghost' }, edge: 'Ingress->Service', findings: [{ code: 'missing_ref:x', severity: 'critical', message: 'Service ghost does not exist' }] },
209
+ ],
210
+ routes: [
211
+ { route: '/web', target: 'echo:80', outcome: 'verified', confidence: 'real' },
212
+ { route: '/api', target: 'ghost', outcome: 'unreachable', confidence: '' },
213
+ ],
214
+ })
215
+
216
+ describe('traceToSubgraph - multi-backend BRANCHES (no linear chaining)', () => {
217
+ it('a second backend hangs off the ENTRY, not after the pods', () => {
218
+ const topo = traceToSubgraph(multiTrace())
219
+ const subjId = topo.nodes.find((n) => n.kind === 'Ingress')!.id
220
+ const echoId = topo.nodes.find((n) => n.name === 'echo')!.id
221
+ const ghostId = topo.nodes.find((n) => n.name === 'ghost')!.id
222
+ const podsId = topo.nodes.find((n) => n.kind === 'Pods')!.id
223
+ // ghost branches off the subject (multi), NOT off pods (the cycle-1 linear bug)
224
+ expect(topo.edges.find((e) => e.target === ghostId)?.source).toBe(subjId)
225
+ expect(topo.edges.find((e) => e.target === echoId)?.source).toBe(subjId)
226
+ // pods hang off THEIR parent service (echo), not the subject
227
+ expect(topo.edges.find((e) => e.target === podsId)?.source).toBe(echoId)
228
+ })
229
+
230
+ it('per-route truth on the EDGE; the router node stays NEUTRAL (no over-attribution)', () => {
231
+ const topo = traceToSubgraph(multiTrace())
232
+ const ghostId = topo.nodes.find((n) => n.name === 'ghost')!.id
233
+ const echoId = topo.nodes.find((n) => n.name === 'echo')!.id
234
+ // the multi Ingress node is NOT painted red though one of its routes is broken
235
+ expect(topo.nodes.find((n) => n.kind === 'Ingress')?.status).not.toBe('unhealthy')
236
+ // the broken route's color is on its edge
237
+ expect(topo.edges.find((e) => e.target === ghostId)?.reachOutcome).toBe('unreachable')
238
+ expect(['verified', 'reached']).toContain(topo.edges.find((e) => e.target === echoId)?.reachOutcome)
239
+ // route labels on the edges
240
+ expect(topo.edges.find((e) => e.target === ghostId)?.label).toBe('/api')
241
+ })
242
+ })
243
+
244
+ describe('traceToSubgraph - frontier rule (no flow past a break)', () => {
245
+ it('a broken Service does NOT draw a reached edge to its pods - it is blocked', () => {
246
+ const t = baseTrace({
247
+ verdict: 'broken',
248
+ brokenAt: 0,
249
+ downstream: [
250
+ { resource: { kind: 'Service', namespace: 'prod', name: 'notready' }, edge: 'entry:Service', findings: [{ code: 'svc:no-endpoints', severity: 'critical', message: '0/2 ready' }] },
251
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [] },
252
+ ],
253
+ routes: [{ route: 'notready', target: 'notready:80', outcome: 'unreachable', confidence: 'real' }],
254
+ subject: { kind: 'Service', namespace: 'prod', name: 'notready' },
255
+ })
256
+ const topo = traceToSubgraph(t)
257
+ const podsId = topo.nodes.find((n) => n.kind === 'Pods')!.id
258
+ const edge = topo.edges.find((e) => e.target === podsId)
259
+ expect(edge?.reachOutcome).toBe('blocked')
260
+ expect(edge?.reachOutcome).not.toBe('reached')
261
+ expect(edge?.reachOutcome).not.toBe('verified')
262
+ })
263
+ })
264
+
265
+ describe('traceToSubgraph - multi-upstream convergence (a broken sibling upstream must not block the subject)', () => {
266
+ it('keeps a reached subject→pods edge + neutral upstream nodes when a sibling upstream has an unrelated broken route', () => {
267
+ const t = baseTrace({
268
+ subject: { kind: 'Service', namespace: 'prod', name: 'echo' },
269
+ upstreams: [
270
+ // `multi` carries a critical from its OWN other route (/api→ghost) - it must NOT
271
+ // paint the multi node red, nor cascade-block the shared echo→pods.
272
+ { resource: { kind: 'Ingress', namespace: 'prod', name: 'multi' }, edge: 'Ingress->Service', findings: [{ code: 'missing_ref:x', severity: 'critical', message: 'ghost missing' }] },
273
+ { resource: { kind: 'Ingress', namespace: 'prod', name: 'wild' }, edge: 'Ingress->Service', findings: [] },
274
+ ],
275
+ downstream: [
276
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [], probes: [{ layer: 'http', target: 'echo:80', vantage: 'local', path: 'apiserver', ok: true }] },
277
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [], probes: [{ layer: 'http', target: 'pod:80', vantage: 'local', path: 'apiserver', ok: true }] },
278
+ ],
279
+ verdict: 'unknown',
280
+ routes: [{ route: 'echo', target: 'echo:80', outcome: 'reached', confidence: 'indirect' }],
281
+ })
282
+ const topo = traceToSubgraph(t)
283
+ // upstream entry nodes are NEUTRAL - never colored by their own/other-route findings
284
+ expect(topo.nodes.find((n) => n.name === 'multi')?.status).toBe('unknown')
285
+ expect(topo.nodes.find((n) => n.name === 'wild')?.status).toBe('unknown')
286
+ const svc = topo.nodes.find((n) => n.kind === 'Service')!
287
+ const pods = topo.nodes.find((n) => n.kind === 'Pods')!
288
+ // the subject→pods edge is REACHED, NOT blocked by the sibling upstream's break
289
+ const svcToPods = topo.edges.find((e) => e.source === svc.id && e.target === pods.id)
290
+ expect(svcToPods?.reachOutcome).toBe('reached')
291
+ expect(svcToPods?.reachOutcome).not.toBe('blocked')
292
+ // both UNPROBED converging upstream→subject edges fall back to the subject's reach
293
+ const upEdges = topo.edges.filter((e) => e.target === svc.id)
294
+ expect(upEdges).toHaveLength(2)
295
+ expect(upEdges.every((e) => e.reachOutcome === 'reached')).toBe(true)
296
+ })
297
+
298
+ it('a PROBED-and-failed upstream entry shows unreachable, not the subject reach', () => {
299
+ // The Service itself is healthy, but this entry path was probed and every probe
300
+ // failed - the edge must reflect the failed entry, not inherit the subject's green.
301
+ const t = baseTrace({
302
+ subject: { kind: 'Service', namespace: 'prod', name: 'echo' },
303
+ upstreams: [
304
+ {
305
+ resource: { kind: 'Ingress', namespace: 'prod', name: 'down' },
306
+ edge: 'Ingress->Service',
307
+ findings: [],
308
+ probes: [{ layer: 'http', target: 'shop.example.com:80', vantage: 'in-cluster', ok: false }],
309
+ },
310
+ ],
311
+ downstream: [
312
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [], probes: [{ layer: 'http', target: 'echo:80', vantage: 'in-cluster', ok: true }] },
313
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [] },
314
+ ],
315
+ verdict: 'broken',
316
+ reason: 'all entry paths into this resource are unreachable; the resource itself looks healthy',
317
+ routes: [{ route: 'echo', target: 'echo:80', outcome: 'verified', confidence: 'real' }],
318
+ })
319
+ const topo = traceToSubgraph(t)
320
+ const svc = topo.nodes.find((n) => n.kind === 'Service')!
321
+ const upEdge = topo.edges.find((e) => e.target === svc.id)
322
+ expect(upEdge?.reachOutcome).toBe('unreachable')
323
+ expect(upEdge?.reachOutcome).not.toBe('reached')
324
+ })
325
+ })
326
+
327
+ describe('reachVerdict - confidence is a level, not an alarm', () => {
328
+ it('reached only via proxy → NEUTRAL (no green ✓ over-claim), but NEVER ⚠', () => {
329
+ const v = reachVerdict(baseTrace({ verdict: 'unknown', routes: [{ route: 'echo', outcome: 'reached', confidence: 'indirect' }], headline: 'Reached via API server' }))
330
+ // Proxy-only must not wear the green ✓ (a non-expert reads it as "works, done")
331
+ // - it's neutral with a caveat. It is also never a ⚠ alarm (the path did reach).
332
+ expect(v.icon).toBe('')
333
+ expect(v.icon).not.toBe('⚠')
334
+ expect(v.tone).toBe('neutral')
335
+ })
336
+ it('broken → ✗', () => {
337
+ expect(reachVerdict(baseTrace({ verdict: 'broken', routes: [{ route: 'x', outcome: 'unreachable' }] })).icon).toBe('✗')
338
+ })
339
+ it('server-error → ⚠ (a real problem)', () => {
340
+ expect(reachVerdict(baseTrace({ verdict: 'degraded', routes: [{ route: 'x', outcome: 'server-error', confidence: 'real' }] })).icon).toBe('⚠')
341
+ })
342
+ it('partial (some reachable, some not) → ⚠', () => {
343
+ const v = reachVerdict(baseTrace({ verdict: 'degraded', routes: [{ route: 'a', outcome: 'reached', confidence: 'real' }, { route: 'b', outcome: 'unreachable' }] }))
344
+ expect(v.icon).toBe('⚠')
345
+ })
346
+ })
347
+
348
+ describe('traceToSubgraph - cycle 5 honesty fixes', () => {
349
+ // Defect 8: a DNS-only live probe is not own-health - the node must read unknown.
350
+ it('a backend whose only live probe is a DNS success is unknown, not green healthy', () => {
351
+ const t = baseTrace({
352
+ subject: { kind: 'Ingress', namespace: 'prod', name: 'ing' },
353
+ downstream: [
354
+ { resource: { kind: 'Ingress', namespace: 'prod', name: 'ing' }, edge: 'entry:Ingress', findings: [], probes: [] },
355
+ { resource: { kind: 'Service', namespace: 'prod', name: 'ext' }, edge: 'Ingress->Service', findings: [], probes: [
356
+ { layer: 'dns', target: 'ext', vantage: 'local', ok: true },
357
+ ] },
358
+ ],
359
+ routes: [],
360
+ })
361
+ const g = traceToSubgraph(t)
362
+ const ext = g.nodes.find((n) => n.name === 'ext')
363
+ expect(ext?.status).not.toBe('healthy')
364
+ expect(ext?.status).toBe('unknown')
365
+ })
366
+
367
+ // Defect 9: worst-route coloring must not cascade-block pods another route reached.
368
+ it('two routes to one backend (port 80 verified, 9090 unreachable) - pods reached by port 80 are NOT blocked', () => {
369
+ const t = baseTrace({
370
+ subject: { kind: 'Ingress', namespace: 'prod', name: 'ing' },
371
+ downstream: [
372
+ { resource: { kind: 'Ingress', namespace: 'prod', name: 'ing' }, edge: 'entry:Ingress', findings: [], probes: [] },
373
+ { resource: { kind: 'Service', namespace: 'prod', name: 'svc' }, edge: 'Ingress->Service', findings: [], probes: [] },
374
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [], probes: [
375
+ { layer: 'http', target: ':80', vantage: 'in-cluster', ok: true, tone: 'healthy' },
376
+ ] },
377
+ ],
378
+ routes: [
379
+ { route: '/web', target: 'svc:80', outcome: 'verified', confidence: 'real' },
380
+ { route: '/api', target: 'svc:9090', outcome: 'unreachable', confidence: 'real' },
381
+ ],
382
+ })
383
+ const g = traceToSubgraph(t)
384
+ const pods = g.nodes.find((n) => n.kind === 'Pods')
385
+ expect((pods?.data as { subtitleOverride?: string })?.subtitleOverride).not.toBe('not reached - break upstream')
386
+ expect(pods?.status).not.toBe('unknown')
387
+ })
388
+ })
389
+
390
+ describe('traceToSubgraph - failed apiserver-proxy probes are indirect evidence, never red', () => {
391
+ it('a node whose ONLY live evidence is a failed proxy probe stays unknown, not unhealthy', () => {
392
+ const t = baseTrace({
393
+ verdict: 'unknown',
394
+ downstream: [
395
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [], probes: [
396
+ { layer: 'http', target: 'echo:80', vantage: 'local', path: 'apiserver', ok: false, tone: 'unhealthy' },
397
+ ] },
398
+ ],
399
+ routes: [{ route: 'echo', target: 'echo:80', outcome: 'unreachable', confidence: 'indirect' }],
400
+ })
401
+ const svc = traceToSubgraph(t).nodes.find((n) => n.kind === 'Service')
402
+ expect(svc?.status).not.toBe('unhealthy')
403
+ expect(svc?.status).toBe('unknown')
404
+ })
405
+
406
+ it('a successful proxy probe still counts as positive evidence beside a failed one', () => {
407
+ const t = baseTrace({
408
+ verdict: 'unknown',
409
+ downstream: [
410
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] },
411
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [], probes: [
412
+ { layer: 'http', target: 'pod-a port 80', vantage: 'local', path: 'apiserver', ok: true, tone: 'healthy' },
413
+ { layer: 'http', target: 'pod-b port 80', vantage: 'local', path: 'apiserver', ok: false, tone: 'unhealthy' },
414
+ ] },
415
+ ],
416
+ routes: [{ route: 'echo', target: 'echo:80', outcome: 'reached', confidence: 'indirect' }],
417
+ })
418
+ const pods = traceToSubgraph(t).nodes.find((n) => n.kind === 'Pods')
419
+ // The failed proxy knock is dropped (indirect); the passing one keeps the
420
+ // node green - it answered.
421
+ expect(pods?.status).toBe('healthy')
422
+ })
423
+
424
+ it('a REAL (non-proxy) failure still paints the node red', () => {
425
+ const t = baseTrace({
426
+ verdict: 'broken',
427
+ downstream: [
428
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [], probes: [
429
+ { layer: 'http', target: '10.0.0.1:80', vantage: 'in-cluster', path: 'data', ok: false, tone: 'unhealthy' },
430
+ ] },
431
+ ],
432
+ routes: [],
433
+ })
434
+ expect(traceToSubgraph(t).nodes.find((n) => n.kind === 'Pods')?.status).toBe('unhealthy')
435
+ })
436
+
437
+ it('a no-route edge whose failures are ALL proxy-vantage reads not-tested, never unreachable/blocked', () => {
438
+ const t = baseTrace({
439
+ verdict: 'unknown',
440
+ downstream: [
441
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] },
442
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [], probes: [
443
+ { layer: 'http', target: 'pod port 80', vantage: 'local', path: 'apiserver', ok: false, tone: 'unhealthy' },
444
+ ] },
445
+ ],
446
+ routes: [{ route: 'echo', target: 'echo:80', outcome: 'unreachable', confidence: 'indirect' }],
447
+ })
448
+ const topo = traceToSubgraph(t)
449
+ const podsId = topo.nodes.find((n) => n.kind === 'Pods')!.id
450
+ expect(topo.edges.find((e) => e.target === podsId)?.reachOutcome).toBe('not-tested')
451
+ })
452
+ })
453
+
454
+ describe('traceToSubgraph - cycle 11 honesty fixes', () => {
455
+ // Defect 3: routeMatchesRef's namespace guard only fired when targetNamespace was
456
+ // SET. A same-ns route (no targetNamespace) could match a cross-ns same-named
457
+ // backend hop, so routeFor's worst-match attached the same-ns route's unreachable
458
+ // to the cross-ns backend and false-condemned it.
459
+ it('a same-ns route\'s unreachable must NOT attach to a cross-ns same-named backend', () => {
460
+ const t = baseTrace({
461
+ subject: { kind: 'Ingress', namespace: 'prod', name: 'ing' },
462
+ verdict: 'degraded',
463
+ downstream: [
464
+ { resource: { kind: 'Ingress', namespace: 'prod', name: 'ing' }, edge: 'entry:Ingress', findings: [] },
465
+ { resource: { kind: 'Service', namespace: 'prod', name: 'api' }, edge: 'Ingress->Service', findings: [] },
466
+ { resource: { kind: 'Service', namespace: 'other', name: 'api' }, edge: 'Ingress->Service', findings: [] },
467
+ ],
468
+ routes: [
469
+ // Same-ns route (no targetNamespace → targets the subject ns 'prod').
470
+ { route: 'api', target: 'api:80', outcome: 'unreachable', confidence: 'real' },
471
+ // Cross-ns backendRef route - actually verified.
472
+ { route: 'api', target: 'api:80', outcome: 'verified', confidence: 'real', targetNamespace: 'other' },
473
+ ],
474
+ })
475
+ const g = traceToSubgraph(t)
476
+ const crossNsEdge = g.edges.find((e) => e.target === 'Service/other/api')
477
+ const sameNsEdge = g.edges.find((e) => e.target === 'Service/prod/api')
478
+ // The cross-ns backend is verified - its own route - never the same-ns unreachable.
479
+ expect(crossNsEdge?.reachOutcome).toBe('verified')
480
+ // The same-ns backend keeps its own unreachable.
481
+ expect(sameNsEdge?.reachOutcome).toBe('unreachable')
482
+ })
483
+ })
484
+
485
+ describe('traceToSubgraph - overridden test path echoes on the route edge', () => {
486
+ const ingressTrace = (): Trace =>
487
+ baseTrace({
488
+ subject: { kind: 'Ingress', namespace: 'prod', name: 'wild' },
489
+ downstream: [
490
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] },
491
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [] },
492
+ ],
493
+ routes: [{ route: '/web', target: 'echo:80', outcome: 'reached', confidence: 'indirect' }],
494
+ })
495
+
496
+ it('defaults to the DECLARED route path, no tooltip', () => {
497
+ const e = traceToSubgraph(ingressTrace()).edges.find((e) => e.label === '/web')
498
+ expect(e).toBeTruthy()
499
+ expect(e?.labelTitle).toBeUndefined()
500
+ })
501
+
502
+ it('shows the OVERRIDDEN tested path as the label, keeps the declared path in the tooltip (never overwrites route identity)', () => {
503
+ const edges = traceToSubgraph(ingressTrace(), '/test').edges
504
+ expect(edges.find((e) => e.label === '/web')).toBeFalsy() // declared path no longer the visible label
505
+ const e = edges.find((e) => e.label === '/test')
506
+ expect(e).toBeTruthy()
507
+ expect(e?.labelTitle).toContain('/web') // declared
508
+ expect(e?.labelTitle).toContain('/test') // tested
509
+ })
510
+
511
+ it('the default "/" path is not treated as an override', () => {
512
+ const e = traceToSubgraph(ingressTrace(), '/').edges.find((e) => e.label === '/web')
513
+ expect(e).toBeTruthy()
514
+ expect(e?.labelTitle).toBeUndefined()
515
+ })
516
+ })
517
+
518
+ describe('traceToSubgraph - DNS success is not transport evidence', () => {
519
+ // An ExternalName whose name resolves but whose every port fails is DOWN. The
520
+ // DNS-ok row must not soften the node to "partially healthy" amber or keep
521
+ // the edge green - resolving a name reaches nothing.
522
+ it('dns-ok + every port failed → node unhealthy (not degraded), edge unreachable', () => {
523
+ const t = baseTrace({
524
+ verdict: 'broken',
525
+ downstream: [
526
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] },
527
+ { resource: { kind: 'ExternalName', namespace: 'prod', name: 'db.example.com' }, edge: 'Service->ExternalName', findings: [], probes: [
528
+ { layer: 'dns', target: 'db.example.com', vantage: 'local', ok: true },
529
+ { layer: 'tcp', target: 'db.example.com:5432', vantage: 'local', ok: false, tone: 'unhealthy' },
530
+ { layer: 'http', target: 'db.example.com:80', vantage: 'local', ok: false, tone: 'unhealthy' },
531
+ ] },
532
+ ],
533
+ routes: [],
534
+ })
535
+ const topo = traceToSubgraph(t)
536
+ const ext = topo.nodes.find((n) => n.kind === 'ExternalName')
537
+ expect(ext?.status).toBe('unhealthy')
538
+ expect(topo.edges.find((e) => e.target === ext?.id)?.reachOutcome).toBe('unreachable')
539
+ })
540
+
541
+ it('dns-ok only (transport skipped) → node unknown, edge not-tested', () => {
542
+ const t = baseTrace({
543
+ verdict: 'unknown',
544
+ downstream: [
545
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] },
546
+ { resource: { kind: 'ExternalName', namespace: 'prod', name: 'db.example.com' }, edge: 'Service->ExternalName', findings: [], probes: [
547
+ { layer: 'dns', target: 'db.example.com', vantage: 'local', ok: true },
548
+ { layer: 'tcp', target: 'db.example.com:5432', vantage: 'local', ok: false, skipped: true, reason: 'not reachable from your machine' },
549
+ ] },
550
+ ],
551
+ routes: [],
552
+ })
553
+ const topo = traceToSubgraph(t)
554
+ const ext = topo.nodes.find((n) => n.kind === 'ExternalName')
555
+ expect(ext?.status).toBe('unknown')
556
+ expect(topo.edges.find((e) => e.target === ext?.id)?.reachOutcome).toBe('not-tested')
557
+ })
558
+
559
+ it('dns FAILURE is still a real break - node unhealthy, edge unreachable', () => {
560
+ const t = baseTrace({
561
+ verdict: 'broken',
562
+ downstream: [
563
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] },
564
+ { resource: { kind: 'ExternalName', namespace: 'prod', name: 'ghost.example.com' }, edge: 'Service->ExternalName', findings: [], probes: [
565
+ { layer: 'dns', target: 'ghost.example.com', vantage: 'local', ok: false, error: 'NXDOMAIN' },
566
+ ] },
567
+ ],
568
+ routes: [],
569
+ })
570
+ const topo = traceToSubgraph(t)
571
+ const ext = topo.nodes.find((n) => n.kind === 'ExternalName')
572
+ expect(ext?.status).toBe('unhealthy')
573
+ expect(topo.edges.find((e) => e.target === ext?.id)?.reachOutcome).toBe('unreachable')
574
+ })
575
+
576
+ it('a real port success still reads reached/healthy beside the dns row', () => {
577
+ const t = baseTrace({
578
+ downstream: [
579
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] },
580
+ { resource: { kind: 'ExternalName', namespace: 'prod', name: 'db.example.com' }, edge: 'Service->ExternalName', findings: [], probes: [
581
+ { layer: 'dns', target: 'db.example.com', vantage: 'local', ok: true },
582
+ { layer: 'http', target: 'db.example.com:80', vantage: 'local', ok: true, tone: 'healthy' },
583
+ ] },
584
+ ],
585
+ routes: [],
586
+ })
587
+ const topo = traceToSubgraph(t)
588
+ const ext = topo.nodes.find((n) => n.kind === 'ExternalName')
589
+ expect(ext?.status).toBe('healthy')
590
+ expect(topo.edges.find((e) => e.target === ext?.id)?.reachOutcome).toBe('reached')
591
+ })
592
+ })
593
+
594
+ describe('traceToSubgraph - publishNotReadyAddresses subtitle honesty', () => {
595
+ // meta.ready counts PUBLISHED endpoints for a publishNotReadyAddresses
596
+ // Service (the Go side stamps ready = selected there) - "3/3 ready" over
597
+ // crashlooping pods is a lie; say what the count actually is.
598
+ it('renders a published count, not "N/M ready", when publishNotReadyAddresses is set', () => {
599
+ const t = baseTrace({
600
+ downstream: [
601
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] },
602
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [], meta: { ready: 3, selected: 3, publishNotReadyAddresses: true } },
603
+ ],
604
+ routes: [],
605
+ })
606
+ const pods = traceToSubgraph(t).nodes.find((n) => n.kind === 'Pods')
607
+ const sub = (pods?.data as { subtitleOverride?: string })?.subtitleOverride
608
+ expect(sub).toBe('3 published (readiness not required)')
609
+ expect(sub).not.toContain('3/3')
610
+ })
611
+
612
+ it('an ordinary Service keeps the "N/M ready" subtitle', () => {
613
+ const t = baseTrace({
614
+ downstream: [
615
+ { resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [] },
616
+ { resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [], meta: { ready: 2, selected: 3 } },
617
+ ],
618
+ routes: [],
619
+ })
620
+ const pods = traceToSubgraph(t).nodes.find((n) => n.kind === 'Pods')
621
+ expect((pods?.data as { subtitleOverride?: string })?.subtitleOverride).toBe('2/3 ready')
622
+ })
623
+ })