@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
@@ -6,10 +6,10 @@ import { PaneLoader } from '../ui/PaneLoader'
6
6
  import { ReachabilityGraph, MarkGlyph } from './ReachabilityGraph'
7
7
  import { Activity, ChevronRight } from 'lucide-react'
8
8
  import { Tooltip } from '../ui/Tooltip'
9
- import { buildGraph } from './reachGraphModel'
9
+ import { buildGraph, noteHeadline } from './reachGraphModel'
10
10
  import { buildOrigins, defaultOrigin, probeCheckStats, type Origin, type OriginId } from './reachOrigins'
11
11
  import { buildSidebar, buildVerdict, type Sidebar, type HopReport, type InspectorCTA, type Selection } from './reachInspector'
12
- import { markStyle, glyphStyle, markHelp, scenariosFor, routeTone, routeChip, routeIdentity, traceInClusterRunnable, SEV_COLOR, SEV_BADGE, type Scenario } from './reachMarks'
12
+ import { markStyle, glyphStyle, markHelp, scenariosFor, routeTone, routeChip, routeIdentity, routeForOrigin, traceInClusterRunnable, SEV_COLOR, SEV_BADGE, type Scenario } from './reachMarks'
13
13
  import { evidenceBannerTitle } from './inClusterSummary'
14
14
  import { DEV_STATES, devTrace, type DevState } from './reachFixtures'
15
15
 
@@ -190,6 +190,9 @@ function ReachabilityBoard(props: BoardProps) {
190
190
  const selectedOriginRunning = running && origin?.id === 'incluster'
191
191
 
192
192
  const [selection, setSelection] = useState<Selection>(undefined)
193
+ // The entry-problem rows point at a node from outside the graph: hovering one
194
+ // rings it, so "where is it?" is answered before a click is spent.
195
+ const [hoveredNodeId, setHoveredNodeId] = useState<string | undefined>(undefined)
193
196
 
194
197
  const model = useMemo(
195
198
  () => buildGraph({ trace, route, origin, origins, stale, running }),
@@ -216,14 +219,21 @@ function ReachabilityBoard(props: BoardProps) {
216
219
  running: selectedOriginRunning,
217
220
  multiPath,
218
221
  httpPath: props.probePath,
222
+ canRunInCluster: !!onRunInCluster && inClusterAllowed !== false,
219
223
  }),
220
- [selection, trace, route, origin, origins, model, stale, selectedOriginRunning, multiPath, props.probePath],
224
+ [selection, trace, route, origin, origins, model, stale, selectedOriginRunning, multiPath, props.probePath, onRunInCluster, inClusterAllowed],
221
225
  )
222
226
  const verdict = useMemo(
223
227
  () => buildVerdict(trace, route, { stale, running: selectedOriginRunning, originId: origin?.id, originName: origin?.name, pathLabel: multiPath ? scenario?.primary.target || scenario?.label : undefined }),
224
228
  [trace, route, origins, stale, running, multiPath, scenario, origin],
225
229
  )
226
230
 
