@skyhook-io/k8s-ui 1.10.5 → 1.12.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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/components/gitops/GitOpsStatusBadge.tsx +40 -0
  3. package/src/components/gitops/GitOpsTableView.tsx +22 -2
  4. package/src/components/gitops/ReconcilingIndicator.test.tsx +31 -0
  5. package/src/components/gitops/detail-helpers.ts +4 -1
  6. package/src/components/issues/IssuesView.tsx +9 -0
  7. package/src/components/issues/diagnostic.ts +3 -0
  8. package/src/components/issues/issues.test.ts +11 -0
  9. package/src/components/issues/severity.ts +3 -0
  10. package/src/components/issues/types.ts +3 -0
  11. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  12. package/src/components/resources/ResourcesView.tsx +261 -30
  13. package/src/components/resources/index.ts +2 -0
  14. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  15. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  16. package/src/components/resources/renderers/AlertRenderer.tsx +4 -1
  17. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  18. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +133 -9
  19. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +19 -4
  20. package/src/components/resources/renderers/CompositeRenderer.test.tsx +81 -0
  21. package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
  22. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  23. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  24. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -1
  25. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +4 -1
  26. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +4 -1
  27. package/src/components/resources/renderers/KustomizationRenderer.tsx +4 -1
  28. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  29. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  30. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  31. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +4 -1
  32. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  33. package/src/components/resources/renderers/PolicySection.test.tsx +102 -0
  34. package/src/components/resources/renderers/PolicySection.tsx +170 -0
  35. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  36. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  37. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  38. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  39. package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -1
  40. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  41. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  42. package/src/components/resources/renderers/index.ts +5 -0
  43. package/src/components/resources/renderers/kyverno-cells.tsx +9 -0
  44. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  45. package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -0
  46. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  47. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  48. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  49. package/src/components/resources/resource-utils-cnpg.test.ts +590 -1
  50. package/src/components/resources/resource-utils-cnpg.ts +433 -56
  51. package/src/components/resources/resource-utils-crossplane.test.ts +68 -0
  52. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  53. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  54. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  55. package/src/components/resources/resource-utils-velero.ts +214 -22
  56. package/src/components/resources/resource-utils.ts +70 -3
  57. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  58. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  59. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  60. package/src/components/topology/K8sResourceNode.tsx +6 -0
  61. package/src/components/trace/ReachabilityGraph.tsx +20 -6
  62. package/src/components/trace/ReachabilityView.tsx +253 -52
  63. package/src/components/trace/problemRows.test.ts +107 -0
  64. package/src/components/trace/reachGraphModel.test.ts +16 -0
  65. package/src/components/trace/reachInspector.test.ts +233 -10
  66. package/src/components/trace/reachInspector.ts +166 -36
  67. package/src/components/trace/reachMarks.test.ts +32 -1
  68. package/src/components/trace/reachMarks.ts +44 -0
  69. package/src/components/trace/reachOrigins.ts +12 -0
  70. package/src/components/trace/types.ts +25 -0
  71. package/src/components/ui/Badge.tsx +36 -0
  72. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  73. package/src/theme/components.css +26 -0
  74. package/src/types/gitops-flux-status.test.ts +163 -0
  75. package/src/types/gitops.ts +80 -24
  76. package/src/types/index.ts +1 -0
  77. package/src/types/policy.ts +42 -0
  78. package/src/utils/api-resources.ts +2 -0
  79. package/src/utils/resource-icons.test.ts +33 -0
  80. package/src/utils/resource-icons.ts +65 -2
@@ -1,8 +1,8 @@
1
1
  import type { Trace, RouteResult, ResourceRef } from './types'
2
2
  import type { Mark, SevTone } from './reachMarks'
3
- import { routeMark, routeChip, routeTone, routeAsSeenFrom, originRouteEvidence, routeForOrigin, traceInClusterRunnable } from './reachMarks'
3
+ import { routeMark, routeChip, routeTone, routeAsSeenFrom, originRouteEvidence, routeForOrigin, traceInClusterRunnable, markHelp } from './reachMarks'
4
4
  import type { Origin, OriginId } from './reachOrigins'
5
- import { strongestGap, actionableGap, originSkipReason } from './reachOrigins'
5
+ import { strongestGap, actionableGap, originSkipReason, originInformationalReason } from './reachOrigins'
6
6
  import { hopEvidenceFor, originProducedEvidence, type GraphNode } from './reachGraphModel'
7
7
 
8
8
  // 'run-probes' re-runs the reachability probes. It is deliberately NOT
