@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,579 @@
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
+ /** Inline style for a mark glyph. */
111
+ export function glyphStyle(m: Mark): React.CSSProperties {
112
+ return { color: markStyle(m).color, fontWeight: 700, fontSize: '12px', lineHeight: 1.1, flex: 'none' }
113
+ }
114
+
115
+ /**
116
+ * The selected origin's OWN result for a route, or undefined when that origin
117
+ * produced nothing for it.
118
+ *
119
+ * Origin ids map onto (vantage, path) exactly as originOf() derives them in the
120
+ * other direction: anything relayed by the API server is the apiserver origin
121
+ * whatever machine issued it, and everything else is named by its vantage.
122
+ */
123
+ export function routeForOrigin(route: RouteResult | undefined, originId: string, runVantage?: string): VantageResult | undefined {
124
+ const rows = route?.byVantage
125
+ if (!rows || rows.length === 0) return undefined
126
+ if (originId === 'apiserver') return rows.find((v) => v.path === 'apiserver')
127
+ if (originId === 'local') return rows.find((v) => v.path !== 'apiserver' && v.vantage === 'local')
128
+ // Both in-cluster origins share (in-cluster, data); `source` is what tells
129
+ // Radar's own dial apart from the throwaway Job's. An absent source resolves
130
+ // the same way originOf does, so the rail and the rows can't disagree.
131
+ const wantJob = originId === 'incluster'
132
+ return rows.find((v) => {
133
+ if (v.path === 'apiserver' || v.vantage !== 'in-cluster') return false
134
+ const src = v.source || (runVantage === 'in-cluster' ? 'radar' : 'probe-job')
135
+ return wantJob ? src === 'probe-job' : src === 'radar'
136
+ })
137
+ }
138
+
139
+ /**
140
+ * What we can honestly say about a route FROM one origin. Four cases that must
141
+ * never be collapsed:
142
+ *
143
+ * - `own` this origin's own result for this route. Use it.
144
+ * - `config` the producer says the outcome was DERIVED, not dialled (`basis`):
145
+ * read off what is declared, or off current cluster state. It is
146
+ * true of every origin and observed by none, so it is rendered as
147
+ * the fact it is and never as the selected origin's dial.
148
+ * - `none` the producer DID send per-vantage results and this origin has no
149
+ * row, so it did not test this route. Not "unknown, guess from the
150
+ * rollup" - the absence is itself the evidence, and inheriting the
151
+ * merged verdict here is precisely the misattribution byVantage
152
+ * exists to remove. What the origin did on OTHER routes is
153
+ * irrelevant to this one.
154
+ * - `rollup` no per-vantage results at all (a trace from a producer that
155
+ * predates the field). The merged verdict is all there is; it is
156
+ * NOT a claim about the selected origin, and callers must keep
157
+ * treating it with the coarse pre-existing gate.
158
+ */
159
+ export type OriginEvidence =
160
+ | { kind: 'own'; result: RouteResult }
161
+ | { kind: 'config'; result: RouteResult }
162
+ | { kind: 'rollup'; result: RouteResult }
163
+ | { kind: 'none' }
164
+
165
+ export function originRouteEvidence(route: RouteResult | undefined, originId: string, runVantage?: string): OriginEvidence {
166
+ if (!route) return { kind: 'none' }
167
+ // Checked before the per-vantage rows: a config-derived break has none by
168
+ // construction, and without this it would fall through to the rollup branch
169
+ // and be attributed to whichever origin happened to be selected.
170
+ if (route.basis) return { kind: 'config', result: route }
171
+ const own = routeForOrigin(route, originId, runVantage)
172
+ if (own) {
173
+ return {
174
+ kind: 'own',
175
+ result: { ...route, outcome: own.outcome, confidence: own.confidence, evidence: own.evidence, failedLayer: own.failedLayer },
176
+ }
177
+ }
178
+ const hasBreakdown = !!route.byVantage && route.byVantage.length > 0
179
+ return hasBreakdown ? { kind: 'none' } : { kind: 'rollup', result: route }
180
+ }
181
+
182
+ /** The route as ONE origin saw it, or undefined when that origin has nothing to
183
+ * say about it. Prefer originRouteEvidence when the caller needs to tell a
184
+ * legacy rollup apart from a genuine "not tested from here". */
185
+ export function routeAsSeenFrom(route: RouteResult | undefined, originId: string): RouteResult | undefined {
186
+ const ev = originRouteEvidence(route, originId)
187
+ return ev.kind === 'none' ? undefined : ev.result
188
+ }
189
+
190
+ /**
191
+ * The evidence class of a route's headline result.
192
+ *
193
+ * `confidence` is what separates proof from relay: an 'indirect' outcome came
194
+ * through the apiserver proxy, which bypasses kube-proxy, NetworkPolicy and the
195
+ * mesh - so it can never be `proved` no matter how clean the response was.
196
+ * A benign unreachable (deliberately scaled to zero) is not a failure.
197
+ */
198
+ export function routeMark(r: RouteResult, opts: { stale?: boolean; running?: boolean } = {}): Mark {
199
+ if (opts.running) return 'running'
200
+ if (opts.stale) return 'stale'
201
+ // A derived break was never dialled. 'failed' is the mark for a request that
202
+ // was sent and did not arrive, so using it here claims an observation that
203
+ // never happened - the contradiction the basis field exists to end.
204
+ if (r.basis) return 'config'
205
+ const indirect = r.confidence === 'indirect'
206
+ switch (r.outcome) {
207
+ case 'verified':
208
+ return indirect ? 'proxied' : 'proved'
209
+ case 'reached':
210
+ return indirect ? 'proxied' : 'answered'
211
+ case 'server-error':
212
+ return 'answered'
213
+ case 'unreachable':
214
+ // A proxy-only failure never condemns the real path - it was never tested.
215
+ if (indirect) return 'answered'
216
+ return r.benign ? 'excluded' : 'failed'
217
+ case 'not-tested':
218
+ default:
219
+ return 'untested'
220
+ }
221
+ }
222
+
223
+ /** A route outcome's severity tone, for the scenario tab dot and verdict dot. */
224
+ export type SevTone = 'healthy' | 'degraded' | 'alert' | 'unhealthy' | 'unknown' | 'info'
225
+
226
+ export const SEV_COLOR: Record<SevTone, string> = {
227
+ healthy: 'var(--color-success)',
228
+ degraded: 'var(--color-warning)',
229
+ alert: 'var(--color-alert)',
230
+ unhealthy: 'var(--color-error)',
231
+ unknown: 'var(--text-disabled)',
232
+ info: 'var(--color-info)',
233
+ }
234
+
235
+ /** Badge class for a tone - the repo's canonical `.status-*` vocabulary. */
236
+ export const SEV_BADGE: Record<SevTone, string> = {
237
+ healthy: 'status-healthy',
238
+ degraded: 'status-degraded',
239
+ alert: 'status-alert',
240
+ unhealthy: 'status-unhealthy',
241
+ unknown: 'status-unknown',
242
+ info: 'status-neutral',
243
+ }
244
+
245
+ export function routeTone(r: RouteResult, opts: { stale?: boolean; running?: boolean } = {}): SevTone {
246
+ if (opts.running) return 'info'
247
+ if (opts.stale) return 'unknown'
248
+ const indirect = r.confidence === 'indirect'
249
+ switch (r.outcome) {
250
+ case 'verified':
251
+ return indirect ? 'degraded' : 'healthy'
252
+ case 'reached':
253
+ case 'server-error':
254
+ return 'degraded'
255
+ case 'unreachable':
256
+ // Indirect-only and benign unreachables are never red - see routeMark.
257
+ return indirect || r.benign ? 'degraded' : 'unhealthy'
258
+ case 'not-tested':
259
+ default:
260
+ return 'unknown'
261
+ }
262
+ }
263
+
264
+ /** Short qualifier chip for a scenario tab - what kind of evidence backs it. */
265
+ /** Every row's dial bypassed the front door - rows exist and none exercised
266
+ * the entry path. The chip and headline must then never read as a bare pass. */
267
+ export function routeBackendScoped(r: RouteResult): boolean {
268
+ const rows = r.byVantage ?? []
269
+ return rows.length > 0 && rows.every((v) => v.segment === 'backend')
270
+ }
271
+
272
+ export function routeChip(r: RouteResult, opts: { stale?: boolean; running?: boolean } = {}): string {
273
+ if (opts.running) return 'probing…'
274
+ if (opts.stale) return 'stale'
275
+ // Named for what was read, not for a request: "could not get through" would
276
+ // describe traffic that was never sent.
277
+ if (r.basis === 'declared-config') return 'broken as declared'
278
+ if (r.basis === 'cluster-state') return 'nothing ready to serve'
279
+ const indirect = r.confidence === 'indirect'
280
+ switch (r.outcome) {
281
+ case 'verified':
282
+ if (indirect) return 'got through via the API server'
283
+ // The segment qualifier APPENDS to the outcome word, never replaces it:
284
+ // proof strength stays outcome-derived, so a future transport-only reach
285
+ // reads "answered · backend", not "verified".
286
+ return routeBackendScoped(r) ? 'got through · backend' : 'got through'
287
+ case 'reached':
288
+ if (indirect) return 'answered via the API server'
289
+ return routeBackendScoped(r) ? 'answered · backend' : 'answered, not confirmed'
290
+ case 'server-error':
291
+ // server-error is NEVER an ordinary app 5xx (those classify as reached):
292
+ // it is a TLS verification failure or a gateway's 502/504 - blaming "the
293
+ // app" here named the one party that is not at fault.
294
+ if (r.failedLayer === 'tls') return 'TLS certificate failed'
295
+ if (r.failedLayer === 'upstream') return 'the front door couldn’t reach the backend'
296
+ return 'answered with a server-side fault'
297
+ case 'unreachable':
298
+ // "only the shortcut failed" left "shortcut" undefined on the page.
299
+ if (indirect) return 'couldn’t get through via the API server'
300
+ return r.benign ? 'nothing running (on purpose)' : 'could not get through'
301
+ case 'not-tested':
302
+ default:
303
+ return 'not tested'
304
+ }
305
+ }
306
+
307
+ const OUTCOME_RANK: Record<RouteOutcome, number> = {
308
+ unreachable: 0,
309
+ 'server-error': 1,
310
+ 'not-tested': 2,
311
+ reached: 3,
312
+ verified: 4,
313
+ }
314
+
315
+ /** Worst-first ordering, so the scenario that needs attention leads the strip. */
316
+ export function orderRoutes(routes: RouteResult[]): RouteResult[] {
317
+ return [...routes].sort((a, b) => OUTCOME_RANK[a.outcome] - OUTCOME_RANK[b.outcome])
318
+ }
319
+
320
+ /**
321
+ * A scenario is one testable path as an operator thinks about it. It is NOT
322
+ * always one RouteResult: a Gateway route declaring several hostnames produces
323
+ * one result per host, and when those results agree in every respect they are
324
+ * one situation with several front doors, not several situations.
325
+ */
326
+ export interface Scenario {
327
+ key: string
328
+ /** Tab title. */
329
+ label: string
330
+ /** Secondary line - the backend, plus the host count when grouped. */
331
+ sub: string
332
+ /** Every route folded into this scenario. */
333
+ routes: RouteResult[]
334
+ /** Representative used for tone, chip and the graph. */
335
+ primary: RouteResult
336
+ /** The distinct entry hostnames, when this scenario groups several. */
337
+ hosts: string[]
338
+ /** Every route label folded into this tab, for the hover. Not all of them are
339
+ * hostnames - a Service port and a Pod both appear here as themselves. */
340
+ members: string[]
341
+ /** Name of the front door serving this scenario, when one is declared. */
342
+ entry?: string
343
+ }
344
+
345
+ /** The host part of a route label like "example.com/path". */
346
+ function hostOf(route: string): string {
347
+ const slash = route.indexOf('/')
348
+ return slash === -1 ? route : route.slice(0, slash)
349
+ }
350
+
351
+ /**
352
+ * The request host a route label names, or "" when it names none.
353
+ *
354
+ * Route labels are NOT all hostnames: the producer emits "host+path" for
355
+ * host-based rules but ":80 → 8080" for port-based ones and "default backend"
356
+ * where no host is declared (internal/trace routeLabel). Feeding those to a
357
+ * host matcher is how a Gateway listener with no hostname - which legitimately
358
+ * serves ANY host - ends up claiming a port-based route that never went near it.
359
+ */
360
+ export function routeHostOf(route: string): string {
361
+ const candidate = hostOf(route).trim().toLowerCase()
362
+ // A dot is required. Without it "GET / · :80 → 8080" yields the bare word
363
+ // "get", which reads as a valid single-label host and gets attributed to a
364
+ // catch-all listener. A genuinely single-label host loses its front-door tag,
365
+ // which is the safe direction to fail: no attribution beats a wrong one.
366
+ return /^[a-z0-9*][a-z0-9*-]*(\.[a-z0-9*-]+)+$/.test(candidate) ? candidate : ''
367
+ }
368
+
369
+ /**
370
+ * Whether a declared hostname serves a request host. Gateway API and Ingress
371
+ * both allow a leading `*.` wildcard, which matches exactly one extra label -
372
+ * `*.example.com` covers `api.example.com` but NOT `a.b.example.com` or the
373
+ * apex. An EMPTY declared hostname is the Gateway-API "any host" listener.
374
+ */
375
+ export function hostMatches(declared: string, host: string): boolean {
376
+ const d = declared.trim().toLowerCase()
377
+ const h = host.trim().toLowerCase()
378
+ if (!h) return false
379
+ if (d === '') return true
380
+ if (!d.startsWith('*.')) return d === h
381
+ const suffix = d.slice(1) // ".example.com"
382
+ if (!h.endsWith(suffix)) return false
383
+ return !h.slice(0, h.length - suffix.length).includes('.')
384
+ }
385
+
386
+ /**
387
+ * Every hostname a hop declares. Ingress/HTTPRoute/GRPCRoute publish them on
388
+ * `hostnames` (+ `tlsHosts`); a GATEWAY publishes them per listener and has no
389
+ * top-level `hostnames` at all - reading only the former made every real Gateway
390
+ * look like it served nothing.
391
+ */
392
+ export function declaredHosts(h: Hop): string[] {
393
+ const listeners = (h.config?.listeners ?? []).map((l) => l.hostname ?? '')
394
+ return [...(h.config?.hostnames ?? []), ...(h.config?.tlsHosts ?? []), ...listeners]
395
+ }
396
+
397
+ /**
398
+ * Which declared entry point serves a hostname. Two hosts that land on the same
399
+ * backend with the same outcome are still different situations when they come in
400
+ * through DIFFERENT front doors: one Gateway can be misconfigured while its
401
+ * sibling is fine, and merging them would hide that behind a shared verdict.
402
+ *
403
+ * When several entries could serve the host, the most SPECIFIC declaration wins
404
+ * (exact over wildcard over catch-all), mirroring how the proxies themselves
405
+ * resolve it - otherwise a catch-all listener would swallow every host and
406
+ * collapse the scenarios it exists to separate.
407
+ */
408
+ export function entryForHost(host: string, upstreams: Hop[] = []): string {
409
+ const h = host.trim().toLowerCase()
410
+ if (!h) return ''
411
+ const specificity = (d: string): number => (d === '' ? 0 : d.startsWith('*.') ? 1 : 2)
412
+ let best: { hop: Hop; rank: number } | undefined
413
+ for (const u of upstreams) {
414
+ for (const d of declaredHosts(u)) {
415
+ if (!hostMatches(d, h)) continue
416
+ const rank = specificity(d.trim().toLowerCase())
417
+ if (!best || rank > best.rank) best = { hop: u, rank }
418
+ }
419
+ }
420
+ const r = best?.hop.resource
421
+ return r ? `${r.kind}/${r.namespace ?? ''}/${r.name}` : ''
422
+ }
423
+
424
+ /**
425
+ * Groups routes that are indistinguishable in outcome. Splitting them out again
426
+ * happens exactly when it carries information - a differing outcome, layer,
427
+ * confidence or serving entry point - so identical hosts collapse but a host
428
+ * that behaves differently, or arrives through a different front door, always
429
+ * keeps its own tab.
430
+ */
431
+ /**
432
+ * A route's stable identity: what it IS, never what happened to it.
433
+ *
434
+ * Composed exactly like the producer's InClusterResultKey (coverage.go), so the
435
+ * two cannot drift. Selection anchors on this rather than on a scenario's group
436
+ * key, which mixes in outcome, confidence and evidence - so a re-run that
437
+ * changed a result changed the key and silently moved the user to a different
438
+ * path.
439
+ */
440
+ /** Whether the in-cluster test has ANYTHING to run: the server only tests a
441
+ * route carrying a concrete InClusterRequest. Offering the control without
442
+ * one spends a click (and a consent dialog) on a guaranteed no-op. */
443
+ export function traceInClusterRunnable(trace: Trace): boolean {
444
+ return (trace.routes ?? []).some((r) => !!r.inClusterRequest && !r.benign)
445
+ }
446
+
447
+ export function routeIdentity(r: RouteResult): string {
448
+ return `${r.route}\u0000${r.target ?? ''}\u0000${r.targetNamespace ?? ''}`
449
+ }
450
+
451
+ /** A route's per-vantage outcomes, order-independent so two routes observed in a
452
+ * different sequence still compare equal. */
453
+ export function vantageSignature(r: RouteResult): string {
454
+ return (r.byVantage ?? [])
455
+ .map(
456
+ (v) =>
457
+ `${v.vantage}/${v.path}/${v.source || 'radar'}=${v.outcome}${v.failedBoundary ? `@${v.failedBoundary}` : ''}${v.segment ? `#${v.segment}` : ''}`,
458
+ )
459
+ .sort()
460
+ .join(',')
461
+ }
462
+
463
+ export function groupRoutes(routes: RouteResult[], upstreams: Hop[] = []): Scenario[] {
464
+ const groups = new Map<string, RouteResult[]>()
465
+ for (const r of orderRoutes(routes)) {
466
+ // For an untested route the REASON is the whole content - "port 443 can't
467
+ // be verified through the proxy" and "port 80 timed out" are different
468
+ // situations even though both are merely not-tested. Folding them together
469
+ // would hide the distinction the operator needs.
470
+ // The evidence distinguishes routes at EVERY outcome, not only not-tested:
471
+ // "connection refused" and "timed out" are the same outcome and layer but
472
+ // different situations, and folding them shows the operator one and hides
473
+ // the other behind a tab that claims to speak for both.
474
+ const distinguishing = r.evidence ?? ''
475
+ const entry = entryForHost(routeHostOf(r.route), upstreams)
476
+ const key = [
477
+ r.target ?? '',
478
+ // Same-named Services in different namespaces are different backends.
479
+ r.targetNamespace ?? '',
480
+ r.outcome,
481
+ r.confidence ?? '',
482
+ r.failedLayer ?? '',
483
+ r.benign ? 'benign' : '',
484
+ r.basis ?? '',
485
+ // Two routes whose rollups agree can still disagree per vantage - one
486
+ // working from a laptop and one not. Collapsing them puts rs[0] in charge
487
+ // of the whole board and destroys exactly the distinction byVantage was
488
+ // added to preserve.
489
+ vantageSignature(r),
490
+ distinguishing,
491
+ entry,
492
+ ].join('|')
493
+ const arr = groups.get(key) ?? []
494
+ arr.push(r)
495
+ groups.set(key, arr)
496
+ }
497
+ return [...groups.entries()].map(([key, rs]) => {
498
+ const primary = rs[0]
499
+ // Only genuine hostnames. hostOf returns the whole label when there is no
500
+ // path separator, so port- and pod-shaped route labels ("port 80",
501
+ // "web-abc123 port 8080") were being counted and announced as "hostnames".
502
+ const hosts = [...new Set(rs.map((r) => routeHostOf(r.route)).filter(Boolean))]
503
+ const members = [...new Set(rs.map((r) => r.route).filter(Boolean))]
504
+ const grouped = rs.length > 1
505
+ const entryId = entryForHost(routeHostOf(rs[0].route), upstreams)
506
+ const entry = entryId ? entryId.split('/').pop() : undefined
507
+ const via = entry ? ` · via ${entry}` : ''
508
+ return {
509
+ key,
510
+ label: grouped ? primary.target || `${rs.length} routes` : primary.route,
511
+ sub: grouped
512
+ ? // Named for what the members ACTUALLY are. Routes folded together can
513
+ // share a hostname and differ only by path, and many carry no hostname
514
+ // at all - so "N hostnames" both undercounted and mis-described them.
515
+ `${hosts.length === rs.length ? `${hosts.length} hostname${hosts.length === 1 ? '' : 's'}` : `${rs.length} paths`}${primary.target ? ` · ${primary.target}` : ''}${via}`
516
+ : `${primary.target || ''}${via}`,
517
+ routes: rs,
518
+ primary,
519
+ hosts,
520
+ members,
521
+ entry,
522
+ }
523
+ })
524
+ }
525
+
526
+ /**
527
+ * Routes the tracer declined to test still describe real paths, so they become
528
+ * scenarios too. Without this an untested resource renders no strip at all and
529
+ * the paths it has are invisible.
530
+ */
531
+ export function scenariosFor(
532
+ routes: RouteResult[],
533
+ notTested: { route?: string; reason: string }[],
534
+ upstreams: Hop[] = [],
535
+ ): Scenario[] {
536
+ // A not-tested ROUTE and the raw skip rows for its host describe the SAME
537
+ // gap (the producer preserves the declared candidate as a route so the
538
+ // in-cluster recovery has a target). Mirroring recountCoverage's host-level
539
+ // absorption: rows whose host a route already covers must not become a
540
+ // second scenario for the same gap.
541
+ const covered = new Set(
542
+ routes.flatMap((r) => [routeHostOf(r.route), hostOfTarget(r.target)]).filter(Boolean),
543
+ )
544
+ const synthesized: RouteResult[] = notTested
545
+ .filter((s) => !!s.route)
546
+ .filter((s) => {
547
+ const h = routeHostOf(s.route as string) || hostOfTarget(s.route)
548
+ return !h || !covered.has(h)
549
+ })
550
+ .map((s) => ({ route: s.route as string, outcome: 'not-tested' as RouteOutcome, evidence: s.reason }))
551
+ return groupRoutes([...routes, ...synthesized], upstreams)
552
+ }
553
+
554
+ /** The bare host of a probe-target-shaped string ("api:80" → "api"). */
555
+ function hostOfTarget(t?: string): string {
556
+ const s = (t ?? '').trim()
557
+ if (!s) return ''
558
+ const i = s.lastIndexOf(':')
559
+ return (i > 0 ? s.slice(0, i) : s).toLowerCase()
560
+ }
561
+
562
+ /**
563
+ * Latency far outside the band is its own signal - a route that answers in
564
+ * seconds is not simply "healthy". Threshold is deliberately generous: this
565
+ * flags pathology, not ordinary variance.
566
+ */
567
+ export const SLOW_THRESHOLD_NS = 1_000_000_000
568
+
569
+ export function isSlow(p: ProbeResult): boolean {
570
+ return typeof p.latencyNs === 'number' && p.latencyNs >= SLOW_THRESHOLD_NS
571
+ }
572
+
573
+ export function formatLatency(ns?: number): string {
574
+ if (typeof ns !== 'number' || ns <= 0) return ''
575
+ const ms = ns / 1_000_000
576
+ if (ms < 1) return '<1 ms'
577
+ if (ms < 1000) return `${Math.round(ms)} ms`
578
+ return `${(ms / 1000).toFixed(1)} s`
579
+ }