231
+ const problems = useMemo(
232
+ () => problemRows(trace, origins, new Set(model.nodes.map((n) => n.id))),
233
+ // Deliberately NOT keyed on the selection: the header is the resource.
234
+ [trace, origins, model],
235
+ )
236
+
227
237
  const onCTA = (cta: InspectorCTA) => {
228
238
  if (cta.disabledReason) return
229
239
  if (cta.action === 'run-in-cluster') onRunInCluster?.()
@@ -243,6 +253,20 @@ function ReachabilityBoard(props: BoardProps) {
243
253
  verdict={verdict}
244
254
  trace={trace}
245
255
  runNonce={runNonce}
256
+ problems={problems}
257
+ onEntryHover={setHoveredNodeId}
258
+ onEntrySelect={(id) => {
259
+ // A pointer row addresses a VANTAGE capsule; everything else a
260
+ // resource node. Selecting the capsule re-routes the whole board to
261
+ // that vantage, which is what "show me what it saw" means.
262
+ const originId = id.startsWith('origin:') ? (id.slice('origin:'.length) as OriginId) : undefined
263
+ if (originId) {
264
+ setOriginId(originId)
265
+ setSelection(undefined)
266
+ return
267
+ }
268
+ setSelection(id)
269
+ }}
246
270
  actions={
247
271
  <ReachActions
248
272
  {...props}
@@ -295,6 +319,7 @@ function ReachabilityBoard(props: BoardProps) {
295
319
  running={running}
296
320
  />
297
321
  )}
322
+ <ViewingStrip verdict={verdict} scenario={scenario} multiPath={multiPath} />
298
323
  <ReachabilityGraph
299
324
  model={model}
300
325
  onAction={(a) => {
@@ -308,6 +333,7 @@ function ReachabilityBoard(props: BoardProps) {
308
333
  onRunInCluster?.()
309
334
  }}
310
335
  selected={selection}
336
+ hovered={hoveredNodeId}
311
337
  onSelect={(id) => {
312
338
  // An origin capsule selects the VANTAGE - the rest of the graph
313
339
  // and the inspector re-route to it. Any other node selects the
@@ -457,16 +483,202 @@ function ScenarioPicker({
457
483
 
458
484
  const LAYER_ORDER = ['dns', 'tcp', 'tls', 'http']
459
485
 
486
+ /**
487
+ * One row in the header's problem list.
488
+ *
489
+ * The list is deliberately VANTAGE-INVARIANT: everything in it is true no
490
+ * matter which capsule is selected, so a fault can never hide behind a click.
491
+ * Faults read off cluster state (a dead entry, a promoted finding) say what is
492
+ * wrong; a pointer row says another vantage saw something and takes you there.
493
+ * What a vantage saw stays on the board and in the inspector.
494
+ */
495
+ interface ProblemRow {
496
+ key: string
497
+ scope: string
498
+ subject: string
499
+ text: string
500
+ detail?: string
501
+ /** Graph node to ring on hover and select on click - a resource node, or an
502
+ * origin capsule (`origin:<id>`) for a pointer row. */
503
+ nodeId: string
504
+ severity: 'critical' | 'warning'
505
+ }
506
+
507
+ const SEV_RANK: Record<ProblemRow['severity'], number> = { critical: 0, warning: 1 }
508
+
509
+ /** Merges every vantage-invariant problem into ONE ranked list: the promoted
510
+ * diagnosis, the declared entries that cannot carry traffic, and a pointer to
511
+ * anything only another vantage saw. Two separate bands read as two separate
512
+ * problems when they were often the same one. */
513
+ export function problemRows(
514
+ trace: Trace,
515
+ origins: Origin[],
516
+ knownNodeIds: ReadonlySet<string>,
517
+ ): ProblemRow[] {
518
+ const rows: ProblemRow[] = []
519
+ const seen = new Set<string>()
520
+ const push = (r: ProblemRow) => {
521
+ const k = `${r.subject}\u0000${r.text}`
522
+ if (seen.has(k)) return
523
+ // Never address a node the graph does not draw: a Pod culprit resolves to
524
+ // the aggregate Pods node it lives in, and anything still unknown drops its
525
+ // pointer rather than offering a hover that rings nothing and a click that
526
+ // selects a section no inspector can resolve.
527
+ if (r.nodeId && !knownNodeIds.has(r.nodeId)) {
528
+ const pods = [...knownNodeIds].find((id) => id.startsWith('n:Pods/'))
529
+ if (r.nodeId.startsWith('n:Pod/') && pods) r = { ...r, nodeId: pods }
530
+ else r = { ...r, nodeId: '' }
531
+ }
532
+ seen.add(k)
533
+ // The caller's key names the row FAMILY and is not unique on its own - one
534
+ // entry can carry several findings, and one origin several failed routes.
535
+ // `k` is what `seen` just proved unique, so pairing them is what makes the
536
+ // React key safe rather than each caller having to get it right.
537
+ rows.push({ ...r, key: `${r.key}${k}` })
538
+ }
539
+ const refNode = (r?: ResourceRef) => (r ? `n:${r.kind}/${r.namespace ?? ''}/${r.name || 'pods'}` : '')
540
+
541
+ // The promoted fault. A COVERAGE-class diagnosis is not a fault - it restates
542
+ // the headline, which is generated from the same coverage state.
543
+ const d = trace.diagnosis
544
+ if (d?.summary && d.class !== 'coverage') {
545
+ const ref = d.culpritResource
546
+ push({
547
+ key: 'diagnosis',
548
+ scope: ref ? ref.kind.toUpperCase() : 'PATH',
549
+ subject: ref ? `${ref.kind} ${ref.name}` : trace.subject.name,
550
+ text: noteHeadline(d.summary),
551
+ detail: [d.summary, d.nextAction].filter(Boolean).join(' — '),
552
+ nodeId: refNode(ref) || refNode(trace.subject),
553
+ // The detector's own severity. Assuming critical turned every warning-tier
554
+ // prediction into a red it had not earned.
555
+ severity: d.severity === 'critical' ? 'critical' : 'warning',
556
+ })
557
+ }
558
+
559
+ for (const p of trace.entryProblems ?? []) {
560
+ push({
561
+ key: `entry-${p.resource.kind}-${p.resource.name}`,
562
+ scope: 'ENTRY',
563
+ subject: `${p.resource.kind} ${p.resource.name}`,
564
+ text: noteHeadline(p.summary),
565
+ detail: [p.summary, p.detail, p.action].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' — '),
566
+ nodeId: refNode(p.resource),
567
+ severity: p.severity === 'critical' ? 'critical' : 'warning',
568
+ })
569
+ }
570
+
571
+ // A fault some vantage hit, on ANY declared path. Deliberately independent of
572
+ // what is selected: the header is the resource, so a row that appears or
573
+ // vanishes as you click capsules would hide exactly the fault the reader has
574
+ // not thought to look for.
575
+ for (const o of origins) {
576
+ if (o.unsupported) continue
577
+ for (const r of trace.routes ?? []) {
578
+ const own = routeForOrigin(r, o.id, trace.runVantage)
579
+ if (!own || (own.outcome !== 'unreachable' && own.outcome !== 'server-error')) continue
580
+ push({
581
+ key: `vantage-${o.id}-${r.route}`,
582
+ scope: 'SEEN FROM',
583
+ subject: o.name,
584
+ text: own.evidence ? noteHeadline(own.evidence) : `could not get through to ${r.target || r.route}`,
585
+ detail: [own.evidence, `path: ${r.route}`].filter(Boolean).join(' — '),
586
+ nodeId: `origin:${o.id}`,
587
+ severity: 'warning',
588
+ })
589
+ }
590
+ }
591
+
592
+ return rows.sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity])
593
+ }
594
+
595
+ /** The header's problem list. Hover names the fault in full and rings it on the
596
+ * board; click focuses it there. */
597
+ /**
598
+ * What you are currently looking at, stated ON the board that changes. Keeping
599
+ * it here rather than in the header is the whole redesign: the header is the
600
+ * resource and never moves, this strip is the selection and always does, and
601
+ * the spatial split says so without anyone having to read a label.
602
+ */
603
+ function ViewingStrip({ verdict, scenario, multiPath }: { verdict: ReturnType<typeof buildVerdict>; scenario?: Scenario; multiPath: boolean }) {
604
+ const path = multiPath ? scenario?.primary.target || scenario?.label : undefined
605
+ return (
606
+ <div className="pointer-events-none absolute left-3 top-2 z-10 flex max-w-[50%] flex-wrap items-center gap-x-2 gap-y-1">
607
+ <span className="text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">VIEWING</span>
608
+ {path && <span className="truncate font-mono text-[11px] font-semibold text-theme-text-secondary">{path}</span>}
609
+ {path && <span className="text-theme-border">·</span>}
610
+ <span className="truncate font-mono text-[11px] font-semibold text-theme-text-secondary">{verdict.originName ?? 'this resource'}</span>
611
+ <span className={`badge-sm whitespace-nowrap ${SEV_BADGE[verdict.tone]}`}>{verdict.chipText}</span>
612
+ </div>
613
+ )
614
+ }
615
+
616
+ function ProblemList({
617
+ rows,
618
+ onHover,
619
+ onSelect,
620
+ }: {
621
+ rows: ProblemRow[]
622
+ onHover?: (nodeId?: string) => void
623
+ onSelect?: (nodeId: string) => void
624
+ }) {
625
+ if (rows.length === 0) return null
626
+ const shown = rows.slice(0, 3)
627
+ const rest = rows.length - shown.length
628
+ return (
629
+ <div className="mt-1.5 flex flex-col gap-1">
630
+ {shown.map((p) => (
631
+ <Tooltip key={p.key} content={p.detail || p.text} wrapperClassName="block">
632
+ <button
633
+ type="button"
634
+ onMouseEnter={() => onHover?.(p.nodeId)}
635
+ onMouseLeave={() => onHover?.(undefined)}
636
+ onFocus={() => onHover?.(p.nodeId)}
637
+ onBlur={() => onHover?.(undefined)}
638
+ onClick={() => onSelect?.(p.nodeId)}
639
+ aria-label={`${p.subject} — ${p.text}. Show it on the path.`}
640
+ className="flex w-full max-w-[92ch] cursor-pointer items-start gap-1.5 rounded-md px-2 py-1.5 text-left text-xs leading-relaxed text-pretty transition-colors hover:bg-theme-hover"
641
+ style={{
642
+ border: `1px solid var(${p.severity === 'critical' ? '--color-error' : '--color-warning'})`,
643
+ background: `color-mix(in srgb, var(${p.severity === 'critical' ? '--color-error' : '--color-warning'}) 10%, transparent)`,
644
+ }}
645
+ >
646
+ <span className="shrink-0" style={{ color: `var(${p.severity === 'critical' ? '--color-error-dark' : '--color-warning-dark'})` }}>▲</span>
647
+ <span className="min-w-0 flex-1 text-theme-text-primary">
648
+ <span className="text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">{p.scope} </span>
649
+ <span className="font-mono font-semibold">{p.subject}</span> — <span className="line-clamp-2">{p.text}</span>
650
+ </span>
651
+ </button>
652
+ </Tooltip>
653
+ ))}
654
+ {rest > 0 && <div className="text-[11px] text-theme-text-tertiary">+{rest} more problem{rest > 1 ? 's' : ''} — see the path below</div>}
655
+ </div>
656
+ )
657
+ }
658
+
659
+ /**
660
+ * The header. Everything in it describes THE RESOURCE and never changes when a
661
+ * vantage or path is selected - which is the whole point: the reader can learn
662
+ * once that this block is static and the board below is what moves. Anything
663
+ * scoped to the current selection lives on the board (the viewing strip and the
664
+ * capsules) and in the inspector.
665
+ */
460
666
  function VerdictBand({
461
667
  verdict,
462
668
  trace,
463
669
  actions,
464
670
  runNonce,
671
+ problems,
672
+ onEntryHover,
673
+ onEntrySelect,
465
674
  }: {
466
675
  verdict: ReturnType<typeof buildVerdict>
467
676
  trace: Trace
468
677
  actions: React.ReactNode
469
678
  runNonce?: number
679
+ problems: ProblemRow[]
680
+ onEntryHover?: (nodeId?: string) => void
681
+ onEntrySelect?: (nodeId: string) => void
470
682
  }) {
471
683
  // The check volume lives HERE, not only in the footer ledger: the band is
472
684
  // the one line everyone reads, and a verdict with no visible work behind it
@@ -489,15 +701,9 @@ function VerdictBand({
489
701
  )}
490
702
  <div className="flex flex-wrap items-baseline gap-2">
491
703
  <span className="text-[14.5px] font-semibold text-theme-text-primary">{verdict.title}</span>
492
- {!verdict.chipScope && <span className={`badge-sm whitespace-nowrap ${SEV_BADGE[verdict.tone]}`}>{verdict.chipText}</span>}
493
704
  <JustTestedNote nonce={runNonce} />
494
705
  </div>
495
- {verdict.chipScope && (
496
- <div className="mt-1 flex flex-wrap items-center gap-2">
497
- <span className="text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary">{verdict.chipScope.toUpperCase()}</span>
498
- <span className={`badge-sm whitespace-nowrap ${SEV_BADGE[verdict.tone]}`}>{verdict.chipText}</span>
499
- </div>
500
- )}
706
+
501
707
  {stats.checks > 0 && (
502
708
  <Tooltip
503
709
  content={`${layerBreakdown}${stats.vantages > 1 ? ` — from ${stats.vantages} vantages` : ''}. Every check is a real dial; skipped ones are listed with their reasons, never counted. Each check waits up to ~1s, inside a 3s budget per run — a slow backend can time out and read as not tested.`}
@@ -514,16 +720,9 @@ function VerdictBand({
514
720
  </span>
515
721
  </Tooltip>
516
722
  )}
517
- {verdict.problem && (
518
- <div
519
- className="mt-1.5 flex max-w-[92ch] items-start gap-1.5 rounded-md px-2 py-1.5 text-xs leading-relaxed text-pretty"
520
- style={{ border: '1px solid var(--color-warning)', background: 'color-mix(in srgb, var(--color-warning) 10%, transparent)' }}
521
- >
522
- <span className="shrink-0" style={{ color: 'var(--color-warning-dark)' }}>▲</span>
523
- <span className="text-theme-text-primary">{verdict.problem}</span>
524
- </div>
525
- )}
723
+
526
724
  {verdict.body && <div className="mt-1 max-w-[76ch] text-xs leading-relaxed text-theme-text-secondary text-pretty">{verdict.body}</div>}
725
+ <ProblemList rows={problems} onHover={onEntryHover} onSelect={onEntrySelect} />
527
726
  </div>
528
727
  <div className="flex flex-none gap-2">{actions}</div>
529
728
  </div>
@@ -681,6 +880,41 @@ function InspectorPanel({
681
880
  </Disclosure>
682
881
  )}
683
882
 
883
+ {/* The action sits with the sentence that explains why it is offered. As
884
+ its own block at the very bottom it repeated that sentence, and put
885
+ the button a scroll away from the reason for it. */}
886
+ {(path.next.body || path.next.ctas.length > 0) && (
887
+ <div className={path.next.header ? 'rounded-md px-2.5 py-2' : ''} style={path.next.header ? { border: '1px solid var(--accent)', background: 'var(--accent-muted)' } : undefined}>
888
+ {path.next.header && (
889
+ <div className="mb-1 text-[9.5px] font-bold tracking-[0.05em]" style={{ color: 'var(--accent-text)' }}>
890
+ {path.next.header}
891
+ </div>
892
+ )}
893
+ {path.next.body && <div className="text-[11.5px] leading-snug text-theme-text-secondary text-pretty">{path.next.body}</div>}
894
+ {path.next.blocked && <div className="mt-1.5 text-[10.5px] leading-snug text-theme-text-tertiary">{path.next.blocked}</div>}
895
+ {path.next.ctas.length > 0 && (
896
+ <div className={`flex flex-wrap gap-1.5 ${path.next.body || path.next.header ? 'mt-2' : 'mt-1'}`}>
897
+ {path.next.ctas.map((c, i) => (
898
+ <Tooltip key={i} content={c.disabledReason ?? ''} wrapperClassName="cursor-help">
899
+ <button
900
+ type="button"
901
+ onClick={() => onCTA(c)}
902
+ disabled={!!c.disabledReason}
903
+ className={
904
+ c.primary && !c.disabledReason
905
+ ? 'btn-brand cursor-pointer whitespace-nowrap rounded-md px-2.5 py-1.5 text-[11px] font-semibold'
906
+ : 'cursor-pointer whitespace-nowrap rounded-md border border-theme-border bg-theme-base px-2.5 py-1.5 text-[11px] font-semibold text-theme-text-secondary disabled:cursor-not-allowed disabled:opacity-60'
907
+ }
908
+ >
909
+ {c.text}
910
+ </button>
911
+ </Tooltip>
912
+ ))}
913
+ </div>
914
+ )}
915
+ </div>
916
+ )}
917
+
684
918
  {path.evidence.length > 0 && (
685
919
  <div>
686
920
  {/* Same count idiom as TEST DETAILS: the number of distinct
@@ -722,39 +956,6 @@ function InspectorPanel({
722
956
  )}
723
957
  <Caveats items={path.notProve} />
724
958
 
725
- {/* The terminal "nothing to do" state renders as one quiet line - the
726
- callout styling is reserved for states that carry an actual next move. */}
727
- <div
728
- className={path.next.quiet ? 'border-t border-theme-border pt-2.5' : 'rounded-md px-2.5 py-2.5'}
729
- style={path.next.quiet ? undefined : { border: '1px solid var(--accent)', background: 'var(--accent-muted)' }}
730
- >
731
- <div className="text-[9.5px] font-bold tracking-[0.05em]" style={{ color: path.next.quiet ? 'var(--text-tertiary)' : 'var(--accent-text)' }}>
732
- {path.next.header}
733
- </div>
734
- <div className="mt-1 text-[11.5px] leading-snug text-theme-text-secondary text-pretty">{path.next.body}</div>
735
- {path.next.blocked && <div className="mt-1.5 text-[10.5px] leading-snug text-theme-text-tertiary">{path.next.blocked}</div>}
736
- {path.next.ctas.length > 0 && (
737
- <div className="mt-2 flex flex-wrap gap-1.5">
738
- {path.next.ctas.map((c, i) => (
739
- <Tooltip key={i} content={c.disabledReason ?? ''} wrapperClassName="cursor-help">
740
- <button
741
- type="button"
742
- onClick={() => onCTA(c)}
743
- disabled={!!c.disabledReason}
744
- className={
745
- c.primary && !c.disabledReason
746
- ? 'btn-brand cursor-pointer whitespace-nowrap rounded-md px-2.5 py-1.5 text-[11px] font-semibold'
747
- : 'cursor-pointer whitespace-nowrap rounded-md border border-theme-border bg-theme-base px-2.5 py-1.5 text-[11px] font-semibold text-theme-text-secondary disabled:cursor-not-allowed disabled:opacity-60'
748
- }
749
- >
750
- {c.text}
751
- </button>
752
- </Tooltip>
753
- ))}
754
- </div>
755
- )}
756
- </div>
757
-
758
959
  </div>
759
960
  )
760
961
  }
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { problemRows } from './ReachabilityView'
3
+ import { buildOrigins } from './reachOrigins'
4
+ import { buildGraph } from './reachGraphModel'
5
+ import type { Trace, RouteResult } from './types'
6
+
7
+ const base = (): Trace => ({
8
+ subject: { kind: 'Service', name: 'shop', namespace: 'store' },
9
+ verdict: 'degraded', brokenAt: -1,
10
+ upstreams: [{ resource: { kind: 'HTTPRoute', name: 'shop', namespace: 'store' }, edge: 'httproute->service', findings: [] }],
11
+ downstream: [
12
+ { resource: { kind: 'Service', name: 'shop', namespace: 'store' }, edge: 'service', findings: [] },
13
+ { resource: { kind: 'Pods', name: '', namespace: 'store' }, edge: 'service->pods', findings: [], config: { pods: [{ name: 'shop-1', ready: true, ip: '10.0.0.1' }], podTotal: 1 } },
14
+ ],
15
+ routes: [],
16
+ } as never)
17
+
18
+ /** The ids the graph actually draws, which is what a header row may address. */
19
+ const nodesOf = (t: Trace, r?: RouteResult) => {
20
+ const origins = buildOrigins(t)
21
+ // Pass every origin, as the app does - the capsules ARE nodes a header row
22
+ // may address.
23
+ const g = buildGraph({ trace: t, route: r ?? ({ route: 'shop', target: 'shop:80', outcome: 'reached' } as never), origin: origins.find((o) => o.id === 'apiserver')!, origins })
24
+ return new Set(g.nodes.map((n) => n.id))
25
+ }
26
+
27
+ describe('the header problem list', () => {
28
+ it('drops a coverage-class diagnosis - the headline already says it', () => {
29
+ const t = base()
30
+ t.diagnosis = { class: 'coverage', summary: 'reachable via API server - the real-traffic path was not confirmed from here' } as never
31
+ expect(problemRows(t, buildOrigins(t), nodesOf(t))).toHaveLength(0)
32
+ })
33
+
34
+ it('renders the severity the detector assigned, never inventing critical', () => {
35
+ const t = base()
36
+ t.diagnosis = { summary: 'a network rule would block traffic', severity: 'warning' } as never
37
+ expect(problemRows(t, buildOrigins(t), nodesOf(t))[0].severity).toBe('warning')
38
+ t.diagnosis = { summary: 'container is crashlooping', severity: 'critical' } as never
39
+ expect(problemRows(t, buildOrigins(t), nodesOf(t))[0].severity).toBe('critical')
40
+ })
41
+
42
+ it('ranks critical above warning', () => {
43
+ const t = base()
44
+ t.diagnosis = { summary: 'container is crashlooping', severity: 'critical' } as never
45
+ t.entryProblems = [{ resource: { kind: 'HTTPRoute', name: 'shop', namespace: 'store' }, summary: 'Not attached: no listener matches its hosts', severity: 'warning' }] as never
46
+ expect(problemRows(t, buildOrigins(t), nodesOf(t)).map((r) => r.severity)).toEqual(['critical', 'warning'])
47
+ })
48
+
49
+ it('is INDEPENDENT of what is selected - the header is the resource', () => {
50
+ const t = base()
51
+ const r: RouteResult = {
52
+ route: 'shop', target: 'shop:80', outcome: 'reached', confidence: 'indirect',
53
+ byVantage: [{ vantage: 'in-cluster', path: 'data', source: 'probe-job', outcome: 'unreachable', confidence: 'real', evidence: 'connection refused' }],
54
+ } as never
55
+ t.routes = [r]
56
+ const rows = problemRows(t, buildOrigins(t), nodesOf(t, r))
57
+ const ptr = rows.find((x) => x.scope === 'SEEN FROM')
58
+ expect(ptr, 'a fault a vantage saw must be listed however the reader is browsing').toBeTruthy()
59
+ expect(ptr!.nodeId).toBe('origin:incluster')
60
+ // the same rows regardless of which vantage the reader has open
61
+ expect(problemRows(t, buildOrigins(t), nodesOf(t, r))).toEqual(rows)
62
+ })
63
+
64
+ it('never addresses a node the graph does not draw', () => {
65
+ const t = base()
66
+ // A Pod culprit: the graph aggregates Pods, so an individual Pod node does not exist.
67
+ t.diagnosis = { summary: 'container is crashlooping', severity: 'critical', culpritResource: { kind: 'Pod', name: 'shop-1', namespace: 'store' } } as never
68
+ const known = nodesOf(t)
69
+ const row = problemRows(t, buildOrigins(t), known)[0]
70
+ expect(row.nodeId === '' || known.has(row.nodeId)).toBe(true)
71
+ })
72
+
73
+ it('says nothing when nothing is wrong', () => {
74
+ const t = base()
75
+ expect(problemRows(t, buildOrigins(t), nodesOf(t))).toHaveLength(0)
76
+ })
77
+ })
78
+
79
+ // One entry can carry several findings, and one origin several failed routes,
80
+ // so a key built from the subject alone collides. React silently mis-renders or
81
+ // drops rows on duplicate keys - which would hide a fault in the list whose
82
+ // whole job is to surface faults.
83
+ describe('every row can be told apart', () => {
84
+ it('gives two findings on ONE entry distinct keys, and keeps both rows', () => {
85
+ const t = base()
86
+ const ref = { kind: 'HTTPRoute', name: 'shop', namespace: 'store' }
87
+ t.entryProblems = [
88
+ { resource: ref, summary: 'Not attached: no listener matches its hosts', severity: 'warning' },
89
+ { resource: ref, summary: 'Backend service port 8080 does not exist', severity: 'critical' },
90
+ ] as never
91
+ const rows = problemRows(t, buildOrigins(t), nodesOf(t))
92
+ expect(rows).toHaveLength(2)
93
+ expect(new Set(rows.map((r) => r.key)).size).toBe(2)
94
+ })
95
+
96
+ it('keeps keys unique across the whole list, whatever the mix', () => {
97
+ const t = base()
98
+ t.diagnosis = { summary: 'container is crashlooping', severity: 'critical' } as never
99
+ t.entryProblems = [
100
+ { resource: { kind: 'HTTPRoute', name: 'shop', namespace: 'store' }, summary: 'Not attached', severity: 'warning' },
101
+ { resource: { kind: 'HTTPRoute', name: 'shop', namespace: 'store' }, summary: 'Backend port missing', severity: 'warning' },
102
+ ] as never
103
+ const rows = problemRows(t, buildOrigins(t), nodesOf(t))
104
+ expect(rows.length).toBeGreaterThan(1)
105
+ expect(new Set(rows.map((r) => r.key)).size).toBe(rows.length)
106
+ })
107
+ })
@@ -1405,3 +1405,19 @@ describe('an all-skipped origin never claims a network verdict', () => {
1405
1405
  expect(originNoEvidenceLabel({ id: 'incluster', mark: 'blocked', unavailable: 'image pull failed' } as never)).toBe('test couldn’t run')
1406
1406
  })
1407
1407
  })
1408
+
1409
+ describe('entry-problem rows can address a graph node', () => {
1410
+ it('the node id an EntryProblem resolves to matches the graph upstream node id', () => {
1411
+ const t = {
1412
+ subject: { kind: 'Service', name: 'shop', namespace: 'store' },
1413
+ verdict: 'degraded', brokenAt: -1,
1414
+ upstreams: [{ resource: { kind: 'HTTPRoute', name: 'shop', namespace: 'store' }, edge: 'httproute->service', findings: [] }],
1415
+ downstream: [{ resource: { kind: 'Service', name: 'shop', namespace: 'store' }, edge: 'service', findings: [] }],
1416
+ routes: [{ route: 'shop', target: 'shop:80', outcome: 'reached', confidence: 'real' }],
1417
+ } as never
1418
+ const g = buildGraph({ trace: t, route: route({ target: 'shop:80', outcome: 'reached' }), origin: buildOrigins(t).find((o) => o.id === 'apiserver')! })
1419
+ // Mirrors the id the EntryProblems row builds from EntryProblem.resource.
1420
+ const fromProblem = 'n:HTTPRoute/store/shop'
1421
+ expect(g.nodes.some((n) => n.id === fromProblem)).toBe(true)
1422
+ })
1423
+ })