@@ -45,7 +45,7 @@ export interface Sidebar {
45
45
  scope: { k: string; v: string }[]
46
46
  evidence: { mark: Mark; text: string }[]
47
47
  notProve: string[]
48
- next: { header: string; body: string; blocked?: string; ctas: InspectorCTA[]; quiet?: boolean }
48
+ next: { header: string; body: string; blocked?: string; ctas: InspectorCTA[] }
49
49
  }
50
50
  /** Every hop on the selected path, in order - the whole story for this path
51
51
  * seen from this vantage, rather than a summary plus whichever node was last
@@ -74,7 +74,7 @@ export interface HopDetail {
74
74
  /** Configured-but-bypassed resources, grouped under one label naming WHY they
75
75
  * are outside this vantage's journey. Their config sections stay readable;
76
76
  * their journey-state words do not apply. */
77
- function contextGroup(ctx: Ctx, byId: Map<string, GraphNode>): Sidebar['context'] {
77
+ function contextGroup(ctx: Ctx, byId: Map<string, GraphNode>, sel: Selection): Sidebar['context'] {
78
78
  const nodes = (ctx.contextNodeIds ?? []).map((id) => byId.get(id)).filter((n): n is GraphNode => !!n)
79
79
  if (nodes.length === 0) return undefined
80
80
  const label =
@@ -85,12 +85,16 @@ function contextGroup(ctx: Ctx, byId: Map<string, GraphNode>): Sidebar['context'
85
85
  : 'PARALLEL ENTRY — NOT ON THIS ROUTE\u2019S PATH'
86
86
  return {
87
87
  label,
88
+ // Context hops are collapsed by default - they are not the journey. But the
89
+ // SELECTED one opens: the entry-problem row's whole purpose is "show me the
90
+ // cause", and landing on a still-collapsed section makes the reader spend a
91
+ // second click on the thing they just asked for.
88
92
  hops: nodes.map((n) => ({
89
93
  ...resourceSection(n),
90
94
  id: n.id,
91
95
  state: 'plain' as const,
92
96
  chipText: '',
93
- expanded: false,
97
+ expanded: sel === n.id,
94
98
  })),
95
99
  }
96
100
  }
@@ -120,7 +124,7 @@ const NOT_DATAPLANE = 'Nothing about the normal network path. Kubernetes relayed
120
124
  const SYNTHETIC_IDENTITY =
121
125
  'That your app can reach it. This test ran from a throwaway Pod under the namespace’s default account with no token mounted — not as your application — so anything that checks who is calling may answer differently.'
122
126
 
123
- function originScope(o: Origin, trace: Trace): { k: string; v: string }[] {
127
+ function originScope(o: Origin, trace: Trace, request?: string): { k: string; v: string }[] {
124
128
  const runsIn: Record<OriginId, string> = {
125
129
  incluster: `a throwaway Pod in ${trace.subject.namespace || 'the cluster'}`,
126
130
  'radar-incluster': 'Radar\u2019s own Pod',
@@ -137,6 +141,9 @@ function originScope(o: Origin, trace: Trace): { k: string; v: string }[] {
137
141
  // different facts and must not share a label.
138
142
  { k: o.id === 'local' ? 'NETWORK POSITION' : 'IDENTITY', v: o.identity },
139
143
  { k: 'MECHANISM', v: o.mech },
144
+ // A status code cannot be read without knowing what was asked for - "404"
145
+ // means nothing until you know the request was GET /.
146
+ ...(request ? [{ k: 'REQUEST', v: request }] : []),
140
147
  ]
141
148
  }
142
149
 
@@ -151,6 +158,7 @@ function gapNext(
151
158
  namespace?: string,
152
159
  multiPath?: boolean,
153
160
  inClusterRunnable = true,
161
+ canRun = true,
154
162
  ): Sidebar['path']['next'] {
155
163
  // The server can only test a route that carries a concrete in-cluster request.
156
164
  // When none does, the run fails with "not supported for this subject" - so the
@@ -161,7 +169,14 @@ function gapNext(
161
169
  // graph, which is the thing that would produce the missing evidence - and a
162
170
  // third copy of the same control (header, panel, capsule) was one too many.
163
171
  // The panel keeps the REASONING, which is what it is good at.
164
- const inClusterCTA = (): InspectorCTA[] => []
172
+ const inClusterCTA = (): InspectorCTA[] => {
173
+ // No handler wired (a library consumer that omits it) means the click would
174
+ // do nothing at all - offer nothing rather than a button that lies.
175
+ if (!canRun) return []
176
+ return inClusterRunnable
177
+ ? [{ text: '⚗ Run the in-cluster test', action: 'run-in-cluster', primary: true }]
178
+ : [{ text: '⚗ Run the in-cluster test', action: 'run-in-cluster', disabledReason: notRunnable }]
179
+ }
165
180
  const actionable = actionableGap(origins)
166
181
  const ceiling = strongestGap(origins)
167
182
  const ceilingNote = ceiling?.unsupported ? `Even then, ${ceiling.name.toLowerCase()} stays untested — ${ceiling.unavailable}` : undefined
@@ -194,28 +209,23 @@ function gapNext(
194
209
  }
195
210
  }
196
211
  if (actionable && actionable.id === current.id) {
197
- // You are looking at the vantage that is itself the gap.
212
+ // You are looking at the vantage that is itself the gap. The section body
213
+ // already said nothing ran from here, and the caveats section already
214
+ // carries the ceiling - so this is the ACTION and nothing else.
198
215
  return {
199
- header: 'RUN THIS NEXT',
200
- body: inClusterRunnable
201
- ? `Nothing has been tested from ${actionable.name} yet, and it is the strongest evidence Radar can still collect.${allPaths}`
202
- : `${notRunnable} Every declared path here was skipped before a request could be formed.`,
203
- blocked: ceilingNote,
216
+ header: '',
217
+ body: inClusterRunnable ? '' : `${notRunnable} Every declared path here was skipped before a request could be formed.`,
204
218
  ctas: actionable.id === 'incluster' ? inClusterCTA() : [{ text: '⟳ Re-run', action: 'run-probes' }],
205
219
  }
206
220
  }
207
221
  if (!actionable) {
208
- // Terminal state: there is nothing to do, and a call-to-action box whose
209
- // message is "no action" shouts its own irrelevance. One quiet line of
210
- // closure; no Re-run (the header already owns that control - the same
211
- // one-copy rule as inClusterCTA), and no ceiling caveat (WHAT THIS
212
- // DOESN'T PROVE states it on this same pane).
213
- return {
214
- header: 'NO STRONGER TEST AVAILABLE',
215
- body: `This is the strongest evidence Radar can collect for this path.${allPaths}`,
216
- ctas: [],
217
- quiet: true,
218
- }
222
+ // Nothing to offer: say nothing. This was a resource-level statement living
223
+ // in the selection-scoped panel, identical on every vantage - the scope
224
+ // mixing this redesign exists to remove - and the ceiling it gestured at is
225
+ // already stated, with specifics, in WHAT THIS DOESN'T PROVE and in the
226
+ // footer's coverage ledger. An empty section is the honest render of
227
+ // "there is no next step".
228
+ return { header: '', body: '', ctas: [] }
219
229
  }
220
230
  return {
221
231
  header: 'RUN THIS NEXT',
@@ -259,6 +269,9 @@ interface Ctx {
259
269
  multiPath?: boolean
260
270
  /** The HTTP path the run requests, as chosen in "what to test". */
261
271
  httpPath?: string
272
+ /** False when the host wired no in-cluster handler, or permission is denied:
273
+ * the run must not be offered at all then. */
274
+ canRunInCluster?: boolean
262
275
  }
263
276
 
264
277
  /**
@@ -280,7 +293,73 @@ function restatesTitle(summary: string | undefined, title: string): boolean {
280
293
  return !!summary && !!title && norm(summary) === norm(title)
281
294
  }
282
295
 
296
+ /** The request this route would send, in one line - "GET /healthz", or "TCP
297
+ * connect" where there is no application request to make. */
298
+ function requestLabel(route: RouteResult | undefined, httpPath?: string): string | undefined {
299
+ const r = route?.inClusterRequest
300
+ if (!r) return undefined
301
+ if (r.protocol === 'tcp') return 'TCP connect'
302
+ const path = httpPath || r.path || '/'
303
+ return `GET ${path}`
304
+ }
305
+
306
+ /**
307
+ * What an ANSWER means for this page's question.
308
+ *
309
+ * "reached" plus an HTTP status is the single most-misread result on the tab:
310
+ * a reader sees amber and 404 and cannot tell whether that is a problem. It is
311
+ * not - for the question this page asks. The network path is proven the moment
312
+ * the app answers at all; the status code is a statement about the app's own
313
+ * routing, auth or health, which reachability does not judge. Say that, and say
314
+ * what would turn it into a verified pass.
315
+ */
316
+ /**
317
+ * What an answer MEANS, and whether that answer is evidence the path works.
318
+ *
319
+ * `pathWorks` is the load-bearing half: an app answering 404 proves the journey
320
+ * reached it, while a gateway answering 502 proves the opposite. Both are
321
+ * "answers", so a caller that prefixes every meaning with "the path works"
322
+ * states the reverse of the truth on exactly the cases that matter most.
323
+ */
324
+ function statusMeaning(evidence?: string, httpPath?: string, failedLayer?: string): { text: string; pathWorks: boolean } | undefined {
325
+ // A cert failure never gets an HTTP status - the handshake stops before a
326
+ // request is sent - so this must be decided before looking for a code.
327
+ if (failedLayer === 'tls') {
328
+ return { text: 'the TLS handshake did not verify - a certificate problem, not an application one.', pathWorks: false }
329
+ }
330
+ const m = /HTTP\s+(\d{3})/i.exec(evidence ?? '')
331
+ if (!m) return undefined
332
+ const code = Number(m[1])
333
+ const asked = httpPath && httpPath !== '/' ? httpPath : '/'
334
+ // 502/504 are NOT the app: a gateway answering that it could not reach its
335
+ // upstream. The producer says so with failedLayer 'upstream'; blaming app
336
+ // health here contradicted the chip beside it.
337
+ if (failedLayer === 'upstream' || code === 502 || code === 504) {
338
+ return {
339
+ text: `the front door answered, but only to say it could not reach the backend (HTTP ${code}) - the break is between the entry and the app, not inside the app.`,
340
+ pathWorks: false,
341
+ }
342
+ }
343
+ if (code >= 500) {
344
+ return { text: `the request reached the app and the app itself returned an error (HTTP ${code}) - application health, which this page does not judge.`, pathWorks: true }
345
+ }
346
+ if (code === 401 || code === 403 || code === 407) {
347
+ return { text: `the app answered by demanding credentials (HTTP ${code}) - it is enforcing auth, which is a different thing from reachability.`, pathWorks: true }
348
+ }
349
+ if (code >= 300 && code < 400) {
350
+ return { text: `the app answered with a redirect (HTTP ${code}), which Radar does not follow - so whatever it points at is untested from here.`, pathWorks: true }
351
+ }
352
+ if (code >= 400) {
353
+ return { text: `the app answered (HTTP ${code}) - that is it saying it serves no route for ${asked}, not a reachability problem. To verify a real route, re-run with a path your app serves.`, pathWorks: true }
354
+ }
355
+ return undefined
356
+ }
357
+
283
358
  /** The persistent diagnosis: did traffic get through, from where, and what next. */
359
+ /** The suffix the localization lines carry; stripped when deduping so one
360
+ * observation cannot appear twice at two lengths. */
361
+ const LOCALIZED_SUFFIX_RE = / [-\u2014] checked directly, past the entry point$/
362
+
284
363
  function pathSection(ctx: Ctx): Sidebar['path'] {
285
364
  const { trace, route, origin, origins } = ctx
286
365
  // The route outcome is merged across origins. Without this gate the panel
@@ -330,14 +409,29 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
330
409
  }
331
410
 
332
411
  const evidence: { mark: Mark; text: string }[] = []
333
- const seen = new Set<string>()
412
+ const seen = new Map<string, number>()
413
+ // Two lines can be the SAME observation worded at different lengths: a route's
414
+ // rollup evidence and the localization fact from that same dial differ only by
415
+ // the " - checked directly..." suffix, so exact-string dedupe let both through
416
+ // and the reader saw "HTTP 404 - reached" twice. Key on the observation and
417
+ // keep the more specific wording.
334
418
  const add = (m: Mark, text: string) => {
335
- const key = text.trim().toLowerCase()
336
- if (!key || seen.has(key)) return
337
- seen.add(key)
338
- evidence.push({ mark: m, text })
419
+ const t = text.trim()
420
+ const key = t.toLowerCase().replace(LOCALIZED_SUFFIX_RE, '').trim()
421
+ if (!key) return
422
+ const at = seen.get(key)
423
+ if (at !== undefined) {
424
+ if (t.length > evidence[at].text.length) evidence[at] = { mark: evidence[at].mark, text: t }
425
+ return
426
+ }
427
+ seen.set(key, evidence.length)
428
+ evidence.push({ mark: m, text: t })
339
429
  }
340
430
  if ((hasEvidence || fromConfig) && asSeen?.evidence) add(mark, asSeen.evidence)
431
+ // A result with no evidence STRING is still a result: show what the mark
432
+ // means rather than leaving the section empty, which would read as "nothing
433
+ // ran" for a vantage that did.
434
+ else if (hasEvidence && asSeen) add(mark, markHelp(mark))
341
435
  // What this vantage saw at each HOP. The route is built from the backend's
342
436
  // probes, so a laptop that dialled the front door and got an answer had all
343
437
  // of it discarded and read as "no test has been run from here" - beside a
@@ -374,12 +468,16 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
374
468
  // has been run" - the capsule preserves the attempt and this row must not
375
469
  // erase it. The error itself is the observation.
376
470
  const attemptError = origin.id === 'incluster' && origin.mark === 'blocked' ? origin.unavailable : undefined
471
+ // A demoted run produced a real observation; "no test has been run from
472
+ // here" erased it.
473
+ const informational = origin.mark === 'inconclusive' ? originInformationalReason(trace, origin.id) : undefined
377
474
  add(
378
475
  mark,
379
476
  // The attempt error outranks skip rows: a prior Job's leftover skips
380
477
  // would otherwise paint a fresh failed run (image pull, quota) with last
381
478
  // run's reason, one pane from a capsule saying "test couldn't run".
382
- attemptError ||
479
+ informational ||
480
+ attemptError ||
383
481
  skipReason ||
384
482
  (origin.unsupported
385
483
  ? 'Radar cannot test from here, so nothing has been learned this way'
@@ -388,7 +486,7 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
388
486
  : // A vantage that CANNOT be used states why (nothing dialable from
389
487
  // the laptop, a skipped mechanism) - "no test has been run" reads
390
488
  // as a test someone forgot, which is a different claim.
391
- origin.unavailable || 'no test has been run from here'),
489
+ origin.unavailable || ''),
392
490
  )
393
491
  }
394
492
 
@@ -399,12 +497,22 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
399
497
  const rawDiagnosis = trace.diagnosis
400
498
  const diagnosis = rawDiagnosis && (!rawDiagnosis.route || rawDiagnosis.route === route?.route) ? rawDiagnosis : undefined
401
499
  const reachedSomething = !hasEvidence && !fromConfig && hopSeen.some((x) => x.ev.mark !== 'failed')
500
+ const answer = statusMeaning(asSeen?.evidence, ctx.httpPath, asSeen?.failedLayer)
402
501
  const body = basis === 'cluster-state'
403
502
  ? 'Nothing is ready to serve this path right now, so it cannot work from any vantage. No request was sent to establish that — it is read off the current state of the cluster, and it changes when the workload does.'
404
503
  : fromConfig
405
504
  ? 'The configuration itself is broken, so this path cannot work from any vantage. No request was sent to establish that — it is read off what is declared.'
406
505
  : reachedSomething
407
506
  ? 'This vantage did reach part of the path — see what it saw below. It has no result for this route as a whole, so the end-to-end journey from here is still unproven.'
507
+ : origin.mark === 'inconclusive' || mark === 'inconclusive'
508
+ // A demoted run RAN and answered - it is only kept out of the verdict. The
509
+ // "nothing has been tested" branch below fired first and denied it happened.
510
+ // Both the origin's mark and the route's own can carry the demotion, and the
511
+ // sentence is the same either way; the reason comes from the informational
512
+ // skip that recorded it, never from the route-scoped skip lookup.
513
+ ? `The probe ran and got an answer, but it is kept as evidence rather than a verdict${
514
+ originInformationalReason(trace, origin.id) ? `: ${originInformationalReason(trace, origin.id)}` : ''
515
+ }. A throwaway identity cannot stand in for your application, so what it saw informs but never decides.`
408
516
  : !hasEvidence
409
517
  ? (origin.unavailable || 'Nothing has been tested from here, so this says nothing about whether traffic gets through.') +
410
518
  // When NOTHING was tested anywhere, the health dots are the only colour
@@ -416,11 +524,23 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
416
524
  : mark === 'proved'
417
525
  ? 'A real request went through and the target answered.'
418
526
  : mark === 'proxied'
419
- ? 'Kubernetes relayed a request and the target answered — which shows something is serving, not that the normal path works.'
527
+ ? [
528
+ 'Kubernetes relayed a request and the target answered — which shows something is serving, not that the normal path works.',
529
+ // The relay caveat alone leaves "404" unreadable: the reader still
530
+ // cannot tell whether the answer itself was a problem.
531
+ answer && `As for the answer itself: ${answer.text}`,
532
+ ]
533
+ .filter(Boolean)
534
+ .join(' ')
420
535
  : mark === 'untested'
421
536
  ? 'Nothing has been tried from here yet. Configuration may look right, but that is intent, not proof.'
422
537
  : mark === 'stale'
423
538
  ? 'This result predates a change to the cluster, so it is set aside rather than trusted.'
539
+ : mark === 'excluded'
540
+ // Benign by design (deliberately scaled to zero, a not-eligible
541
+ // endpoint). It fell through to the generic "answered" sentence,
542
+ // which described a request that was never sent.
543
+ ? `${asSeen?.evidence || 'Nothing is behind this path right now'} — that is deliberate, not a failure: nothing was sent, because there is nothing to reach.`
424
544
  : mark === 'running'
425
545
  ? 'A test is running. Earlier results stay until new ones replace them.'
426
546
  : // A proxy-only failure wears the same amber mark as a real answer,
@@ -428,7 +548,14 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
428
548
  // the reader to debug an application response that never existed.
429
549
  asSeen?.outcome === 'unreachable' && asSeen?.confidence === 'indirect'
430
550
  ? 'The relayed dial failed. The proxy bypasses the real path, so this does not condemn it — but nothing answered, and the real path is still untested.'
431
- : 'The target answered, but not with what was asked for.'
551
+ : // Only an answer that proves the journey completed earns "the
552
+ // path works" - a 502 or a failed handshake is an answer that
553
+ // says the opposite.
554
+ (answer ? (answer.pathWorks ? `The path works: ${answer.text}` : `The path did not work: ${answer.text}`) : undefined) ??
555
+ // A transport-only reach: nothing was asked of the application.
556
+ (asSeen?.outcome === 'reached'
557
+ ? 'The port accepted a connection, but nothing was asked of the application - the transport works and the application protocol is unverified.'
558
+ : 'The target answered, but not with what was asked for.')
432
559
 
433
560
  return {
434
561
  chipTone: asSeen ? routeTone(asSeen, { stale: ctx.stale, running: ctx.running }) : 'unknown',
@@ -436,7 +563,7 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
436
563
  title: `${origin.name} → ${route?.target || trace.subject.name}`,
437
564
  request: route ? `${route.route}${ctx.httpPath && ctx.httpPath !== '/' ? ` · HTTP path ${ctx.httpPath}` : ''}` : undefined,
438
565
  body,
439
- scope: [...originScope(origin, trace), ...(route ? [{ k: 'PATH', v: route.route }] : [])],
566
+ scope: [...originScope(origin, trace, requestLabel(route, ctx.httpPath)), ...(route ? [{ k: 'PATH', v: route.route }] : [])],
440
567
  evidence,
441
568
  notProve,
442
569
  next:
@@ -449,7 +576,7 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
449
576
  ...(diagnosis.command ? [{ text: 'Copy the command', action: 'copy-command' as InspectorAction, command: diagnosis.command }] : []),
450
577
  ],
451
578
  }
452
- : gapNext(origins, origin, trace.subject.namespace, ctx.multiPath, traceInClusterRunnable(trace)),
579
+ : gapNext(origins, origin, trace.subject.namespace, ctx.multiPath, traceInClusterRunnable(trace), ctx.canRunInCluster !== false),
453
580
  }
454
581
  }
455
582
 
@@ -588,7 +715,7 @@ export function buildSidebar(sel: Selection, ctx: Ctx): Sidebar {
588
715
  expanded: sel ? n.id === sel : i === defaultOpen,
589
716
  }
590
717
  }),
