@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.
- package/package.json +1 -1
- package/src/components/gitops/GitOpsStatusBadge.tsx +40 -0
- package/src/components/gitops/GitOpsTableView.tsx +22 -2
- package/src/components/gitops/ReconcilingIndicator.test.tsx +31 -0
- package/src/components/gitops/detail-helpers.ts +4 -1
- package/src/components/issues/IssuesView.tsx +9 -0
- package/src/components/issues/diagnostic.ts +3 -0
- package/src/components/issues/issues.test.ts +11 -0
- package/src/components/issues/severity.ts +3 -0
- package/src/components/issues/types.ts +3 -0
- package/src/components/resources/ResourcesSidebar.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +261 -30
- package/src/components/resources/index.ts +2 -0
- package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
- package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +4 -1
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +133 -9
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +19 -4
- package/src/components/resources/renderers/CompositeRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
- package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/EventRenderer.tsx +10 -18
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/KustomizationRenderer.tsx +4 -1
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
- package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +102 -0
- package/src/components/resources/renderers/PolicySection.tsx +170 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
- package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -1
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
- package/src/components/resources/renderers/index.ts +5 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +9 -0
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
- package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -0
- package/src/components/resources/renderers/velero-cells.tsx +128 -9
- package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +590 -1
- package/src/components/resources/resource-utils-cnpg.ts +433 -56
- package/src/components/resources/resource-utils-crossplane.test.ts +68 -0
- package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
- package/src/components/resources/resource-utils-velero.test.ts +237 -0
- package/src/components/resources/resource-utils-velero.ts +214 -22
- package/src/components/resources/resource-utils.ts +70 -3
- package/src/components/shared/ResourceActionsBar.tsx +3 -1
- package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
- package/src/components/topology/K8sResourceNode.tsx +6 -0
- package/src/components/trace/ReachabilityGraph.tsx +20 -6
- package/src/components/trace/ReachabilityView.tsx +253 -52
- package/src/components/trace/problemRows.test.ts +107 -0
- package/src/components/trace/reachGraphModel.test.ts +16 -0
- package/src/components/trace/reachInspector.test.ts +233 -10
- package/src/components/trace/reachInspector.ts +166 -36
- package/src/components/trace/reachMarks.test.ts +32 -1
- package/src/components/trace/reachMarks.ts +44 -0
- package/src/components/trace/reachOrigins.ts +12 -0
- package/src/components/trace/types.ts +25 -0
- package/src/components/ui/Badge.tsx +36 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
- package/src/theme/components.css +26 -0
- package/src/types/gitops-flux-status.test.ts +163 -0
- package/src/types/gitops.ts +80 -24
- package/src/types/index.ts +1 -0
- package/src/types/policy.ts +42 -0
- package/src/utils/api-resources.ts +2 -0
- package/src/utils/resource-icons.test.ts +33 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
+
})
|