@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,449 @@
1
+ import type { Trace, ProbeResult, RouteResult } from './types'
2
+ import type { Mark } from './reachMarks'
3
+ import { originRouteEvidence, routeMark } from './reachMarks'
4
+
5
+ /**
6
+ * An Origin is a vantage point - WHERE a test ran from, and as WHOM. It is a
7
+ * first-class entity in the graph, never a Kubernetes resource, because two
8
+ * results about the same Service can mean completely different things depending
9
+ * on who sent the request and by what mechanism.
10
+ *
11
+ * Radar has three real origins today. `caller` and `external` are declared here
12
+ * and rendered as unavailable rather than omitted: an operator reading this tab
13
+ * needs to know that the strongest evidence - the real application caller, and a
14
+ * genuine request from outside the cluster - is absent, not merely unrun.
15
+ * Hiding them would make synthetic evidence look complete.
16
+ */
17
+ export type OriginId = 'incluster' | 'radar-incluster' | 'apiserver' | 'local' | 'caller' | 'external'
18
+
19
+ /** Which lane an origin's traffic belongs to. */
20
+ export type Lane = 'dataplane' | 'control'
21
+
22
+ /** What the origin's identity means for the result's strength. */
23
+ export type OriginKind = 'synthetic' | 'radar' | 'real-client' | 'real-caller' | 'relayed'
24
+
25
+ export interface Origin {
26
+ id: OriginId
27
+ glyph: string
28
+ name: string
29
+ /** How the request is actually sent. */
30
+ mech: string
31
+ /** Namespace / ServiceAccount / mesh participation, as far as we know it. */
32
+ identity: string
33
+ kind: OriginKind
34
+ kindTag: string
35
+ lane: Lane
36
+ mark: Mark
37
+ /** Set when this origin cannot be used at all. Renders instead of an action. */
38
+ unavailable?: string
39
+ /** True for origins Radar does not implement (vs merely denied by RBAC). */
40
+ unsupported?: boolean
41
+ }
42
+
43
+ export const ORIGIN_KIND_TAG: Record<OriginKind, string> = {
44
+ synthetic: 'SYNTHETIC',
45
+ radar: 'RADAR ITSELF',
46
+ 'real-client': 'REAL CLIENT',
47
+ 'real-caller': 'REAL CALLER',
48
+ relayed: 'RELAYED · NOT A CALLER',
49
+ }
50
+
51
+ /** The laptop's own tag. It shares the 'real-client' KIND with the external
52
+ * origin, but the tags must differ: a genuine request from the public internet
53
+ * IS a real client; Radar dialling from the operator's machine behaves AS one
54
+ * - the sender is Radar, and saying "real client" upgraded it into a user. */
55
+ export const LAPTOP_TAG = 'AS A CLIENT'
56
+
57
+ /** Evidence strength, strongest first. Drives the "next strongest test" prompt. */
58
+ export const ORIGIN_STRENGTH: OriginId[] = ['caller', 'external', 'incluster', 'radar-incluster', 'local', 'apiserver']
59
+
60
+ /** The live work this page actually did: how many real dials were sent, at
61
+ * which layers, from how many vantages. The route counts alone undersold it -
62
+ * "1 got through" can be twenty-odd DNS/TLS/HTTP checks across hostnames,
63
+ * ports, pods and vantages, and that volume IS the trust signal. */
64
+ export function probeCheckStats(trace?: Trace): { checks: number; byLayer: Record<string, number>; vantages: number } {
65
+ const live = allProbes(trace).filter((p) => !p.skipped)
66
+ const byLayer: Record<string, number> = {}
67
+ for (const p of live) byLayer[p.layer] = (byLayer[p.layer] ?? 0) + 1
68
+ const vantages = new Set(live.map((p) => originOf(p, trace?.runVantage))).size
69
+ return { checks: live.length, byLayer, vantages }
70
+ }
71
+
72
+ export function allProbes(trace?: Trace): ProbeResult[] {
73
+ if (!trace) return []
74
+ return [...(trace.upstreams ?? []), ...(trace.downstream ?? [])].flatMap((h) => h.probes ?? [])
75
+ }
76
+
77
+ /** Which origin produced a probe. The apiserver path wins over vantage: a proxy
78
+ * relay is a control-plane mechanism no matter where the process calling it runs. */
79
+ export function originOf(p: ProbeResult, runVantage?: string): OriginId {
80
+ if (p.path === 'apiserver') return 'apiserver'
81
+ if (p.vantage !== 'in-cluster') return 'local'
82
+ if (p.source === 'probe-job') return 'incluster'
83
+ if (p.source === 'radar') return 'radar-incluster'
84
+ // No source: an older producer. An in-cluster dataplane probe can only be
85
+ // Radar's own when Radar itself runs in the cluster; from a laptop the only
86
+ // way to get one is the throwaway Job. runVantage is what the producer knows
87
+ // about itself, so it settles the ambiguity without guessing.
88
+ return runVantage === 'in-cluster' ? 'radar-incluster' : 'incluster'
89
+ }
90
+
91
+ interface OriginContext {
92
+ inClusterAllowed?: boolean
93
+ /** The scenario on screen. Marks are scored against THIS route when the
94
+ * producer sent per-vantage results, so the rail cannot disagree with the
95
+ * graph about what a vantage found. */
96
+ route?: RouteResult
97
+ inClusterRunning?: boolean
98
+ inClusterDeniedReason?: string
99
+ /** A run was ATTEMPTED and its probe never started (image pull, quota,
100
+ * webhook). "Not tested" would erase the attempt; execution failure is its
101
+ * own state, distinct from reachability. */
102
+ inClusterRunError?: string
103
+ stale?: boolean
104
+ }
105
+
106
+ /**
107
+ * Derives an origin's mark from the probes it actually produced.
108
+ *
109
+ * A live failure always wins over a success: when one origin's probes disagree
110
+ * among themselves, surfacing the failure is the safe direction. An origin whose
111
+ * every probe was SKIPPED did not fail - it never ran, which is `blocked`, and
112
+ * the skip reason explains why (a ClusterIP is not routable from a laptop).
113
+ */
114
+ function markFor(probes: ProbeResult[], id: OriginId, ctx: OriginContext): Mark {
115
+ if (id === 'incluster' && ctx.inClusterRunning) return 'running'
116
+ // The SELECTED route's own result for this origin wins when the producer sent
117
+ // one. Without it the rail scored an origin across EVERY route at once, so a
118
+ // vantage that failed on one path read as failed while the graph beside it -
119
+ // now route-scoped - showed that same vantage succeeding on the selected one.
120
+ const ev = ctx.route ? originRouteEvidence(ctx.route, id) : undefined
121
+ if (ev?.kind === 'own') {
122
+ if (ctx.stale) return 'stale'
123
+ return routeMark(ev.result, {})
124
+ }
125
+ // The producer sent a breakdown and this origin is not in it. That means the
126
+ // ROUTE has no result from here - it does NOT mean this vantage sent nothing.
127
+ // A laptop that dialled a public Ingress and got an answer has real evidence
128
+ // on the entry hop; the route is built from the backend's probes and cannot
129
+ // see it. Saying "not tested" there contradicted the graph beside it, which
130
+ // was drawing that very dial. Fall through to what this origin actually did.
131
+ if (ev?.kind === 'none' && probes.filter((p) => !p.skipped).length === 0) {
132
+ if (id === 'incluster' && ctx.inClusterAllowed === false) return 'denied'
133
+ return 'untested'
134
+ }
135
+ if (probes.length === 0) {
136
+ if (id === 'incluster' && ctx.inClusterAllowed === false) return 'denied'
137
+ return 'untested'
138
+ }
139
+ const live = probes.filter((p) => !p.skipped)
140
+ // A demoted run is a DISPOSITION, not a coverage gap: the probe dialled and
141
+ // its answer was deliberately kept informational. 'blocked' ("never tried -
142
+ // something failed earlier") was false on both clauses.
143
+ if (live.length === 0 && probes.some((p) => p.skipped && p.skipClass === 'informational')) return 'inconclusive'
144
+ if (live.length === 0) return 'blocked'
145
+ if (ctx.stale) return 'stale'
146
+ if (live.some((p) => !p.ok || p.tone === 'unhealthy')) return 'failed'
147
+ // 'reached' = answered with a 3xx/4xx/app-5xx: evidence the port serves, not a
148
+ // clean pass - same rule as the route rollup, where reached maps to answered.
149
+ if (live.some((p) => p.tone === 'degraded' || p.tone === 'reached')) return 'answered'
150
+ return id === 'apiserver' ? 'proxied' : 'proved'
151
+ }
152
+
153
+ /** The reason an all-skipped origin never ran, taken from the probes themselves. */
154
+ function skipReason(probes: ProbeResult[]): string | undefined {
155
+ return probes.find((p) => p.skipped && p.reason)?.reason
156
+ }
157
+
158
+ /**
159
+ * Why THIS origin's dials for a route were skipped, from the skip rows
160
+ * themselves. Each skip carries (vantage, path, source), so the reason is
161
+ * attributed by the exact identity that produced it - inferring from the run
162
+ * mode repeated the proxy's reason under the laptop, and lost it entirely when
163
+ * the run came from inside the cluster (which relays through the proxy too).
164
+ * A port-scoped skip only speaks for its own port; a shared (portless) skip
165
+ * speaks for every port. Another port's reason is never borrowed.
166
+ */
167
+ export function originSkipReason(trace: Trace | undefined, originId: OriginId, route?: RouteResult): string | undefined {
168
+ const target = route?.target || route?.route || ''
169
+ const m = /:(\d+)$/.exec(target)
170
+ const port = m ? Number(m[1]) : undefined
171
+ const backend = m ? target.slice(0, target.lastIndexOf(':')).trim() : ''
172
+ const pick = (probes: ProbeResult[]): string | undefined => {
173
+ const skips = probes.filter((p) => p.skipped && p.reason && originOf(p, trace?.runVantage) === originId)
174
+ if (port !== undefined) {
175
+ return (skips.find((p) => p.port === port) ?? skips.find((p) => !p.port))?.reason
176
+ }
177
+ return skips[0]?.reason
178
+ }
179
+ // Two backends can share a port, so a route with a named backend takes its
180
+ // reason ONLY from that backend's own hops - borrowing a sibling's would be
181
+ // the exact misattribution this lookup exists to prevent. The flat pool
182
+ // remains only for routes that never name a backend.
183
+ if (backend) {
184
+ const own = [...(trace?.upstreams ?? []), ...(trace?.downstream ?? [])].filter((h) => h.resource.name === backend)
185
+ if (own.length > 0) {
186
+ for (const h of own) {
187
+ const r = pick(h.probes ?? [])
188
+ if (r) return r
189
+ }
190
+ return undefined
191
+ }
192
+ }
193
+ return pick(allProbes(trace))
194
+ }
195
+
196
+ /**
197
+ * Why this origin's run was kept informational. A demotion applies to the RUN,
198
+ * not to one backend, so this is origin-scoped rather than route-scoped: the
199
+ * route-scoped lookup asks which declared path a skip speaks for, which is the
200
+ * wrong question when the whole run was demoted.
201
+ */
202
+ export function originInformationalReason(trace: Trace | undefined, originId: OriginId): string | undefined {
203
+ return allProbes(trace).find(
204
+ (p) => p.skipped && p.skipClass === 'informational' && !!p.reason && originOf(p, trace?.runVantage) === originId,
205
+ )?.reason
206
+ }
207
+
208
+ /**
209
+ * Builds the origin rail for a trace.
210
+ *
211
+ * Order is by evidence strength, not by availability, so the gap between what
212
+ * was tested and what would be conclusive stays legible.
213
+ */
214
+ export function buildOrigins(trace: Trace | undefined, ctx: OriginContext = {}): Origin[] {
215
+ const probes = allProbes(trace)
216
+ const byOrigin = new Map<OriginId, ProbeResult[]>()
217
+ for (const p of probes) {
218
+ const id = originOf(p, trace?.runVantage)
219
+ const arr = byOrigin.get(id) ?? []
220
+ arr.push(p)
221
+ byOrigin.set(id, arr)
222
+ }
223
+ const ns = trace?.subject.namespace ?? ''
224
+
225
+ const caller: Origin = {
226
+ id: 'caller',
227
+ glyph: '◈',
228
+ name: 'Real caller workload',
229
+ mech: 'one of your running Pods, as itself',
230
+ identity: 'the app’s own account and namespace',
231
+ kind: 'real-caller',
232
+ kindTag: ORIGIN_KIND_TAG['real-caller'],
233
+ lane: 'dataplane',
234
+ mark: 'blocked',
235
+ unsupported: true,
236
+ unavailable: 'Radar can’t send a request from one of your running Pods yet. Until it can, anything that depends on who is calling — mesh mTLS, authorization policy, network policy — stays untested.',
237
+ }
238
+ const external: Origin = {
239
+ id: 'external',
240
+ glyph: '◉',
241
+ name: 'Direct external client',
242
+ mech: 'a request from the public internet',
243
+ identity: 'no cluster identity · anonymous client',
244
+ kind: 'real-client',
245
+ kindTag: ORIGIN_KIND_TAG['real-client'],
246
+ lane: 'dataplane',
247
+ mark: 'blocked',
248
+ unsupported: true,
249
+ unavailable: 'Radar has nowhere to test from that is provably on the public internet. A test from your machine is reported as exactly that, never as “from outside”.',
250
+ }
251
+
252
+ const inClusterProbes = byOrigin.get('incluster') ?? []
253
+ // A failed ATTEMPT is not "not tested": the operator clicked Run and the
254
+ // probe never started. Only when no live probe made it through - a partial
255
+ // run that produced results keeps its evidence-based mark.
256
+ const inClusterFailedToRun =
257
+ !!ctx.inClusterRunError && !ctx.inClusterRunning && inClusterProbes.filter((p) => !p.skipped).length === 0
258
+ const incluster: Origin = {
259
+ id: 'incluster',
260
+ glyph: '⚗',
261
+ name: 'In-cluster probe',
262
+ mech: 'a throwaway Pod Radar starts inside the cluster',
263
+ // The Job sets no serviceAccountName and disables token automount, so it runs
264
+ // as the target namespace's default SA with no credentials mounted - NOT as
265
+ // Radar. Sidecar injection is admission's call, not ours; the runner handles
266
+ // injected sidecars precisely because they do appear.
267
+ identity: `in ${ns || 'the namespace'} · the namespace’s default account, no token`,
268
+ kind: 'synthetic',
269
+ kindTag: ORIGIN_KIND_TAG.synthetic,
270
+ lane: 'dataplane',
271
+ mark: inClusterFailedToRun && ctx.inClusterAllowed !== false ? 'blocked' : markFor(inClusterProbes, 'incluster', ctx),
272
+ unavailable:
273
+ ctx.inClusterAllowed === false
274
+ ? ctx.inClusterDeniedReason || 'Not permitted in this cluster.'
275
+ : inClusterFailedToRun
276
+ ? ctx.inClusterRunError
277
+ : undefined,
278
+ }
279
+
280
+ const apiProbes = byOrigin.get('apiserver') ?? []
281
+ const apiserver: Origin = {
282
+ id: 'apiserver',
283
+ glyph: '⌸',
284
+ name: 'API-server proxy',
285
+ mech: 'Kubernetes relays the request for us',
286
+ identity: 'Radar’s own credentials — not a Pod',
287
+ kind: 'relayed',
288
+ kindTag: ORIGIN_KIND_TAG.relayed,
289
+ lane: 'control',
290
+ mark: markFor(apiProbes, 'apiserver', ctx),
291
+ }
292
+
293
+ const localProbes = byOrigin.get('local') ?? []
294
+ const localMark = markFor(localProbes, 'local', ctx)
295
+ // A probed run that produced NOTHING from the laptop, on a subject with no
296
+ // front door, is not "not tested yet" - there is nothing the laptop could
297
+ // dial. The vantage stays on the rail (the evidence ceiling must stay
298
+ // visible, exactly like the caller and external gaps) but it must say WHY it
299
+ // cannot be used - a bare "not tested" reads as a test someone forgot to run.
300
+ // Only claimed after a run: with no probes anywhere, "not tested yet" is the
301
+ // honest state for everyone.
302
+ const localCannotDial = probes.length > 0 && localProbes.length === 0 && localMark === 'untested' && (trace?.upstreams ?? []).length === 0
303
+ // "No Ingress, Gateway or LoadBalancer" is only true of a ClusterIP subject.
304
+ // A LoadBalancer/NodePort Service with no Ingress reaches this branch too -
305
+ // Radar just doesn't dial its external address yet, which is a claim about
306
+ // Radar, not about the Service.
307
+ const subjectType = (trace?.downstream ?? [])
308
+ .find((h) => h.resource.kind === 'Service' && h.resource.name === trace?.subject.name)
309
+ ?.config?.serviceType?.trim()
310
+ .toUpperCase()
311
+ const externallyAddressable = subjectType === 'LOADBALANCER' || subjectType === 'NODEPORT' || subjectType === 'EXTERNALNAME'
312
+ const local: Origin = {
313
+ id: 'local',
314
+ glyph: '▣',
315
+ name: 'Radar on your machine',
316
+ mech: 'this machine, over your own network',
317
+ kind: 'real-client',
318
+ kindTag: LAPTOP_TAG,
319
+ // Not the cluster dataplane: from a laptop a request never traverses
320
+ // kube-proxy, NetworkPolicy or the mesh, so it cannot sit in that lane.
321
+ lane: 'control',
322
+ mark: localCannotDial ? 'blocked' : localMark,
323
+ identity: localIdentity(localProbes),
324
+ unavailable:
325
+ localMark === 'blocked'
326
+ ? skipReason(localProbes)
327
+ : localCannotDial
328
+ ? externallyAddressable
329
+ ? 'Radar doesn’t dial this Service’s external address from your machine yet. From here it can only relay through the API-server proxy.'
330
+ : 'This Service has no entry point Radar can dial from your machine — no Ingress, Gateway address or LoadBalancer. From here Radar can only relay through the API-server proxy.'
331
+ : undefined,
332
+ }
333
+
334
+ const radarProbes = byOrigin.get('radar-incluster') ?? []
335
+ // Radar running AS a Pod is not the throwaway Job: it dials with Radar's own
336
+ // ServiceAccount from Radar's own namespace, and whatever sidecars Radar has.
337
+ // Collapsing the two credited Radar's direct dials to a Job never created.
338
+ const radarInCluster: Origin = {
339
+ id: 'radar-incluster',
340
+ glyph: '◈',
341
+ name: 'Radar, inside the cluster',
342
+ mech: 'Radar\u2019s own process, running as a Pod',
343
+ identity: 'Radar\u2019s own namespace and ServiceAccount',
344
+ kind: 'radar',
345
+ kindTag: ORIGIN_KIND_TAG.radar,
346
+ lane: 'dataplane',
347
+ mark: markFor(radarProbes, 'radar-incluster', ctx),
348
+ unsupported: trace?.runVantage !== 'in-cluster' && radarProbes.length === 0,
349
+ unavailable:
350
+ trace?.runVantage !== 'in-cluster' && radarProbes.length === 0
351
+ ? 'Radar is not running in this cluster, so it cannot dial from inside on its own. The in-cluster test starts a throwaway Pod instead.'
352
+ : undefined,
353
+ }
354
+
355
+ const all: Record<OriginId, Origin> = { caller, external, incluster, 'radar-incluster': radarInCluster, local, apiserver }
356
+ // Radar not being a Pod is not an evidence GAP - the in-cluster Job covers the
357
+ // same ground. Listing it as "never tested" would pad the coverage caveat with
358
+ // a deployment detail, next to genuine holes like "as your application".
359
+ const radarIsAbsent = radarProbes.length === 0 && trace?.runVantage !== 'in-cluster'
360
+ return ORIGIN_STRENGTH.filter((id) => !(id === 'radar-incluster' && radarIsAbsent)).map((id) => all[id])
361
+ }
362
+
363
+ /**
364
+ * What we can honestly say about where the laptop sat on the network.
365
+ *
366
+ * This used to be a constant - "we can't tell where on the network you are" -
367
+ * for every trace forever. DNS already resolves the address, so when the name
368
+ * resolves to a globally routable one we DO know the request left for a public
369
+ * address, and saying nothing under-claims.
370
+ *
371
+ * It stops there deliberately. A public address is not proof the packet crossed
372
+ * the public internet: a VPN, hairpin NAT or split-horizon DNS all break that
373
+ * inference. So this describes the ADDRESS and never the journey, and it does
374
+ * not make the external vantage supported - that is a different claim.
375
+ */
376
+ export function localIdentity(probes: ProbeResult[]): string {
377
+ const scopes = new Set(probes.filter((p) => !p.skipped && p.addressScope).map((p) => p.addressScope))
378
+ if (scopes.has('mixed')) {
379
+ return 'this name resolves to both public and private addresses — we can’t tell which one you reached'
380
+ }
381
+ if (scopes.has('public')) {
382
+ return 'you dialled a public address — though a VPN or split-horizon DNS could still have kept it internal'
383
+ }
384
+ if (scopes.has('private')) {
385
+ return 'you dialled a private address, so this did not come from the public internet'
386
+ }
387
+ return 'we can’t tell where on the network you are'
388
+ }
389
+
390
+ /** The origin that should be selected when the view opens: the strongest one
391
+ * that actually produced evidence, else the strongest usable one. */
392
+ export function defaultOrigin(origins: Origin[], route?: RouteResult): OriginId {
393
+ // First paint must AGREE with the headline: the headline speaks from the
394
+ // rollup's deciding evidence, and default-selecting a different vantage put
395
+ // "not tested" directly under a sentence about a mechanism that vantage
396
+ // never used. Prefer the origin whose row decided the rollup.
397
+ const fromHeadline = headlineEvidenceOrigin(route)
398
+ if (fromHeadline && origins.some((o) => o.id === fromHeadline && !o.unsupported)) return fromHeadline
399
+ const evidence: Mark[] = ['proved', 'failed', 'answered', 'proxied', 'stale', 'running']
400
+ const withEvidence = origins.find((o) => evidence.includes(o.mark))
401
+ if (withEvidence) return withEvidence.id
402
+ const usable = origins.find((o) => !o.unsupported && !o.unavailable)
403
+ return usable?.id ?? 'apiserver'
404
+ }
405
+
406
+ /** The origin whose evidence DECIDED the route rollup - what the headline
407
+ * speaks from. Undefined when nothing decided it (untested / no rows). */
408
+ export function headlineEvidenceOrigin(route?: RouteResult): OriginId | undefined {
409
+ if (!route || route.outcome === 'not-tested') return undefined
410
+ if (route.confidence === 'indirect') return 'apiserver'
411
+ const rows = route.byVantage ?? []
412
+ const row = rows.find((v) => v.confidence === 'real' && v.outcome === route.outcome) ?? rows.find((v) => v.outcome === route.outcome)
413
+ if (!row) return undefined
414
+ if (row.path === 'apiserver') return 'apiserver'
415
+ if (row.vantage !== 'in-cluster') return 'local'
416
+ return row.source === 'radar' ? 'radar-incluster' : 'incluster'
417
+ }
418
+
419
+ const isGap = (o: Origin): boolean => o.mark === 'untested' || o.mark === 'denied' || (!!o.unsupported && o.mark === 'blocked')
420
+
421
+ /**
422
+ * The strongest origin that is not yet proven, including ones Radar cannot use.
423
+ * This is the honest ceiling on the evidence - useful as a caveat, but NEVER as
424
+ * a call to action.
425
+ */
426
+ export function strongestGap(origins: Origin[]): Origin | undefined {
427
+ return origins.find(isGap)
428
+ }
429
+
430
+ /**
431
+ * The strongest gap Radar can actually close.
432
+ *
433
+ * Kept separate from `strongestGap` deliberately. The real caller is almost
434
+ * always the strongest missing origin AND permanently unavailable, so driving
435
+ * the next-step prompt from `strongestGap` made every resource advertise a test
436
+ * that cannot be run - a dead end exactly where the operator needs a next move.
437
+ * The unreachable ceiling belongs in the caveats; the prompt belongs here.
438
+ */
439
+ export function actionableGap(origins: Origin[]): Origin | undefined {
440
+ // ORIGIN_STRENGTH is an evidence ranking, and a gap is only worth proposing
441
+ // if it is STRONGER than what has already been proven. Without this the
442
+ // panel told you your laptop (control lane) was "the strongest evidence
443
+ // Radar can still collect" about in-cluster reachability, after the
444
+ // in-cluster probe had already succeeded.
445
+ const provenAt = origins.findIndex((o) => o.mark === 'proved')
446
+ const ceiling = provenAt === -1 ? origins.length : provenAt
447
+ return origins.slice(0, ceiling).find((o) => isGap(o) && !o.unsupported && o.mark !== 'denied')
448
+ }
449
+