591
- context: contextGroup(ctx, byId),
718
+ context: contextGroup(ctx, byId, sel),
592
719
  }
593
720
  }
594
721
 
@@ -607,6 +734,8 @@ export function buildVerdict(
607
734
  * route-scoped badge apart from the resource-wide headline beside it. */
608
735
  chipScope?: string
609
736
  scopeLabel?: string
737
+ /** The selected vantage's name - the viewing strip renders it. */
738
+ originName?: string
610
739
  title: string
611
740
  problem?: string
612
741
  body: string
@@ -643,6 +772,7 @@ export function buildVerdict(
643
772
  .filter(Boolean)
644
773
  .join(' · ') || undefined,
645
774
  scopeLabel: opts.pathLabel || opts.originName ? 'THIS RESOURCE' : undefined,
775
+ originName: opts.originName,
646
776
  // A stale screen previously led with the old headline ("Reachable...") and
647
777
  // then said underneath that the result was excluded. That is a contradiction,
648
778
  // not an exclusion.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { routeMark, routeTone, routeChip, orderRoutes, scenariosFor, groupRoutes, vantageSignature, routeIdentity, isSlow, formatLatency, hostMatches, declaredHosts, routeHostOf, routeForOrigin, routeAsSeenFrom, originRouteEvidence, MARKS } from './reachMarks'
2
+ import { routeMark, routeTone, routeChip, orderRoutes, scenariosFor, groupRoutes, vantageSignature, routeIdentity, isSlow, formatLatency, hostMatches, declaredHosts, routeHostOf, routeForOrigin, routeAsSeenFrom, originRouteEvidence, MARKS, edgeHelp, markHelp, edgeHelpIsRedundant } from './reachMarks'
3
3
  import type { RouteResult, Hop } from './types'
4
4
 
5
5
  const r = (o: Partial<RouteResult>): RouteResult => ({ route: 'GET /', outcome: 'verified', ...o })
@@ -665,3 +665,34 @@ describe('a preserved candidate is one gap, not two scenarios', () => {
665
665
  expect(s).toHaveLength(2)
666
666
  })
667
667
  })
