@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,37 @@
1
+ import type { ProbeResult, ProbeLayer } from './types'
2
+ import type { StatusTone } from '../ui/status-tone'
3
+ import { hostFromTarget } from './reachVerdict'
4
+
5
+ // A Pods-hop probe target is "<name> port <N>" (apiserver path) or "<ip>:<port>"
6
+ // (data path). The pod identity is the first token with any :port stripped, which
7
+ // joins back to the roster's name (apiserver) or ip (data). hostFromTarget is
8
+ // IPv6-aware for the ip:port form.
9
+ export function podProbeKey(target?: string): string {
10
+ return hostFromTarget((target || '').split(' ')[0])
11
+ }
12
+
13
+ /**
14
+ * Summarizes ONE pod's probe results into a single reachability cell: its worst
15
+ * live outcome (a failure wins, so a bad pod is never masked), else the deepest
16
+ * layer it reached, with the vantage that produced it.
17
+ */
18
+ export function podReach(probes: ProbeResult[]): { tone: StatusTone; text: string; vantage: string } {
19
+ const live = probes.filter((p) => !p.skipped)
20
+ if (live.length === 0) return { tone: 'neutral', text: 'not tested', vantage: '' }
21
+ const vantage = live.some((p) => p.vantage === 'in-cluster') ? 'real' : live.some((p) => p.path === 'apiserver') ? 'via API server' : ''
22
+ const failed = live.find((p) => !p.ok || p.tone === 'unhealthy')
23
+ if (failed) return { tone: 'unhealthy', text: failed.detail || failed.error || `${failed.layer.toUpperCase()} failed`, vantage }
24
+ const order: ProbeLayer[] = ['http', 'tls', 'tcp', 'dns']
25
+ const best = order.map((L) => live.find((p) => p.layer === L && p.ok)).find(Boolean)
26
+ // Only a real-traffic HTTP 2xx is "verified" (green). A reached 3xx/4xx (tone
27
+ // 'reached') or a transport-only TCP/TLS/DNS success proves the port answers, not
28
+ // that the real path serves - so it reads neutral ("reached, not verified"),
29
+ // matching the route pills, instead of a green dot the headline contradicts.
30
+ const tone: StatusTone =
31
+ best?.tone === 'degraded'
32
+ ? 'degraded'
33
+ : best?.layer === 'http' && best?.tone !== 'reached'
34
+ ? 'healthy'
35
+ : 'neutral'
36
+ return { tone, text: best?.detail || 'reached', vantage }
37
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { collapseSkipRows } from './probe-display'
3
+ import type { ProbeResult } from './types'
4
+
5
+ const skip = (overrides: Partial<ProbeResult>): ProbeResult => ({
6
+ layer: 'http',
7
+ target: 'port 53',
8
+ vantage: 'local',
9
+ ok: false,
10
+ skipped: true,
11
+ reason: 'Port named "dns" looks non-HTTP.',
12
+ ...overrides,
13
+ })
14
+
15
+ const ok = (overrides: Partial<ProbeResult>): ProbeResult => ({
16
+ layer: 'http',
17
+ target: 'port 80',
18
+ vantage: 'local',
19
+ ok: true,
20
+ ...overrides,
21
+ })
22
+
23
+ describe('collapseSkipRows', () => {
24
+ it('returns empty for empty input', () => {
25
+ expect(collapseSkipRows([])).toEqual([])
26
+ })
27
+
28
+ it('passes non-skipped rows through unchanged', () => {
29
+ const rows = [ok({ target: '10.0.0.1:80' }), ok({ target: '10.0.0.2:80' })]
30
+ expect(collapseSkipRows(rows)).toEqual(rows)
31
+ })
32
+
33
+ it('collapses identical-reason skip rows into one with count', () => {
34
+ const rows = [skip({ target: 'pod-a port 53' }), skip({ target: 'pod-b port 53' }), skip({ target: 'pod-c port 53' })]
35
+ const out = collapseSkipRows(rows)
36
+ expect(out).toHaveLength(1)
37
+ expect(out[0].target).toBe('3 probes skipped')
38
+ expect(out[0].skipped).toBe(true)
39
+ })
40
+
41
+ it('keeps single skip rows untouched', () => {
42
+ const rows = [skip({ target: 'pod-a port 53' })]
43
+ const out = collapseSkipRows(rows)
44
+ expect(out).toHaveLength(1)
45
+ expect(out[0].target).toBe('pod-a port 53')
46
+ })
47
+
48
+ it('keeps skip rows with different reasons separate', () => {
49
+ const rows = [
50
+ skip({ reason: 'Port named "dns" looks non-HTTP.' }),
51
+ skip({ reason: 'Port named "dns-tcp" looks non-HTTP.' }),
52
+ ]
53
+ expect(collapseSkipRows(rows)).toHaveLength(2)
54
+ })
55
+
56
+ it('keeps skip rows with different paths separate', () => {
57
+ const rows = [
58
+ skip({ path: 'apiserver', target: 'pod-a port 53' }),
59
+ skip({ path: 'data', target: 'pod-a port 53' }),
60
+ ]
61
+ expect(collapseSkipRows(rows)).toHaveLength(2)
62
+ })
63
+
64
+ it('preserves order: collapsed row stays at the position of the first occurrence', () => {
65
+ const rows = [
66
+ ok({ target: '10.0.0.1:80' }),
67
+ skip({ target: 'pod-a port 53' }),
68
+ ok({ target: '10.0.0.2:80' }),
69
+ skip({ target: 'pod-b port 53' }),
70
+ ]
71
+ const out = collapseSkipRows(rows)
72
+ expect(out).toHaveLength(3)
73
+ expect(out[0].target).toBe('10.0.0.1:80')
74
+ expect(out[1].target).toBe('2 probes skipped')
75
+ expect(out[2].target).toBe('10.0.0.2:80')
76
+ })
77
+
78
+ it('keeps skip rows with the same reason but different commands separate', () => {
79
+ // Per-pod port-forward commands differ even when the reason is identical;
80
+ // collapsing would surface only the first pod's command for all of them.
81
+ const rows = [
82
+ skip({ target: 'pod-a port 6379', command: 'kubectl port-forward pod/pod-a -n ns 6379:6379' }),
83
+ skip({ target: 'pod-b port 6379', command: 'kubectl port-forward pod/pod-b -n ns 6379:6379' }),
84
+ ]
85
+ expect(collapseSkipRows(rows)).toHaveLength(2)
86
+ })
87
+
88
+ it('collapses skip rows that share reason AND command', () => {
89
+ const rows = [
90
+ skip({ target: 'a', command: 'kubectl auth can-i get services/proxy -n ns' }),
91
+ skip({ target: 'b', command: 'kubectl auth can-i get services/proxy -n ns' }),
92
+ ]
93
+ const out = collapseSkipRows(rows)
94
+ expect(out).toHaveLength(1)
95
+ expect(out[0].target).toBe('2 probes skipped')
96
+ expect(out[0].command).toBe('kubectl auth can-i get services/proxy -n ns')
97
+ })
98
+
99
+ it('does not dedupe a skip row with no reason (defensive)', () => {
100
+ const rows = [
101
+ skip({ target: 'pod-a port 53', reason: undefined }),
102
+ skip({ target: 'pod-b port 53', reason: undefined }),
103
+ ]
104
+ const out = collapseSkipRows(rows)
105
+ expect(out).toHaveLength(2)
106
+ })
107
+ })
@@ -0,0 +1,37 @@
1
+ import type { ProbeResult } from './types'
2
+
3
+ // collapseSkipRows folds identical (layer, path, reason, command) skip rows
4
+ // into a single row labelled with the collapsed count. Real probe outcomes
5
+ // (success, failure, partial latency rows) pass through unchanged - only
6
+ // "didn't run, here's why" rows get deduped. Driving case: a Pods hop
7
+ // with three replicas and one non-HTTP port produces three skip rows
8
+ // with identical reasons; the operator only needs to read it once. The
9
+ // command is part of the key so rows whose fill-the-gap commands differ
10
+ // (e.g. a per-pod port-forward) stay separate rather than collapse and
11
+ // surface the wrong pod's command.
12
+ export function collapseSkipRows(rows: ProbeResult[]): ProbeResult[] {
13
+ const out: ProbeResult[] = []
14
+ const seenIdx = new Map<string, number>()
15
+ const counts = new Map<string, number>()
16
+ for (const r of rows) {
17
+ if (!r.skipped || !r.reason) {
18
+ out.push(r)
19
+ continue
20
+ }
21
+ const key = [r.layer, r.path ?? '', r.reason, r.command ?? ''].join('|')
22
+ if (!seenIdx.has(key)) {
23
+ seenIdx.set(key, out.length)
24
+ counts.set(key, 1)
25
+ out.push(r)
26
+ } else {
27
+ counts.set(key, (counts.get(key) ?? 1) + 1)
28
+ }
29
+ }
30
+ return out.map((r) => {
31
+ if (!r.skipped) return r
32
+ const key = [r.layer, r.path ?? '', r.reason, r.command ?? ''].join('|')
33
+ const n = counts.get(key) ?? 1
34
+ if (n <= 1) return r
35
+ return { ...r, target: `${n} probes skipped` }
36
+ })
37
+ }
@@ -0,0 +1,261 @@
1
+ import type { Trace, PodStatus, ProbeResult } from './types'
2
+
3
+ /**
4
+ * Fixtures for the dev-only state switcher. These exist so the empty, failing,
5
+ * sampled and permission-denied states are reachable without engineering a real
6
+ * cluster into each shape - they are NOT demo data and never render outside a
7
+ * dev build.
8
+ *
9
+ * The prototype this view was built from carried a tenth state, "conflicting
10
+ * vantages", where a synthetic probe succeeds and the real caller is refused.
11
+ * It is deliberately absent: RouteResult holds ONE outcome and ONE confidence,
12
+ * so two disagreeing origins cannot both be retained. Faking it here would
13
+ * imply a capability the model does not have.
14
+ */
15
+ export type DevState = 'mixed' | 'break' | 'front' | 'synth' | 'scale' | 'fanout' | 'none' | 'running' | 'stale' | 'rbac'
16
+
17
+ export const DEV_STATES: { id: DevState; label: string }[] = [
18
+ { id: 'mixed', label: '1 · Mixed vantages' },
19
+ { id: 'break', label: '2 · Confirmed break' },
20
+ { id: 'front', label: '3 · Front door verified' },
21
+ { id: 'synth', label: '4 · Synthetic only' },
22
+ { id: 'scale', label: '5 · Aggregated scale' },
23
+ { id: 'fanout', label: '6 · Gateway fan-out' },
24
+ { id: 'none', label: 'No tests yet' },
25
+ { id: 'running', label: 'Probe running' },
26
+ { id: 'stale', label: 'Stale + changed' },
27
+ { id: 'rbac', label: 'Probe not permitted' },
28
+ ]
29
+
30
+ const NS = 'payments'
31
+ const SUBJECT = { kind: 'Service', name: 'checkout-api', namespace: NS }
32
+
33
+ const probe = (o: Partial<ProbeResult>): ProbeResult => ({
34
+ layer: 'http',
35
+ target: 'checkout-api:80',
36
+ vantage: 'in-cluster',
37
+ path: 'data',
38
+ ok: true,
39
+ tone: 'healthy',
40
+ ...o,
41
+ })
42
+
43
+ const pod = (name: string, ready: boolean, ip: string, reason?: string): PodStatus => ({ name, ready, ip, reason })
44
+
45
+ const PODS = [
46
+ pod('checkout-api-7d4c-d4wq', true, '10.244.1.17'),
47
+ pod('checkout-api-7d4c-m8zt', true, '10.244.2.31'),
48
+ pod('checkout-api-7d4c-k9x2', false, '10.244.3.9', 'readiness probe failing for 12m'),
49
+ ]
50
+
51
+ function base(): Trace {
52
+ return {
53
+ subject: SUBJECT,
54
+ verdict: 'healthy',
55
+ brokenAt: -1,
56
+ upstreams: [],
57
+ downstream: [],
58
+ coverage: { tested: 2, passed: 2, failed: 0, skipped: 1 },
59
+ routes: [],
60
+ notTested: [],
61
+ }
62
+ }
63
+
64
+ function serviceHop(clusterIP = '10.96.14.2') {
65
+ return {
66
+ resource: SUBJECT,
67
+ edge: 'service',
68
+ findings: [],
69
+ config: { clusterIP, serviceType: 'ClusterIP', selector: { app: 'checkout-api' }, ports: [{ port: 80, targetPort: '8080' }] },
70
+ probes: [],
71
+ }
72
+ }
73
+
74
+ function podsHop(pods: PodStatus[], probes: ProbeResult[], podTotal?: number) {
75
+ const ready = pods.filter((p) => p.ready).length
76
+ return {
77
+ resource: { kind: 'Pods', name: '', namespace: NS },
78
+ edge: 'service->pods',
79
+ findings: [],
80
+ meta: { ready: podTotal ? 236 : ready, selected: podTotal ?? pods.length },
81
+ config: { pods, podTotal: podTotal ?? pods.length },
82
+ probes,
83
+ }
84
+ }
85
+
86
+ export function devTrace(state: DevState): Trace {
87
+ const t = base()
88
+ const okProbes = [
89
+ probe({ target: '10.244.1.17:8080', detail: 'HTTP 200', latencyNs: 11_000_000 }),
90
+ probe({ target: '10.244.2.31:8080', detail: 'HTTP 200', latencyNs: 9_000_000 }),
91
+ ]
92
+
93
+ if (state === 'none') {
94
+ return {
95
+ ...t,
96
+ verdict: 'unknown',
97
+ unknownClass: 'by-design',
98
+ headline: 'Untested — configuration predicts this path but nothing has been observed',
99
+ coverage: { tested: 0, passed: 0, failed: 0, skipped: 3 },
100
+ downstream: [serviceHop(), podsHop(PODS, [])],
101
+ routes: [
102
+ { route: 'GET / · :80 → 8080', target: ':80 → 8080', outcome: 'not-tested' },
103
+ { route: 'gRPC :9090 → 9090', target: ':9090 → 9090', outcome: 'not-tested' },
104
+ ],
105
+ }
106
+ }
107
+
108
+ if (state === 'break') {
109
+ const failed = [
110
+ probe({ layer: 'tcp', target: '10.244.1.17:8080', ok: false, tone: 'unhealthy', error: 'ECONNREFUSED', detail: 'connection refused' }),
111
+ probe({ layer: 'tcp', target: '10.244.2.31:8080', ok: false, tone: 'unhealthy', error: 'ECONNREFUSED', detail: 'connection refused' }),
112
+ ]
113
+ return {
114
+ ...t,
115
+ verdict: 'broken',
116
+ brokenAt: 1,
117
+ headline: 'Unreachable — every eligible endpoint refused the connection on 8080',
118
+ coverage: { tested: 2, passed: 0, failed: 2, skipped: 1 },
119
+ downstream: [serviceHop(), podsHop(PODS, failed)],
120
+ diagnosis: {
121
+ causeCode: 'svc:port-mismatch',
122
+ summary: 'The Service targetPort (8080) does not match the container port (8081).',
123
+ nextAction: 'Edit the Service targetPort to 8081.',
124
+ culpritResource: SUBJECT,
125
+ },
126
+ routes: [
127
+ { route: 'GET / · :80 → 8080', target: ':80 → 8080', outcome: 'unreachable', failedLayer: 'tcp', confidence: 'real', evidence: 'ECONNREFUSED at both endpoints' },
128
+ { route: 'gRPC :9090 → 9090', target: ':9090 → 9090', outcome: 'reached', confidence: 'indirect', evidence: 'stream reset via proxy' },
129
+ ],
130
+ }
131
+ }
132
+
133
+ if (state === 'scale') {
134
+ const many: PodStatus[] = Array.from({ length: 8 }, (_, i) => pod(`checkout-api-7d4c-s${i}`, true, `10.244.4.${i + 10}`))
135
+ many.push(pod('checkout-api-7d4c-q7r2', true, '10.244.4.99'))
136
+ const probes = [
137
+ ...many.slice(0, 7).map((p) => probe({ target: `${p.ip}:8080`, detail: 'HTTP 200', latencyNs: 12_000_000 })),
138
+ probe({ target: '10.244.4.99:8080', ok: false, tone: 'unhealthy', layer: 'tcp', error: 'ECONNREFUSED', detail: 'connection refused' }),
139
+ probe({ target: '10.244.4.10:8080', detail: 'HTTP 200', latencyNs: 1_900_000_000 }),
140
+ ]
141
+ return {
142
+ ...t,
143
+ verdict: 'degraded',
144
+ headline: 'Mostly reachable — one eligible endpoint refuses connections and most were not probed',
145
+ coverage: { tested: 8, passed: 7, failed: 1, skipped: 228 },
146
+ downstream: [serviceHop(), podsHop(many, probes, 240)],
147
+ routes: [{ route: 'GET / · :80 → 8080', target: ':80 → 8080', outcome: 'reached', confidence: 'real', evidence: '7 of 8 sampled endpoints returned HTTP 200' }],
148
+ }
149
+ }
150
+
151
+ if (state === 'front') {
152
+ const allReady = PODS.map((p) => ({ ...p, ready: true, reason: undefined }))
153
+ return {
154
+ ...t,
155
+ headline: 'Reachable — the front door itself was exercised from outside the cluster',
156
+ coverage: { tested: 3, passed: 3, failed: 0, skipped: 0 },
157
+ upstreams: [
158
+ {
159
+ resource: { kind: 'Gateway', name: 'primary-gateway', namespace: NS },
160
+ edge: 'gateway->service',
161
+ findings: [],
162
+ config: { addresses: ['203.0.113.7'], listeners: [{ name: 'https', port: 443, protocol: 'HTTPS' }] },
163
+ probes: [probe({ layer: 'tls', vantage: 'local', target: '203.0.113.7:443', detail: 'TLS handshake OK' })],
164
+ },
165
+ ],
166
+ downstream: [serviceHop(), podsHop(allReady, okProbes)],
167
+ routes: [
168
+ { route: 'front door · primary-gateway', target: ':443 → 80', outcome: 'verified', confidence: 'real', evidence: 'HTTP 200 · 41 ms from outside the cluster' },
169
+ { route: 'GET / · :80 → 8080', target: ':80 → 8080', outcome: 'verified', confidence: 'real', evidence: 'HTTP 200 · 11 ms' },
170
+ ],
171
+ }
172
+ }
173
+
174
+
175
+ // A Gateway with several attached routes - the ONE shape where the topology
176
+ // itself is the information rather than a formality. It is also the shape the
177
+ // fixtures never covered, which is how a listener-hostname bug survived: the
178
+ // front-door fixture declares a listener with no hostname, so the code path
179
+ // that reads listener hostnames was never exercised here.
180
+ if (state === 'fanout') {
181
+ const GW = { kind: 'Gateway', name: 'primary-gateway', namespace: 'edge' }
182
+ const routeHop = (name: string, host: string, findings: Trace['downstream'][number]['findings'] = []) => ({
183
+ resource: { kind: 'HTTPRoute', name, namespace: NS },
184
+ edge: 'Gateway->HTTPRoute',
185
+ findings,
186
+ config: { hostnames: [host] },
187
+ probes: [],
188
+ })
189
+ return {
190
+ ...t,
191
+ subject: GW,
192
+ verdict: 'degraded',
193
+ headline: 'One of seven routes has no backend - the rest are configured and untested',
194
+ upstreams: [],
195
+ downstream: [
196
+ routeHop('shop', 'shop.example.com'),
197
+ routeHop('checkout', 'checkout.example.com', [
198
+ { code: 'route:no-backend', severity: 'critical', message: "Service 'checkout-api' in namespace 'payments' doesn't exist, so this route's backend receives no traffic.", cause: 'Backend Service missing', action: 'Create the Service or fix the backendRef' },
199
+ ]),
200
+ routeHop('account', 'account.example.com'),
201
+ routeHop('admin', 'admin.example.com', [
202
+ { code: 'route:not-accepted', severity: 'warning', message: 'Not Accepted by the parent Gateway - hostname not covered by any listener.', cause: 'Hostname not served by a listener' },
203
+ ]),
204
+ routeHop('docs', 'docs.example.com'),
205
+ routeHop('status', 'status.example.com'),
206
+ routeHop('api', 'api.example.com'),
207
+ ],
208
+ coverage: { tested: 0, passed: 0, failed: 0, skipped: 7 },
209
+ routes: [
210
+ { route: 'checkout.example.com/', target: 'checkout-api:80', outcome: 'unreachable', confidence: 'real', evidence: 'no backend to reach', failedLayer: 'tcp' },
211
+ { route: 'shop.example.com/', target: 'shop:80', outcome: 'verified', confidence: 'real', evidence: 'HTTP 200 · 14 ms' },
212
+ { route: 'admin.example.com/', target: 'admin:80', outcome: 'not-tested', evidence: 'hostname not covered by any listener' },
213
+ ],
214
+ notTested: [
215
+ { route: 'account.example.com/', reason: 'route not actively tested', reasonClass: 'coverage' },
216
+ { route: 'docs.example.com/', reason: 'route not actively tested', reasonClass: 'coverage' },
217
+ ],
218
+ }
219
+ }
220
+
221
+ if (state === 'rbac') {
222
+ return {
223
+ ...t,
224
+ verdict: 'degraded',
225
+ headline: 'Only control-plane evidence — Radar is not permitted to probe the dataplane',
226
+ coverage: { tested: 1, passed: 1, failed: 0, skipped: 2 },
227
+ downstream: [
228
+ serviceHop(),
229
+ podsHop(PODS, [probe({ vantage: 'local', path: 'apiserver', target: 'checkout-api port 80', detail: 'HTTP 200 via proxy', latencyNs: 12_000_000 })]),
230
+ ],
231
+ routes: [{ route: 'GET / · :80 → 8080', target: ':80 → 8080', outcome: 'verified', confidence: 'indirect', evidence: 'HTTP 200 through the apiserver proxy' }],
232
+ notTested: [{ route: 'gRPC :9090 → 9090', reason: 'probe creation denied by RBAC', reasonClass: 'vantage' }],
233
+ }
234
+ }
235
+
236
+ if (state === 'synth') {
237
+ return {
238
+ ...t,
239
+ verdict: 'degraded',
240
+ headline: 'Reachable from a synthetic probe — the real caller is still unproven',
241
+ downstream: [serviceHop(), podsHop(PODS, okProbes)],
242
+ routes: [{ route: 'GET / · :80 → 8080', target: ':80 → 8080', outcome: 'verified', confidence: 'real', evidence: 'HTTP 200 · 11 ms from the probe Pod' }],
243
+ }
244
+ }
245
+
246
+ // mixed / running / stale share the same trace; the last two are driven by
247
+ // props (inClusterRunning, clusterChangedSinceTest) layered over it.
248
+ return {
249
+ ...t,
250
+ headline: 'Reachable in-cluster on :80 — front door and real caller still unproven',
251
+ downstream: [
252
+ serviceHop(),
253
+ podsHop(PODS, [...okProbes, probe({ vantage: 'local', path: 'apiserver', target: 'checkout-api port 80', detail: 'HTTP 200 via proxy', latencyNs: 12_000_000 })]),
254
+ ],
255
+ routes: [
256
+ { route: 'GET / · :80 → 8080', target: ':80 → 8080', outcome: 'verified', confidence: 'real', evidence: 'DNS 2 ms · TCP 1 ms · HTTP 200 · 11 ms' },
257
+ { route: 'gRPC :9090 → 9090', target: ':9090 → 9090', outcome: 'reached', confidence: 'indirect', evidence: 'connected, stream reset' },
258
+ ],
259
+ notTested: [{ route: 'front door · primary-gateway', reason: 'no request has entered through the Gateway', reasonClass: 'coverage' }],
260
+ }
261
+ }