@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 type { Trace, RouteResult, RouteOutcome, ProbeResult, Hop, VantageResult } from './types'
2
+
3
+ /**
4
+ * A Mark is the evidence class of ONE path segment for one scenario and one
5
+ * origin. It is deliberately distinct from health: a node's dot says whether a
6
+ * resource is well, a mark says what we actually learned about traffic reaching
7
+ * it. The two must never be conflated - a healthy node behind a failed edge
8
+ * stays healthy and the edge stays red.
9
+ *
10
+ * The vocabulary is closed. Adding a member means deciding its glyph, its tone,
11
+ * and its line style, because every mark renders in all three.
12
+ */
13
+ export type Mark =
14
+ | 'proved' // observed through the dataplane - real packets, real path
15
+ | 'answered' // something replied, but not the thing we asked for
16
+ | 'proxied' // reached via the apiserver proxy - bypasses the dataplane
17
+ | 'config' // declared in configuration; predicted, never observed
18
+ | 'failed' // confirmed failure
19
+ | 'blocked' // never ran because an earlier segment failed
20
+ | 'excluded' // not in the path by design (NotReady, not an eligible endpoint)
21
+ | 'untested' // no origin has exercised this
22
+ | 'inconclusive' // ran, and deliberately kept informational - never a verdict
23
+ | 'stale' // observed, but before a change that invalidates it
24
+ | 'running' // in flight right now
25
+ | 'denied' // we are not permitted to run this test
26
+ | 'slow' // answered, far outside the normal latency band
27
+
28
+ export interface MarkStyle {
29
+ glyph: string
30
+ /** CSS custom property reference - never a literal color. */
31
+ color: string
32
+ /** SVG stroke-dasharray; 'none' for the solid lines that carry proof. */
33
+ dash: string
34
+ strokeWidth: number
35
+ strokeOpacity: number
36
+ }
37
+
38
+ /**
39
+ * Only `proved` and `failed` draw solid. Solid means "a packet did this" -
40
+ * everything softer is dashed so a predicted or relayed segment can never be
41
+ * misread as observed truth at a glance.
42
+ */
43
+ export const MARKS: Record<Mark, MarkStyle> = {
44
+ proved: { glyph: '●', color: 'var(--color-success-dark)', dash: 'none', strokeWidth: 2.6, strokeOpacity: 1 },
45
+ answered: { glyph: '◑', color: 'var(--color-warning-dark)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 1 },
46
+ proxied: { glyph: '◐', color: 'var(--color-info)', dash: '8 5', strokeWidth: 1.8, strokeOpacity: 1 },
47
+ config: { glyph: '◇', color: 'var(--text-tertiary)', dash: '2 5', strokeWidth: 1.8, strokeOpacity: 1 },
48
+ failed: { glyph: '✕', color: 'var(--color-error-dark)', dash: 'none', strokeWidth: 2.6, strokeOpacity: 1 },
49
+ blocked: { glyph: '⊘', color: 'var(--text-disabled)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 0.75 },
50
+ excluded: { glyph: '⊗', color: 'var(--text-disabled)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 0.75 },
51
+ // Info-blue, not amber: amber is "answered with a caveat / needs attention"
52
+ // (answered, slow, stale, denied); blue is "not the real path / disposition"
53
+ // (proxied, running, inconclusive); grey is "nothing ran". Three families
54
+ // instead of one overloaded amber.
55
+ inconclusive: { glyph: '◍', color: 'var(--color-info)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 0.9 },
56
+ untested: { glyph: '○', color: 'var(--text-disabled)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 0.75 },
57
+ stale: { glyph: '◷', color: 'var(--color-warning-dark)', dash: '6 4', strokeWidth: 1.8, strokeOpacity: 1 },
58
+ running: { glyph: '◌', color: 'var(--color-info)', dash: '4 4', strokeWidth: 1.8, strokeOpacity: 1 },
59
+ denied: { glyph: '⊘', color: 'var(--color-warning-dark)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 1 },
60
+ slow: { glyph: '◔', color: 'var(--color-warning-dark)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 1 },
61
+ }
62
+
63
+ /** One vocabulary, shared with the lane labels and the sidebar. The legend used
64
+ * to say "observed through the dataplane" while the lane said "REAL TRAFFIC"
65
+ * for the same thing.
66
+ *
67
+ * ONE flat registry; `category` is metadata for grouping at render time. The
68
+ * flat list teaches the enum as one axis, which it is not - what happened,
69
+ * how it was tested, and why nothing ran are different questions. */
70
+ export type MarkCategory = 'happened' | 'tested' | 'why-not' | 'state'
71
+ export const MARK_LEGEND: { mark: Mark; text: string; category: MarkCategory }[] = [
72
+ { mark: 'proved', text: 'a request got through', category: 'happened' },
73
+ // 'answered' also wears a proxy-only failure (nothing answered there), so the
74
+ // legend describes the CLASS - an attempt that fell short of verification -
75
+ // and leaves "answered" vs "couldn't get through" to the chip.
76
+ { mark: 'answered', text: 'the attempt didn’t verify the asked-for path', category: 'happened' },
77
+ { mark: 'failed', text: 'a request was refused', category: 'happened' },
78
+ { mark: 'proxied', text: 'answered via the API server — not live traffic', category: 'tested' },
79
+ { mark: 'inconclusive', text: 'tested, but kept informational — a throwaway identity can\u2019t condemn the path', category: 'tested' },
80
+ { mark: 'config', text: 'configured this way — not tested', category: 'tested' },
81
+ { mark: 'untested', text: 'not tested from here', category: 'why-not' },
82
+ // 'blocked' covers two honest cases - a segment downstream of a failure, and
83
+ // a vantage whose every dial was skipped or abandoned. The legend must not
84
+ // pick one: "never tried" was false for a proxy dial that ran and timed out.
85
+ { mark: 'blocked', text: 'never completed — an earlier failure or a skip stopped it', category: 'why-not' },
86
+ { mark: 'denied', text: 'not allowed to test this', category: 'why-not' },
87
+ { mark: 'excluded', text: 'not sent any traffic', category: 'why-not' },
88
+ { mark: 'running', text: 'testing now', category: 'state' },
89
+ { mark: 'stale', text: 'out of date', category: 'state' },
90
+ { mark: 'slow', text: 'answered, but very slowly', category: 'state' },
91
+ ]
92
+
93
+ export const MARK_CATEGORY_LABEL: Record<MarkCategory, string> = {
94
+ happened: 'what happened',
95
+ tested: 'how it was tested',
96
+ 'why-not': 'why nothing ran',
97
+ state: 'state',
98
+ }
99
+
100
+ export function markStyle(m: Mark): MarkStyle {
101
+ return MARKS[m] ?? MARKS.untested
102
+ }
103
+
104
+ /** Hover text for a mark. The legend sits below the graph and was the ONLY
105
+ * decoder, so reading a symbol meant travelling to it and back every time. */
106
+ export function markHelp(m: Mark): string {
107
+ return MARK_LEGEND.find((l) => l.mark === m)?.text ?? m
108
+ }
109
+
110
+ /**
111
+ * What a LINE means, when the generic mark help would mislead.
112
+ *
113
+ * Structural edges are drawn `config` because nothing was dialled along them -
114
+ * but "not tested" then invites the reader to ask why we didn't, and the honest
115
+ * answer differs per edge: ownership is not a traffic hop at all, a selector is
116
+ * read from the cluster rather than proven by a request, and declared routing
117
+ * can only be exercised by a request that actually goes through that entry.
118
+ * Saying "not tested" for all three trains the reader to expect a test that in
119
+ * two of the cases does not exist.
120
+ */
121
+ export function edgeHelp(label: string, mark: Mark): string {
122
+ if (mark === 'config') {
123
+ switch (label.trim().toLowerCase()) {
124
+ case 'runs':
125
+ return 'ownership, not a traffic hop - the workload owns these Pods'
126
+ case 'selects':
127
+ return 'read from the cluster - which Pods this Service selects'
128
+ case 'routes to':
129
+ case 'sends to':
130
+ return 'declared routing - only a request through this entry exercises it'
131
+ case 'also serves':
132
+ return 'other backends this entry declares, collapsed here'
133
+ }
134
+ }
135
+ // Excluded-by-design, not a gap: this backend serves a different host, so no
136
+ // request for THIS path would ever go down it. "not sent any traffic" read as
137
+ // something we skipped.
138
+ if (mark === 'excluded' && label.trim().toLowerCase() === 'other host') {
139
+ return 'not part of this path - this backend serves a different host'
140
+ }
141
+ return markHelp(mark)
142
+ }
143
+
144
+ /** Whether a line's own words already say what its mark would say. An origin
145
+ * edge is labelled with its evidence ("not tested"), so appending the mark help
146
+ * ("not tested from here") repeated it back at the reader. */
147
+ export function edgeHelpIsRedundant(title: string, label: string, mark: Mark): boolean {
148
+ const t = title.trim().toLowerCase()
149
+ if (!t) return false
150
+ const help = edgeHelp(label, mark).toLowerCase()
151
+ return help.startsWith(t) || t.startsWith(help)
152
+ }
153
+
154
+ /** Inline style for a mark glyph. */
155
+ export function glyphStyle(m: Mark): React.CSSProperties {
156
+ return { color: markStyle(m).color, fontWeight: 700, fontSize: '12px', lineHeight: 1.1, flex: 'none' }
157
+ }
158
+
159
+ /**
160
+ * The selected origin's OWN result for a route, or undefined when that origin
161
+ * produced nothing for it.
162
+ *
163
+ * Origin ids map onto (vantage, path) exactly as originOf() derives them in the
164
+ * other direction: anything relayed by the API server is the apiserver origin
165
+ * whatever machine issued it, and everything else is named by its vantage.
166
+ */
167
+ export function routeForOrigin(route: RouteResult | undefined, originId: string, runVantage?: string): VantageResult | undefined {
168
+ const rows = route?.byVantage
169
+ if (!rows || rows.length === 0) return undefined
170
+ if (originId === 'apiserver') return rows.find((v) => v.path === 'apiserver')
171
+ if (originId === 'local') return rows.find((v) => v.path !== 'apiserver' && v.vantage === 'local')
172
+ // Both in-cluster origins share (in-cluster, data); `source` is what tells
173
+ // Radar's own dial apart from the throwaway Job's. An absent source resolves
174
+ // the same way originOf does, so the rail and the rows can't disagree.
175
+ const wantJob = originId === 'incluster'
176
+ return rows.find((v) => {
177
+ if (v.path === 'apiserver' || v.vantage !== 'in-cluster') return false
178
+ const src = v.source || (runVantage === 'in-cluster' ? 'radar' : 'probe-job')
179
+ return wantJob ? src === 'probe-job' : src === 'radar'
180
+ })
181
+ }
182
+
183
+ /**
184
+ * What we can honestly say about a route FROM one origin. Four cases that must
185
+ * never be collapsed:
186
+ *
187
+ * - `own` this origin's own result for this route. Use it.
188
+ * - `config` the producer says the outcome was DERIVED, not dialled (`basis`):
189
+ * read off what is declared, or off current cluster state. It is
190
+ * true of every origin and observed by none, so it is rendered as
191
+ * the fact it is and never as the selected origin's dial.
192
+ * - `none` the producer DID send per-vantage results and this origin has no
193
+ * row, so it did not test this route. Not "unknown, guess from the
194
+ * rollup" - the absence is itself the evidence, and inheriting the
195
+ * merged verdict here is precisely the misattribution byVantage
196
+ * exists to remove. What the origin did on OTHER routes is
197
+ * irrelevant to this one.
198
+ * - `rollup` no per-vantage results at all (a trace from a producer that
199
+ * predates the field). The merged verdict is all there is; it is
200
+ * NOT a claim about the selected origin, and callers must keep
201
+ * treating it with the coarse pre-existing gate.
202
+ */
203
+ export type OriginEvidence =
204
+ | { kind: 'own'; result: RouteResult }
205
+ | { kind: 'config'; result: RouteResult }
206
+ | { kind: 'rollup'; result: RouteResult }
207
+ | { kind: 'none' }
208
+
209
+ export function originRouteEvidence(route: RouteResult | undefined, originId: string, runVantage?: string): OriginEvidence {
210
+ if (!route) return { kind: 'none' }
211
+ // Checked before the per-vantage rows: a config-derived break has none by
212
+ // construction, and without this it would fall through to the rollup branch
213
+ // and be attributed to whichever origin happened to be selected.
214
+ if (route.basis) return { kind: 'config', result: route }
215
+ const own = routeForOrigin(route, originId, runVantage)
216
+ if (own) {
217
+ return {
218
+ kind: 'own',
219
+ result: { ...route, outcome: own.outcome, confidence: own.confidence, evidence: own.evidence, failedLayer: own.failedLayer },
220
+ }
221
+ }
222
+ const hasBreakdown = !!route.byVantage && route.byVantage.length > 0
223
+ return hasBreakdown ? { kind: 'none' } : { kind: 'rollup', result: route }
224
+ }
225
+
226
+ /** The route as ONE origin saw it, or undefined when that origin has nothing to
227
+ * say about it. Prefer originRouteEvidence when the caller needs to tell a
228
+ * legacy rollup apart from a genuine "not tested from here". */
229
+ export function routeAsSeenFrom(route: RouteResult | undefined, originId: string): RouteResult | undefined {
230
+ const ev = originRouteEvidence(route, originId)
231
+ return ev.kind === 'none' ? undefined : ev.result
232
+ }
233
+
234
+ /**
235
+ * The evidence class of a route's headline result.
236
+ *
237
+ * `confidence` is what separates proof from relay: an 'indirect' outcome came
238
+ * through the apiserver proxy, which bypasses kube-proxy, NetworkPolicy and the
239
+ * mesh - so it can never be `proved` no matter how clean the response was.
240
+ * A benign unreachable (deliberately scaled to zero) is not a failure.
241
+ */
242
+ export function routeMark(r: RouteResult, opts: { stale?: boolean; running?: boolean } = {}): Mark {
243
+ if (opts.running) return 'running'
244
+ if (opts.stale) return 'stale'
245
+ // A derived break was never dialled. 'failed' is the mark for a request that
246
+ // was sent and did not arrive, so using it here claims an observation that
247
+ // never happened - the contradiction the basis field exists to end.
248
+ if (r.basis) return 'config'
249
+ const indirect = r.confidence === 'indirect'
250
+ switch (r.outcome) {
251
+ case 'verified':
252
+ return indirect ? 'proxied' : 'proved'
253
+ case 'reached':
254
+ return indirect ? 'proxied' : 'answered'
255
+ case 'server-error':
256
+ return 'answered'
257
+ case 'unreachable':
258
+ // A proxy-only failure never condemns the real path - it was never tested.
259
+ if (indirect) return 'answered'
260
+ return r.benign ? 'excluded' : 'failed'
261
+ case 'not-tested':
262
+ default:
263
+ return 'untested'
264
+ }
265
+ }
266
+
267
+ /** A route outcome's severity tone, for the scenario tab dot and verdict dot. */
268
+ export type SevTone = 'healthy' | 'degraded' | 'alert' | 'unhealthy' | 'unknown' | 'info'
269
+
270
+ export const SEV_COLOR: Record<SevTone, string> = {
271
+ healthy: 'var(--color-success)',
272
+ degraded: 'var(--color-warning)',
273
+ alert: 'var(--color-alert)',
274
+ unhealthy: 'var(--color-error)',
275
+ unknown: 'var(--text-disabled)',
276
+ info: 'var(--color-info)',
277
+ }
278
+
279
+ /** Badge class for a tone - the repo's canonical `.status-*` vocabulary. */
280
+ export const SEV_BADGE: Record<SevTone, string> = {
281
+ healthy: 'status-healthy',
282
+ degraded: 'status-degraded',
283
+ alert: 'status-alert',
284
+ unhealthy: 'status-unhealthy',
285
+ unknown: 'status-unknown',
286
+ info: 'status-neutral',
287
+ }
288
+
289
+ export function routeTone(r: RouteResult, opts: { stale?: boolean; running?: boolean } = {}): SevTone {
290
+ if (opts.running) return 'info'
291
+ if (opts.stale) return 'unknown'
292
+ const indirect = r.confidence === 'indirect'
293
+ switch (r.outcome) {
294
+ case 'verified':
295
+ return indirect ? 'degraded' : 'healthy'
296
+ case 'reached':
297
+ case 'server-error':
298
+ return 'degraded'
299
+ case 'unreachable':
300
+ // Indirect-only and benign unreachables are never red - see routeMark.
301
+ return indirect || r.benign ? 'degraded' : 'unhealthy'
302
+ case 'not-tested':
303
+ default:
304
+ return 'unknown'
305
+ }
306
+ }
307
+
308
+ /** Short qualifier chip for a scenario tab - what kind of evidence backs it. */
309
+ /** Every row's dial bypassed the front door - rows exist and none exercised
310
+ * the entry path. The chip and headline must then never read as a bare pass. */
311
+ export function routeBackendScoped(r: RouteResult): boolean {
312
+ const rows = r.byVantage ?? []
313
+ return rows.length > 0 && rows.every((v) => v.segment === 'backend')
314
+ }
315
+
316
+ export function routeChip(r: RouteResult, opts: { stale?: boolean; running?: boolean } = {}): string {
317
+ if (opts.running) return 'probing…'
318
+ if (opts.stale) return 'stale'
319
+ // Named for what was read, not for a request: "could not get through" would
320
+ // describe traffic that was never sent.
321
+ if (r.basis === 'declared-config') return 'broken as declared'
322
+ if (r.basis === 'cluster-state') return 'nothing ready to serve'
323
+ const indirect = r.confidence === 'indirect'
324
+ switch (r.outcome) {
325
+ case 'verified':
326
+ if (indirect) return 'got through via the API server'
327
+ // The segment qualifier APPENDS to the outcome word, never replaces it:
328
+ // proof strength stays outcome-derived, so a future transport-only reach
329
+ // reads "answered · backend", not "verified".
330
+ return routeBackendScoped(r) ? 'got through · backend' : 'got through'
331
+ case 'reached':
332
+ if (indirect) return 'answered via the API server'
333
+ return routeBackendScoped(r) ? 'answered · backend' : 'answered, not confirmed'
334
+ case 'server-error':
335
+ // server-error is NEVER an ordinary app 5xx (those classify as reached):
336
+ // it is a TLS verification failure or a gateway's 502/504 - blaming "the
337
+ // app" here named the one party that is not at fault.
338
+ if (r.failedLayer === 'tls') return 'TLS certificate failed'
339
+ if (r.failedLayer === 'upstream') return 'the front door couldn’t reach the backend'
340
+ return 'answered with a server-side fault'
341
+ case 'unreachable':
342
+ // "only the shortcut failed" left "shortcut" undefined on the page.
343
+ if (indirect) return 'couldn’t get through via the API server'
344
+ return r.benign ? 'nothing running (on purpose)' : 'could not get through'
345
+ case 'not-tested':
346
+ default:
347
+ return 'not tested'
348
+ }
349
+ }
350
+
351
+ const OUTCOME_RANK: Record<RouteOutcome, number> = {
352
+ unreachable: 0,
353
+ 'server-error': 1,
354
+ 'not-tested': 2,
355
+ reached: 3,
356
+ verified: 4,
357
+ }
358
+
359
+ /** Worst-first ordering, so the scenario that needs attention leads the strip. */
360
+ export function orderRoutes(routes: RouteResult[]): RouteResult[] {
361
+ return [...routes].sort((a, b) => OUTCOME_RANK[a.outcome] - OUTCOME_RANK[b.outcome])
362
+ }
363
+
364
+ /**
365
+ * A scenario is one testable path as an operator thinks about it. It is NOT
366
+ * always one RouteResult: a Gateway route declaring several hostnames produces
367
+ * one result per host, and when those results agree in every respect they are
368
+ * one situation with several front doors, not several situations.
369
+ */
370
+ export interface Scenario {
371
+ key: string
372
+ /** Tab title. */
373
+ label: string
374
+ /** Secondary line - the backend, plus the host count when grouped. */
375
+ sub: string
376
+ /** Every route folded into this scenario. */
377
+ routes: RouteResult[]
378
+ /** Representative used for tone, chip and the graph. */
379
+ primary: RouteResult
380
+ /** The distinct entry hostnames, when this scenario groups several. */
381
+ hosts: string[]
382
+ /** Every route label folded into this tab, for the hover. Not all of them are
383
+ * hostnames - a Service port and a Pod both appear here as themselves. */
384
+ members: string[]
385
+ /** Name of the front door serving this scenario, when one is declared. */
386
+ entry?: string
387
+ }
388
+
389
+ /** The host part of a route label like "example.com/path". */
390
+ function hostOf(route: string): string {
391
+ const slash = route.indexOf('/')
392
+ return slash === -1 ? route : route.slice(0, slash)
393
+ }
394
+
395
+ /**
396
+ * The request host a route label names, or "" when it names none.
397
+ *
398
+ * Route labels are NOT all hostnames: the producer emits "host+path" for
399
+ * host-based rules but ":80 → 8080" for port-based ones and "default backend"
400
+ * where no host is declared (internal/trace routeLabel). Feeding those to a
401
+ * host matcher is how a Gateway listener with no hostname - which legitimately
402
+ * serves ANY host - ends up claiming a port-based route that never went near it.
403
+ */
404
+ export function routeHostOf(route: string): string {
405
+ const candidate = hostOf(route).trim().toLowerCase()
406
+ // A dot is required. Without it "GET / · :80 → 8080" yields the bare word
407
+ // "get", which reads as a valid single-label host and gets attributed to a
408
+ // catch-all listener. A genuinely single-label host loses its front-door tag,
409
+ // which is the safe direction to fail: no attribution beats a wrong one.
410
+ return /^[a-z0-9*][a-z0-9*-]*(\.[a-z0-9*-]+)+$/.test(candidate) ? candidate : ''
411
+ }
412
+
413
+ /**
414
+ * Whether a declared hostname serves a request host. Gateway API and Ingress
415
+ * both allow a leading `*.` wildcard, which matches exactly one extra label -
416
+ * `*.example.com` covers `api.example.com` but NOT `a.b.example.com` or the
417
+ * apex. An EMPTY declared hostname is the Gateway-API "any host" listener.
418
+ */
419
+ export function hostMatches(declared: string, host: string): boolean {
420
+ const d = declared.trim().toLowerCase()
421
+ const h = host.trim().toLowerCase()
422
+ if (!h) return false
423
+ if (d === '') return true
424
+ if (!d.startsWith('*.')) return d === h
425
+ const suffix = d.slice(1) // ".example.com"
426
+ if (!h.endsWith(suffix)) return false
427
+ return !h.slice(0, h.length - suffix.length).includes('.')
428
+ }
429
+
430
+ /**
431
+ * Every hostname a hop declares. Ingress/HTTPRoute/GRPCRoute publish them on
432
+ * `hostnames` (+ `tlsHosts`); a GATEWAY publishes them per listener and has no
433
+ * top-level `hostnames` at all - reading only the former made every real Gateway
434
+ * look like it served nothing.
435
+ */
436
+ export function declaredHosts(h: Hop): string[] {
437
+ const listeners = (h.config?.listeners ?? []).map((l) => l.hostname ?? '')
438
+ return [...(h.config?.hostnames ?? []), ...(h.config?.tlsHosts ?? []), ...listeners]
439
+ }
440
+
441
+ /**
442
+ * Which declared entry point serves a hostname. Two hosts that land on the same
443
+ * backend with the same outcome are still different situations when they come in
444
+ * through DIFFERENT front doors: one Gateway can be misconfigured while its
445
+ * sibling is fine, and merging them would hide that behind a shared verdict.
446
+ *
447
+ * When several entries could serve the host, the most SPECIFIC declaration wins
448
+ * (exact over wildcard over catch-all), mirroring how the proxies themselves
449
+ * resolve it - otherwise a catch-all listener would swallow every host and
450
+ * collapse the scenarios it exists to separate.
451
+ */
452
+ export function entryForHost(host: string, upstreams: Hop[] = []): string {
453
+ const h = host.trim().toLowerCase()
454
+ if (!h) return ''
455
+ const specificity = (d: string): number => (d === '' ? 0 : d.startsWith('*.') ? 1 : 2)
456
+ let best: { hop: Hop; rank: number } | undefined
457
+ for (const u of upstreams) {
458
+ for (const d of declaredHosts(u)) {
459
+ if (!hostMatches(d, h)) continue
460
+ const rank = specificity(d.trim().toLowerCase())
461
+ if (!best || rank > best.rank) best = { hop: u, rank }
462
+ }
463
+ }
464
+ const r = best?.hop.resource
465
+ return r ? `${r.kind}/${r.namespace ?? ''}/${r.name}` : ''
466
+ }
467
+
468
+ /**
469
+ * Groups routes that are indistinguishable in outcome. Splitting them out again
470
+ * happens exactly when it carries information - a differing outcome, layer,
471
+ * confidence or serving entry point - so identical hosts collapse but a host
472
+ * that behaves differently, or arrives through a different front door, always
473
+ * keeps its own tab.
474
+ */
475
+ /**
476
+ * A route's stable identity: what it IS, never what happened to it.
477
+ *
478
+ * Composed exactly like the producer's InClusterResultKey (coverage.go), so the
479
+ * two cannot drift. Selection anchors on this rather than on a scenario's group
480
+ * key, which mixes in outcome, confidence and evidence - so a re-run that
481
+ * changed a result changed the key and silently moved the user to a different
482
+ * path.
483
+ */
484
+ /** Whether the in-cluster test has ANYTHING to run: the server only tests a
485
+ * route carrying a concrete InClusterRequest. Offering the control without
486
+ * one spends a click (and a consent dialog) on a guaranteed no-op. */
487
+ export function traceInClusterRunnable(trace: Trace): boolean {
488
+ return (trace.routes ?? []).some((r) => !!r.inClusterRequest && !r.benign)
489
+ }
490
+
491
+ export function routeIdentity(r: RouteResult): string {
492
+ return `${r.route}\u0000${r.target ?? ''}\u0000${r.targetNamespace ?? ''}`
493
+ }
494
+
495
+ /** A route's per-vantage outcomes, order-independent so two routes observed in a
496
+ * different sequence still compare equal. */
497
+ export function vantageSignature(r: RouteResult): string {
498
+ return (r.byVantage ?? [])
499
+ .map(
500
+ (v) =>
501
+ `${v.vantage}/${v.path}/${v.source || 'radar'}=${v.outcome}${v.failedBoundary ? `@${v.failedBoundary}` : ''}${v.segment ? `#${v.segment}` : ''}`,
502
+ )
503
+ .sort()
504
+ .join(',')
505
+ }
506
+
507
+ export function groupRoutes(routes: RouteResult[], upstreams: Hop[] = []): Scenario[] {
508
+ const groups = new Map<string, RouteResult[]>()
509
+ for (const r of orderRoutes(routes)) {
510
+ // For an untested route the REASON is the whole content - "port 443 can't
511
+ // be verified through the proxy" and "port 80 timed out" are different
512
+ // situations even though both are merely not-tested. Folding them together
513
+ // would hide the distinction the operator needs.
514
+ // The evidence distinguishes routes at EVERY outcome, not only not-tested:
515
+ // "connection refused" and "timed out" are the same outcome and layer but
516
+ // different situations, and folding them shows the operator one and hides
517
+ // the other behind a tab that claims to speak for both.
518
+ const distinguishing = r.evidence ?? ''
519
+ const entry = entryForHost(routeHostOf(r.route), upstreams)
520
+ const key = [
521
+ r.target ?? '',
522
+ // Same-named Services in different namespaces are different backends.
523
+ r.targetNamespace ?? '',
524
+ r.outcome,
525
+ r.confidence ?? '',
526
+ r.failedLayer ?? '',
527
+ r.benign ? 'benign' : '',
528
+ r.basis ?? '',
529
+ // Two routes whose rollups agree can still disagree per vantage - one
530
+ // working from a laptop and one not. Collapsing them puts rs[0] in charge
531
+ // of the whole board and destroys exactly the distinction byVantage was
532
+ // added to preserve.
533
+ vantageSignature(r),
534
+ distinguishing,
535
+ entry,
536
+ ].join('|')
537
+ const arr = groups.get(key) ?? []
538
+ arr.push(r)
539
+ groups.set(key, arr)
540
+ }
541
+ return [...groups.entries()].map(([key, rs]) => {
542
+ const primary = rs[0]
543
+ // Only genuine hostnames. hostOf returns the whole label when there is no
544
+ // path separator, so port- and pod-shaped route labels ("port 80",
545
+ // "web-abc123 port 8080") were being counted and announced as "hostnames".
546
+ const hosts = [...new Set(rs.map((r) => routeHostOf(r.route)).filter(Boolean))]
547
+ const members = [...new Set(rs.map((r) => r.route).filter(Boolean))]
548
+ const grouped = rs.length > 1
549
+ const entryId = entryForHost(routeHostOf(rs[0].route), upstreams)
550
+ const entry = entryId ? entryId.split('/').pop() : undefined
551
+ const via = entry ? ` · via ${entry}` : ''
552
+ return {
553
+ key,
554
+ label: grouped ? primary.target || `${rs.length} routes` : primary.route,
555
+ sub: grouped
556
+ ? // Named for what the members ACTUALLY are. Routes folded together can
557
+ // share a hostname and differ only by path, and many carry no hostname
558
+ // at all - so "N hostnames" both undercounted and mis-described them.
559
+ `${hosts.length === rs.length ? `${hosts.length} hostname${hosts.length === 1 ? '' : 's'}` : `${rs.length} paths`}${primary.target ? ` · ${primary.target}` : ''}${via}`
560
+ : `${primary.target || ''}${via}`,
561
+ routes: rs,
562
+ primary,
563
+ hosts,
564
+ members,
565
+ entry,
566
+ }
567
+ })
568
+ }
569
+
570
+ /**
571
+ * Routes the tracer declined to test still describe real paths, so they become
572
+ * scenarios too. Without this an untested resource renders no strip at all and
573
+ * the paths it has are invisible.
574
+ */
575
+ export function scenariosFor(
576
+ routes: RouteResult[],
577
+ notTested: { route?: string; reason: string }[],
578
+ upstreams: Hop[] = [],
579
+ ): Scenario[] {
580
+ // A not-tested ROUTE and the raw skip rows for its host describe the SAME
581
+ // gap (the producer preserves the declared candidate as a route so the
582
+ // in-cluster recovery has a target). Mirroring recountCoverage's host-level
583
+ // absorption: rows whose host a route already covers must not become a
584
+ // second scenario for the same gap.
585
+ const covered = new Set(
586
+ routes.flatMap((r) => [routeHostOf(r.route), hostOfTarget(r.target)]).filter(Boolean),
587
+ )
588
+ const synthesized: RouteResult[] = notTested
589
+ .filter((s) => !!s.route)
590
+ .filter((s) => {
591
+ const h = routeHostOf(s.route as string) || hostOfTarget(s.route)
592
+ return !h || !covered.has(h)
593
+ })
594
+ .map((s) => ({ route: s.route as string, outcome: 'not-tested' as RouteOutcome, evidence: s.reason }))
595
+ return groupRoutes([...routes, ...synthesized], upstreams)
596
+ }
597
+
598
+ /** The bare host of a probe-target-shaped string ("api:80" → "api"). */
599
+ function hostOfTarget(t?: string): string {
600
+ const s = (t ?? '').trim()
601
+ if (!s) return ''
602
+ const i = s.lastIndexOf(':')
603
+ return (i > 0 ? s.slice(0, i) : s).toLowerCase()
604
+ }
605
+
606
+ /**
607
+ * Latency far outside the band is its own signal - a route that answers in
608
+ * seconds is not simply "healthy". Threshold is deliberately generous: this
609
+ * flags pathology, not ordinary variance.
610
+ */
611
+ export const SLOW_THRESHOLD_NS = 1_000_000_000
612
+
613
+ export function isSlow(p: ProbeResult): boolean {
614
+ return typeof p.latencyNs === 'number' && p.latencyNs >= SLOW_THRESHOLD_NS
615
+ }
616
+
617
+ export function formatLatency(ns?: number): string {
618
+ if (typeof ns !== 'number' || ns <= 0) return ''
619
+ const ms = ns / 1_000_000
620
+ if (ms < 1) return '<1 ms'
621
+ if (ms < 1000) return `${Math.round(ms)} ms`
622
+ return `${(ms / 1000).toFixed(1)} s`
623
+ }