668
+
669
+ describe('a structural line explains itself, not a test that never existed', () => {
670
+ it('ownership and selection never claim they were "not tested"', () => {
671
+ expect(edgeHelp('runs', 'config')).toContain('ownership')
672
+ expect(edgeHelp('runs', 'config')).not.toContain('not tested')
673
+ expect(edgeHelp('selects', 'config')).toContain('read from the cluster')
674
+ expect(edgeHelp('selects', 'config')).not.toContain('not tested')
675
+ })
676
+ it('declared routing says what WOULD exercise it', () => {
677
+ for (const l of ['routes to', 'sends to']) {
678
+ expect(edgeHelp(l, 'config')).toContain('request through this entry')
679
+ }
680
+ })
681
+ it('anything actually observed keeps the mark vocabulary', () => {
682
+ expect(edgeHelp('selects', 'proved')).toBe(markHelp('proved'))
683
+ expect(edgeHelp('routes to', 'failed')).toBe(markHelp('failed'))
684
+ })
685
+ })
686
+
687
+ describe('the rest of the edge vocabulary', () => {
688
+ it('a collapsed sibling and an off-path backend say why, not "not tested"', () => {
689
+ expect(edgeHelp('also serves', 'config')).toContain('collapsed')
690
+ expect(edgeHelp('also serves', 'config')).not.toContain('not tested')
691
+ expect(edgeHelp('other host', 'excluded')).toContain('different host')
692
+ })
693
+ it("a line whose own label already says it does not say it twice", () => {
694
+ // origin edges are labelled with their evidence
695
+ expect(edgeHelpIsRedundant('not tested', '', 'untested')).toBe(true)
696
+ expect(edgeHelpIsRedundant('HTTP 404 · reached', '', 'proxied')).toBe(false)
697
+ })
698
+ })
@@ -107,6 +107,50 @@ export function markHelp(m: Mark): string {
107
107
  return MARK_LEGEND.find((l) => l.mark === m)?.text ?? m
108
108
  }
109
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
+
110
154
  /** Inline style for a mark glyph. */
111
155
  export function glyphStyle(m: Mark): React.CSSProperties {
112
156
  return { color: markStyle(m).color, fontWeight: 700, fontSize: '12px', lineHeight: 1.1, flex: 'none' }
@@ -193,6 +193,18 @@ export function originSkipReason(trace: Trace | undefined, originId: OriginId, r
193
193
  return pick(allProbes(trace))
194
194
  }
195
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
+
196
208
  /**
197
209
  * Builds the origin rail for a trace.
198
210
  *
@@ -254,6 +254,25 @@ export interface Trace {
254
254
  * walking the hop chain. Absent when there is nothing to diagnose (every
255
255
  * route verified over real traffic). */
256
256
  diagnosis?: Diagnosis
257
+ /** Faults on the DECLARED ENTRY POINTS (an Ingress or route that will never
258
+ * receive traffic). Deliberately separate from `diagnosis`: entries are
259
+ * parallel, so a dead front door must not condemn a Service that another
260
+ * entry still serves - but a verdict scoped to the tested path cannot see it
261
+ * either. Vantage-invariant; deduped against `diagnosis` by the producer. */
262
+ entryProblems?: EntryProblem[]
263
+ }
264
+
265
+ /** One declared entry point that cannot carry traffic, mirrors
266
+ * internal/trace/trace.go EntryProblem. */
267
+ export interface EntryProblem {
268
+ resource: ResourceRef
269
+ /** The human line; `detail` carries the raw controller cause for the hover. */
270
+ summary: string
271
+ detail?: string
272
+ severity: string
273
+ code?: string
274
+ action?: string
275
+ command?: string
257
276
  }
258
277
 
259
278
  /** Hoisted lead diagnosis, mirrors internal/trace/coverage.go Diagnosis. Every
@@ -262,6 +281,12 @@ export interface Trace {
262
281
  * fingerprints (missing-ref / svc:*); a pod-state code is omitted and summary
263
282
  * carries the honest prose instead. */
264
283
  export interface Diagnosis {
284
+ /** "fault" (something wrong, promoted from a finding) or "coverage" (a
285
+ * statement about what could be tested). The problem list renders faults
286
+ * only - a coverage sentence there restates the headline. */
287
+ class?: string
288
+ /** Severity of the finding this was promoted from. */
289
+ severity?: string
265
290
  causeCode?: string
266
291
  /** Which route this diagnosis explains, when attributable to exactly one.
267
292
  * Absent means it describes the resource as a whole. The selected-path panel
@@ -106,6 +106,25 @@ const KIND: Record<string, string> = {
106
106
  CiliumClusterwideNetworkPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
107
107
  ClusterNetworkPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
108
108
 
109
+ // Kyverno. Policy kinds share the indigo family used by the other
110
+ // policy-shaped kinds above; the destructive ones (Deleting/Cleanup) and
111
+ // PolicyException take amber so they read as "changes or bypasses things".
112
+ Policy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
113
+ ClusterPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
114
+ ValidatingPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
115
+ NamespacedValidatingPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
116
+ ImageValidatingPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
117
+ NamespacedImageValidatingPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
118
+ MutatingPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
119
+ NamespacedMutatingPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
120
+ GeneratingPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
121
+ NamespacedGeneratingPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
122
+ DeletingPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
123
+ NamespacedDeletingPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
124
+ PolicyException: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
125
+ CleanupPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
126
+ ClusterCleanupPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
127
+
109
128
  // Cluster-scoped
110
129
  Node: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
111
130
  Namespace: 'bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-950/50 dark:text-gray-400 dark:border-gray-700/40',
@@ -144,6 +163,14 @@ const KIND: Record<string, string> = {
144
163
  // Contour
145
164
  HTTPProxy: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
146
165
 
166
+ // CloudNativePG. Only Pooler is keyed here. getKindColorClass takes the kind
167
+ // string alone, and CNPG's other kinds arrive as bare `Cluster` / `Backup` /
168
+ // `ScheduledBackup`, which collide with CAPI and Velero — keying those would
169
+ // paint another operator's resource in Postgres colors, the exact collision
170
+ // class this integration fixes elsewhere. Coloring them needs the API group
171
+ // threaded through Badge; until then they take the default.
172
+ Pooler: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
173
+
147
174
  // Cluster API
148
175
  CAPICluster: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
149
176
  MachineDeployment: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
@@ -154,6 +181,15 @@ const KIND: Record<string, string> = {
154
181
  ClusterClass: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
155
182
  MachineHealthCheck: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
156
183
 
184
+ // Velero. Only kinds whose names no other operator claims are mapped: this
185
+ // table is keyed on kind alone with no group awareness, so a shared name would
186
+ // paint a foreign resource in Velero's colour. That rules out `Backup` (CNPG),
187
+ // `Restore` (rancher/backup-restore-operator) and `Schedule` (several
188
+ // operators) — the same collisions the renderers guard on velero.io.
189
+ BackupStorageLocation: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
190
+ VolumeSnapshotLocation: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
191
+ BackupRepository: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
192
+
157
193
  // Events
158
194
  Event: 'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-950/50 dark:text-slate-400 dark:border-slate-700/40',
159
195
  }
@@ -1,5 +1,5 @@
1
1
  import { useState, useMemo } from 'react'
2
- import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
2
+ import { AlertTriangle, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
3
3
  import { ConfirmDialog } from './ConfirmDialog'
4
4
  import { pluralize } from '../../utils/pluralize'
5
5
 
@@ -20,6 +20,7 @@ interface ForceDeleteConfirmDialogProps {
20
20
  isLoading: boolean
21
21
  cascadeDependents?: CascadeDependent[]
22
22
  cascadeLoading?: boolean
23
+ cascadeRootResolved?: boolean
23
24
  }
24
25
 
25
26
  export function ForceDeleteConfirmDialog({
@@ -32,6 +33,7 @@ export function ForceDeleteConfirmDialog({
32
33
  isLoading,
33
34
  cascadeDependents,
34
35
  cascadeLoading,
36
+ cascadeRootResolved,
35
37
  }: ForceDeleteConfirmDialogProps) {
36
38
  const [forceDelete, setForceDelete] = useState(false)
37
39
 
@@ -68,6 +70,15 @@ export function ForceDeleteConfirmDialog({
68
70
  <CascadeDependentsList dependents={cascadeDependents} />
69
71
  )}
70
72
 
73
+ {!cascadeLoading && cascadeRootResolved === false && (
74
+ <div className="flex items-start gap-2 rounded border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-warning-text">
75
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
76
+ <span>
77
+ Radar couldn&apos;t verify which dependent resources Kubernetes will also delete. Additional resources may be deleted but aren&apos;t shown here.
78
+ </span>
79
+ </div>
80
+ )}
81
+
71
82
  <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
72
83
  <input
73
84
  type="checkbox"