@skyhook-io/k8s-ui 1.7.13 → 1.7.14

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.7.13",
3
+ "version": "1.7.14",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -1,3 +1,3 @@
1
1
  export { AuditCard, type AuditCardData } from './AuditCard'
2
2
  export { AuditAlerts, type AuditFinding } from './AuditAlerts'
3
- export { AuditFindingsTable, type AuditFindingsTableProps, type ResourceGroup, type CheckMeta } from './AuditFindingsTable'
3
+ export { AuditFindingsTable, type AuditFindingsTableProps, type ResourceGroup, type CheckMeta, type CheckReference } from './AuditFindingsTable'
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { ChevronDown, ChevronRight, ExternalLink, EyeOff, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
4
4
  import { ClusterName, EmptyState, FilterPill } from '../ui'
5
- import type { CheckMeta } from '../audit'
5
+ import type { CheckMeta, CheckReference } from '../audit'
6
6
  import { CHECK_SEVERITIES, CHECK_SEVERITY_RANK, type Check, type CheckSeverity, type EffectiveCheckFinding, type CheckResourceRef } from './types'
7
7
  import {
8
8
  SEVERITY_BADGE_CLASS,
@@ -255,11 +255,11 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
255
255
  </div>
256
256
  </div>
257
257
 
258
- <SeverityBar totals={totals} />
258
+ <CheckSeverityBar totals={totals} />
259
259
 
260
260
  <div className="flex flex-wrap items-center gap-1.5">
261
261
  {CHECK_SEVERITIES.map((s) => (
262
- <SeverityChip key={s} severity={s} count={totals[s]} active={severityFilter.has(s)} onClick={() => toggle(setSeverityFilter, s)} />
262
+ <CheckSeverityChip key={s} severity={s} count={totals[s]} active={severityFilter.has(s)} onClick={() => toggle(setSeverityFilter, s)} />
263
263
  ))}
264
264
  <span className="mx-1.5 h-5 w-px bg-theme-border" />
265
265
  {CATEGORIES.map((c) => (
@@ -366,7 +366,7 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
366
366
  )
367
367
  }
368
368
 
369
- function SeverityBar({ totals }: { totals: Record<CheckSeverity, number> }) {
369
+ export function CheckSeverityBar({ totals }: { totals: Record<CheckSeverity, number> }) {
370
370
  const sum = CHECK_SEVERITIES.reduce((n, s) => n + totals[s], 0)
371
371
  return (
372
372
  <div className="flex h-1.5 overflow-hidden rounded-full bg-theme-elevated" role="img" aria-label="Severity distribution">
@@ -381,7 +381,7 @@ function SeverityBar({ totals }: { totals: Record<CheckSeverity, number> }) {
381
381
  )
382
382
  }
383
383
 
384
- function SeverityChip({ severity, count, active, onClick }: { severity: CheckSeverity; count: number; active: boolean; onClick: () => void }) {
384
+ export function CheckSeverityChip({ severity, count, active, onClick }: { severity: CheckSeverity; count: number; active: boolean; onClick: () => void }) {
385
385
  return (
386
386
  <button
387
387
  type="button"
@@ -399,36 +399,117 @@ function SeverityChip({ severity, count, active, onClick }: { severity: CheckSev
399
399
  )
400
400
  }
401
401
 
402
- function FleetCheckRow({
403
- fc,
404
- meta,
405
- clusterLabel,
406
- open,
407
- onToggle,
408
- resourceHref,
409
- onResourceClick,
410
- onHideCheck,
411
- onHideCategory,
412
- }: {
413
- fc: FleetCheck
414
- meta?: CheckMeta
415
- clusterLabel?: (check: Check) => string | undefined
402
+ // Re-export the audit CheckReference (single source of truth) — CheckMeta.references
403
+ // is typed against this same shape, so the two must not drift apart.
404
+ export type { CheckReference }
405
+
406
+ export interface CheckRemediationBlockProps {
407
+ description?: string
408
+ remediation?: string
409
+ references?: CheckReference[]
410
+ layout?: 'columns' | 'stack'
411
+ }
412
+
413
+ export function CheckRemediationBlock({ description, remediation, references, layout = 'columns' }: CheckRemediationBlockProps) {
414
+ if (!description && !remediation && (!references || references.length === 0)) return null
415
+
416
+ if (layout === 'stack') {
417
+ return (
418
+ <div className="flex flex-col gap-2">
419
+ {description && <p className="text-[13px] leading-relaxed text-theme-text-secondary">{description}</p>}
420
+ {remediation && (
421
+ <div>
422
+ <div className="text-[11px] uppercase tracking-wider text-theme-text-tertiary">How to fix</div>
423
+ <p className="mt-0.5 text-[13px] leading-relaxed text-theme-text-secondary">{remediation}</p>
424
+ </div>
425
+ )}
426
+ {references && references.length > 0 && <CheckReferenceLinks references={references} />}
427
+ </div>
428
+ )
429
+ }
430
+
431
+ return (
432
+ <>
433
+ <div className="flex flex-col gap-4 md:flex-row md:gap-8">
434
+ {remediation && (
435
+ <section className="md:flex-1">
436
+ <h4 className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-[var(--color-radar-accent)]">
437
+ <Wrench className="h-3.5 w-3.5" /> How to fix
438
+ </h4>
439
+ <p className="text-sm leading-relaxed text-theme-text-primary">{remediation}</p>
440
+ </section>
441
+ )}
442
+ {description && (
443
+ <section className="md:flex-1">
444
+ <h4 className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What this checks</h4>
445
+ <p className="text-sm leading-relaxed text-theme-text-secondary">{description}</p>
446
+ </section>
447
+ )}
448
+ </div>
449
+ {references && references.length > 0 && <CheckReferenceLinks references={references} />}
450
+ </>
451
+ )
452
+ }
453
+
454
+ function CheckReferenceLinks({ references }: { references: CheckReference[] }) {
455
+ return (
456
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
457
+ {references.map((r) => (
458
+ <a
459
+ key={r.url}
460
+ href={r.url}
461
+ target="_blank"
462
+ rel="noreferrer"
463
+ className="inline-flex items-center gap-1 text-xs font-medium text-[var(--color-radar-accent)] hover:underline"
464
+ >
465
+ {r.label}
466
+ <ExternalLink className="h-3 w-3" />
467
+ </a>
468
+ ))}
469
+ </div>
470
+ )
471
+ }
472
+
473
+ export interface CheckCardShellProps {
474
+ title: ReactNode
475
+ category: string
476
+ severity: CheckSeverity
416
477
  open: boolean
417
478
  onToggle: () => void
418
- resourceHref?: (ref: CheckResourceRef) => string
419
- onResourceClick?: (ref: CheckResourceRef) => void
420
- onHideCheck?: (checkID: string, title: string) => void
421
- onHideCategory?: (category: string) => void
422
- }) {
423
- const clusterCount = fc.clusters.length
424
- const single = clusterCount <= 1
425
-
426
- const menuItems: { label: string; onClick: () => void }[] = []
427
- if (onHideCheck) menuItems.push({ label: `Hide "${fc.title}" check`, onClick: () => onHideCheck(fc.checkID, fc.title) })
428
- if (onHideCategory) menuItems.push({ label: `Hide all ${fc.category} checks`, onClick: () => onHideCategory(fc.category) })
479
+ summary?: ReactNode
480
+ description?: ReactNode
481
+ renderActions?: () => ReactNode
482
+ children?: ReactNode
483
+ as?: 'li' | 'div'
484
+ className?: string
485
+ dimmed?: boolean
486
+ }
429
487
 
488
+ export function CheckCardShell({
489
+ title,
490
+ category,
491
+ severity,
492
+ open,
493
+ onToggle,
494
+ summary,
495
+ description,
496
+ renderActions,
497
+ children,
498
+ as = 'li',
499
+ className,
500
+ dimmed,
501
+ }: CheckCardShellProps) {
502
+ const Container = as
430
503
  return (
431
- <li className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
504
+ <Container
505
+ className={[
506
+ 'overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm',
507
+ dimmed ? 'opacity-60' : '',
508
+ className ?? '',
509
+ ]
510
+ .filter(Boolean)
511
+ .join(' ')}
512
+ >
432
513
  <div
433
514
  role="button"
434
515
  tabIndex={0}
@@ -441,158 +522,100 @@ function FleetCheckRow({
441
522
  onToggle()
442
523
  }
443
524
  }}
444
- className={`group flex cursor-pointer items-center gap-3 border-l-2 py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${SEVERITY_RAIL_CLASS[fc.severity]}`}
525
+ className={`group flex cursor-pointer items-start gap-3 border-l-2 py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${SEVERITY_RAIL_CLASS[severity]}`}
445
526
  >
446
- <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
527
+ <ChevronRight className={`mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
447
528
 
448
- <div className="flex min-w-0 flex-1 flex-col gap-1">
449
- <div className="flex items-center gap-2">
450
- <span className="truncate text-sm font-medium text-theme-text-primary">{fc.title}</span>
451
- <span className={`badge-sm shrink-0 text-[10px] ${categoryBadgeClass(fc.category)}`}>{fc.category}</span>
452
- </div>
453
- <div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
454
- <span className="shrink-0 font-medium text-theme-text-secondary tabular-nums">
455
- {fc.totalResources} {fc.totalResources === 1 ? 'resource' : 'resources'}
456
- </span>
457
- {clusterCount > 1 && (
458
- <>
459
- <span aria-hidden>·</span>
460
- <span className="shrink-0 tabular-nums">{clusterCount} clusters</span>
461
- </>
462
- )}
529
+ <div className="flex min-w-0 flex-1 flex-col gap-1.5">
530
+ <div className="flex flex-wrap items-center gap-2">
531
+ <span className="truncate text-sm font-semibold text-theme-text-primary">{title}</span>
532
+ <span className={`badge-sm shrink-0 text-[10px] ${categoryBadgeClass(category)}`}>{category}</span>
463
533
  </div>
534
+ {description}
535
+ {summary}
464
536
  </div>
465
537
 
466
- <span className={`badge-sm shrink-0 text-[10px] font-semibold ${SEVERITY_BADGE_CLASS[fc.severity]}`}>{SEVERITY_LABEL[fc.severity]}</span>
467
- {menuItems.length > 0 && <RowMenu items={menuItems} />}
538
+ <span className={`badge-sm mt-0.5 shrink-0 text-[10px] font-semibold ${SEVERITY_BADGE_CLASS[severity]}`}>
539
+ {SEVERITY_LABEL[severity]}
540
+ </span>
541
+ {renderActions?.()}
468
542
  </div>
469
543
 
470
544
  {/* Kept mounted (not `open &&`) so the grid-rows transition animates the
471
- collapse too; inert when closed so SR + tab skip the clipped content. */}
545
+ collapse too, matching IssueRow; inert when closed so SR + tab skip
546
+ the clipped content. */}
472
547
  <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
473
548
  <div className="overflow-hidden" inert={!open || undefined}>
474
- <div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
475
- <div className="flex flex-col gap-4">
476
- <div className="flex flex-col gap-4 md:flex-row md:gap-8">
477
- {meta?.remediation && (
478
- <section className="md:flex-1">
479
- <h4 className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-[var(--color-radar-accent)]">
480
- <Wrench className="h-3.5 w-3.5" /> How to fix
481
- </h4>
482
- <p className="text-sm leading-relaxed text-theme-text-primary">{meta.remediation}</p>
483
- </section>
484
- )}
485
- {meta?.description && (
486
- <section className="md:flex-1">
487
- <h4 className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What this checks</h4>
488
- <p className="text-sm leading-relaxed text-theme-text-secondary">{meta.description}</p>
489
- </section>
490
- )}
491
- </div>
492
-
493
- {meta?.references && meta.references.length > 0 && (
494
- <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
495
- {meta.references.map((r) => (
496
- <a
497
- key={r.url}
498
- href={r.url}
499
- target="_blank"
500
- rel="noreferrer"
501
- className="inline-flex items-center gap-1 text-xs font-medium text-[var(--color-radar-accent)] hover:underline"
502
- >
503
- {r.label}
504
- <ExternalLink className="h-3 w-3" />
505
- </a>
506
- ))}
507
- </div>
508
- )}
509
-
510
- <div className="border-t border-theme-border/70 pt-3">
511
- {single ? (
512
- <ResourceList
513
- label={`Affected resources (${fc.totalResources})`}
514
- check={fc.clusters[0]}
515
- resourceHref={resourceHref}
516
- onResourceClick={onResourceClick}
517
- />
518
- ) : (
519
- <ClusterBreakdown fc={fc} clusterLabel={clusterLabel} resourceHref={resourceHref} onResourceClick={onResourceClick} />
520
- )}
521
- </div>
522
- </div>
523
- </div>
549
+ <div className="flex flex-col gap-4 border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">{children}</div>
524
550
  </div>
525
551
  </div>
526
- </li>
552
+ </Container>
527
553
  )
528
554
  }
529
555
 
530
- // Per-cluster sub-groups for a check that spans the fleet. Each cluster is a
531
- // collapsible row ("cluster · K resources"); open it for that cluster's
532
- // resources. Caps the cluster list so a check across hundreds of clusters
533
- // stays scannable.
534
- function ClusterBreakdown({
535
- fc,
536
- clusterLabel,
537
- resourceHref,
538
- onResourceClick,
539
- }: {
540
- fc: FleetCheck
541
- clusterLabel?: (check: Check) => string | undefined
542
- resourceHref?: (ref: CheckResourceRef) => string
543
- onResourceClick?: (ref: CheckResourceRef) => void
544
- }) {
556
+ export interface CheckClusterBreakdownGroup {
557
+ id: string
558
+ label: ReactNode
559
+ environment?: string
560
+ count: ReactNode
561
+ }
562
+
563
+ export interface CheckClusterBreakdownShellProps<T extends CheckClusterBreakdownGroup> {
564
+ heading: ReactNode
565
+ groups: T[]
566
+ renderGroupBody: (group: T) => ReactNode
567
+ clusterCap?: number
568
+ autoExpandLimit?: number
569
+ }
570
+
571
+ export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>({
572
+ heading,
573
+ groups,
574
+ renderGroupBody,
575
+ clusterCap = CLUSTER_CAP,
576
+ autoExpandLimit = AUTO_EXPAND_CLUSTERS,
577
+ }: CheckClusterBreakdownShellProps<T>) {
545
578
  const [openClusters, setOpenClusters] = useState<Set<string>>(
546
- () => new Set(fc.clusters.length <= AUTO_EXPAND_CLUSTERS ? fc.clusters.map((c) => c.subject.cluster_id) : []),
579
+ () => new Set(groups.length <= autoExpandLimit ? groups.map((g) => g.id) : []),
547
580
  )
548
581
  const [showAllClusters, setShowAllClusters] = useState(false)
549
- const shown = showAllClusters ? fc.clusters : fc.clusters.slice(0, CLUSTER_CAP)
550
- const hiddenClusters = fc.clusters.length - shown.length
582
+ const shown = showAllClusters ? groups : groups.slice(0, clusterCap)
583
+ const hiddenClusters = groups.length - shown.length
551
584
 
552
585
  return (
553
586
  <section className="flex flex-col gap-1.5">
554
- <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
555
- Affected resources <span className="tabular-nums">({fc.totalResources})</span> · {fc.clusters.length} clusters
556
- </h4>
587
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{heading}</h4>
557
588
  <ul className="flex flex-col gap-1">
558
- {shown.map((c) => {
559
- const id = c.subject.cluster_id
560
- const isOpen = openClusters.has(id)
561
- const env = c.environment
589
+ {shown.map((group) => {
590
+ const isOpen = openClusters.has(group.id)
562
591
  return (
563
- <li key={id} className="rounded-lg border border-theme-border/70 bg-theme-surface">
592
+ <li key={group.id} className="rounded-lg border border-theme-border/70 bg-theme-surface">
564
593
  <button
565
594
  type="button"
566
595
  aria-expanded={isOpen}
567
596
  onClick={() =>
568
597
  setOpenClusters((prev) => {
569
598
  const next = new Set(prev)
570
- if (next.has(id)) next.delete(id)
571
- else next.add(id)
599
+ if (next.has(group.id)) next.delete(group.id)
600
+ else next.add(group.id)
572
601
  return next
573
602
  })
574
603
  }
575
604
  className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-theme-hover/50"
576
605
  >
577
606
  <ChevronDown className={`h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${isOpen ? '' : '-rotate-90'}`} />
578
- <span className="min-w-0 max-w-[260px] truncate text-sm font-medium text-theme-text-primary">
579
- <ClusterName name={clusterLabel?.(c) || id} />
580
- </span>
581
- {env && (
607
+ <span className="min-w-0 max-w-[260px] truncate text-sm font-medium text-theme-text-primary">{group.label}</span>
608
+ {group.environment && (
582
609
  <span className="shrink-0 rounded bg-theme-elevated px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-secondary ring-1 ring-theme-border">
583
- {env}
610
+ {group.environment}
584
611
  </span>
585
612
  )}
586
613
  <span className="flex-1" />
587
- <span className="shrink-0 text-xs tabular-nums text-theme-text-secondary">
588
- {c.affectedResources} {c.affectedResources === 1 ? 'resource' : 'resources'}
589
- </span>
614
+ <span className="shrink-0 text-xs tabular-nums text-theme-text-secondary">{group.count}</span>
590
615
  </button>
591
616
  <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: isOpen ? '1fr' : '0fr' }}>
592
617
  <div className="overflow-hidden" inert={!isOpen || undefined}>
593
- <div className="px-2.5 pb-2 pl-7">
594
- <ResourceList check={c} resourceHref={resourceHref} onResourceClick={onResourceClick} />
595
- </div>
618
+ <div className="px-2.5 pb-2 pl-7">{renderGroupBody(group)}</div>
596
619
  </div>
597
620
  </div>
598
621
  </li>
@@ -605,13 +628,117 @@ function ClusterBreakdown({
605
628
  onClick={() => setShowAllClusters(true)}
606
629
  className="mt-0.5 inline-flex w-fit items-center gap-1 rounded px-2 py-1 text-xs font-medium text-[var(--color-radar-accent)] hover:underline"
607
630
  >
608
- View all {fc.clusters.length} clusters →
631
+ View all {groups.length} clusters →
609
632
  </button>
610
633
  )}
611
634
  </section>
612
635
  )
613
636
  }
614
637
 
638
+ function FleetCheckRow({
639
+ fc,
640
+ meta,
641
+ clusterLabel,
642
+ open,
643
+ onToggle,
644
+ resourceHref,
645
+ onResourceClick,
646
+ onHideCheck,
647
+ onHideCategory,
648
+ }: {
649
+ fc: FleetCheck
650
+ meta?: CheckMeta
651
+ clusterLabel?: (check: Check) => string | undefined
652
+ open: boolean
653
+ onToggle: () => void
654
+ resourceHref?: (ref: CheckResourceRef) => string
655
+ onResourceClick?: (ref: CheckResourceRef) => void
656
+ onHideCheck?: (checkID: string, title: string) => void
657
+ onHideCategory?: (category: string) => void
658
+ }) {
659
+ const clusterCount = fc.clusters.length
660
+ const single = clusterCount <= 1
661
+
662
+ const menuItems: { label: string; onClick: () => void }[] = []
663
+ if (onHideCheck) menuItems.push({ label: `Hide "${fc.title}" check`, onClick: () => onHideCheck(fc.checkID, fc.title) })
664
+ if (onHideCategory) menuItems.push({ label: `Hide all ${fc.category} checks`, onClick: () => onHideCategory(fc.category) })
665
+
666
+ return (
667
+ <CheckCardShell
668
+ title={fc.title}
669
+ category={fc.category}
670
+ severity={fc.severity}
671
+ open={open}
672
+ onToggle={onToggle}
673
+ summary={
674
+ <div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
675
+ <span className="shrink-0 font-medium text-theme-text-secondary tabular-nums">
676
+ {fc.totalResources} {fc.totalResources === 1 ? 'resource' : 'resources'}
677
+ </span>
678
+ {clusterCount > 1 && (
679
+ <>
680
+ <span aria-hidden>·</span>
681
+ <span className="shrink-0 tabular-nums">{clusterCount} clusters</span>
682
+ </>
683
+ )}
684
+ </div>
685
+ }
686
+ renderActions={() => (menuItems.length > 0 ? <RowMenu items={menuItems} /> : null)}
687
+ >
688
+ <CheckRemediationBlock description={meta?.description} remediation={meta?.remediation} references={meta?.references} />
689
+
690
+ <div className="border-t border-theme-border/70 pt-3">
691
+ {single ? (
692
+ <ResourceList
693
+ label={`Affected resources (${fc.totalResources})`}
694
+ check={fc.clusters[0]}
695
+ resourceHref={resourceHref}
696
+ onResourceClick={onResourceClick}
697
+ />
698
+ ) : (
699
+ <ClusterBreakdown fc={fc} clusterLabel={clusterLabel} resourceHref={resourceHref} onResourceClick={onResourceClick} />
700
+ )}
701
+ </div>
702
+ </CheckCardShell>
703
+ )
704
+ }
705
+
706
+ // Per-cluster sub-groups for a check that spans the fleet. Each cluster is a
707
+ // collapsible row ("cluster · K resources"); open it for that cluster's
708
+ // resources. Caps the cluster list so a check across hundreds of clusters
709
+ // stays scannable.
710
+ function ClusterBreakdown({
711
+ fc,
712
+ clusterLabel,
713
+ resourceHref,
714
+ onResourceClick,
715
+ }: {
716
+ fc: FleetCheck
717
+ clusterLabel?: (check: Check) => string | undefined
718
+ resourceHref?: (ref: CheckResourceRef) => string
719
+ onResourceClick?: (ref: CheckResourceRef) => void
720
+ }) {
721
+ return (
722
+ <CheckClusterBreakdownShell
723
+ heading={
724
+ <>
725
+ Affected resources <span className="tabular-nums">({fc.totalResources})</span> · {fc.clusters.length} clusters
726
+ </>
727
+ }
728
+ groups={fc.clusters.map((c) => ({
729
+ id: c.subject.cluster_id,
730
+ label: <ClusterName name={clusterLabel?.(c) || c.subject.cluster_id} />,
731
+ environment: c.environment,
732
+ count: `${c.affectedResources} ${c.affectedResources === 1 ? 'resource' : 'resources'}`,
733
+ check: c,
734
+ }))}
735
+ renderGroupBody={(group) => (
736
+ <ResourceList check={group.check} resourceHref={resourceHref} onResourceClick={onResourceClick} />
737
+ )}
738
+ />
739
+ )
740
+ }
741
+
615
742
  // The resource lines for one cluster's check. `label` (set in the single-cluster
616
743
  // case) renders a section heading; in the cluster-breakdown case the cluster row
617
744
  // already provides the heading, so it's omitted.
@@ -1,3 +1,16 @@
1
- export { ChecksView, type ChecksViewProps } from './ChecksView'
1
+ export {
2
+ CheckCardShell,
3
+ CheckClusterBreakdownShell,
4
+ CheckRemediationBlock,
5
+ CheckSeverityBar,
6
+ CheckSeverityChip,
7
+ ChecksView,
8
+ type CheckCardShellProps,
9
+ type CheckClusterBreakdownGroup,
10
+ type CheckClusterBreakdownShellProps,
11
+ type CheckReference,
12
+ type CheckRemediationBlockProps,
13
+ type ChecksViewProps,
14
+ } from './ChecksView'
2
15
  export * from './types'
3
16
  export * from './severity'
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState, type ReactNode } from 'react';
1
+ import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
2
2
  import { ChevronRight, CircleCheck, Clock, ExternalLink } from 'lucide-react';
3
3
  import { ClusterName, EmptyState } from '../ui';
4
4
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
@@ -90,25 +90,48 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
90
90
  );
91
91
  }
92
92
 
93
- function IssueRow({
94
- issue,
95
- clusterLabel,
96
- open,
97
- onToggle,
98
- resourceHref,
99
- onResourceClick,
100
- }: {
93
+ export interface IssueRowSlotContext {
94
+ issue: Issue;
95
+ open: boolean;
96
+ }
97
+
98
+ export interface IssueRowProps {
101
99
  issue: Issue;
102
100
  clusterLabel?: (issue: Issue) => string | undefined;
103
101
  open: boolean;
104
102
  onToggle: () => void;
105
103
  resourceHref?: (ref: IssueResourceRef) => string;
106
104
  onResourceClick?: (ref: IssueResourceRef) => void;
107
- }) {
105
+ as?: 'li' | 'div';
106
+ className?: string;
107
+ dimmed?: boolean;
108
+ renderBadges?: (ctx: IssueRowSlotContext) => ReactNode;
109
+ renderMeta?: (ctx: IssueRowSlotContext) => ReactNode;
110
+ renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
111
+ ResourceLinkIcon?: ComponentType<{ className?: string }>;
112
+ }
113
+
114
+ export function IssueRow({
115
+ issue,
116
+ clusterLabel,
117
+ open,
118
+ onToggle,
119
+ resourceHref,
120
+ onResourceClick,
121
+ as = 'li',
122
+ className,
123
+ dimmed,
124
+ renderBadges,
125
+ renderMeta,
126
+ renderActions,
127
+ ResourceLinkIcon = ExternalLink,
128
+ }: IssueRowProps) {
108
129
  const cluster = clusterLabel?.(issue);
109
130
  const affected = affectedSummary(issue.affected);
110
131
  const { headline } = issueMessageParts(issue);
111
132
  const [renderDetails, setRenderDetails] = useState(open);
133
+ const Container = as;
134
+ const slotCtx = { issue, open };
112
135
 
113
136
  useEffect(() => {
114
137
  if (open) {
@@ -122,8 +145,12 @@ function IssueRow({
122
145
  }, [open, renderDetails]);
123
146
 
124
147
  return (
125
- <li
126
- className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm"
148
+ <Container
149
+ className={[
150
+ 'overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm',
151
+ dimmed ? 'opacity-60' : '',
152
+ className ?? '',
153
+ ].filter(Boolean).join(' ')}
127
154
  style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 72px' }}
128
155
  >
129
156
  {/* The whole header is the single toggle target — chevron is just the
@@ -149,6 +176,7 @@ function IssueRow({
149
176
  <div className="flex min-w-0 items-baseline gap-2">
150
177
  <span className="shrink-0 text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
151
178
  <span className={`badge-sm shrink-0 self-center text-[10px] ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
179
+ {renderBadges?.(slotCtx)}
152
180
  {/* The detector reason/message rides the title row so the most
153
181
  useful triage signal is visible without expanding — it fills
154
182
  the otherwise-empty band between the title and the severity
@@ -180,6 +208,7 @@ function IssueRow({
180
208
  <span className="shrink-0 tabular-nums">{affected}</span>
181
209
  </>
182
210
  ) : null}
211
+ {renderMeta?.(slotCtx)}
183
212
  </div>
184
213
  </div>
185
214
 
@@ -203,6 +232,7 @@ function IssueRow({
203
232
  <span className={`badge-sm shrink-0 text-[10px] font-semibold ${ISSUE_SEVERITY_BADGE_CLASS[issue.severity]}`}>
204
233
  {ISSUE_SEVERITY_LABEL[issue.severity]}
205
234
  </span>
235
+ {renderActions?.(slotCtx)}
206
236
  </div>
207
237
 
208
238
  {renderDetails ? (
@@ -218,16 +248,16 @@ function IssueRow({
218
248
  <div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
219
249
  <div className="flex flex-col gap-4">
220
250
  <Diagnosis issue={issue} />
221
- <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
251
+ <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
222
252
  <div className="border-t border-theme-border/70 pt-3">
223
- <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
253
+ <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
224
254
  </div>
225
255
  </div>
226
256
  </div>
227
257
  </div>
228
258
  </div>
229
259
  ) : null}
230
- </li>
260
+ </Container>
231
261
  );
232
262
  }
233
263
 
@@ -316,10 +346,12 @@ function DiagnosticContext({
316
346
  issue,
317
347
  resourceHref,
318
348
  onResourceClick,
349
+ ResourceLinkIcon,
319
350
  }: {
320
351
  issue: Issue;
321
352
  resourceHref?: (ref: IssueResourceRef) => string;
322
353
  onResourceClick?: (ref: IssueResourceRef) => void;
354
+ ResourceLinkIcon: ComponentType<{ className?: string }>;
323
355
  }) {
324
356
  const ctx = issue.diagnostic_context;
325
357
  const facts = ctx?.facts?.filter((fact) => fact.message || fact.refs?.length || fact.related_issues?.length) ?? [];
@@ -347,6 +379,7 @@ function DiagnosticContext({
347
379
  refForLink={memberRef(issue, related.ref)}
348
380
  resourceHref={resourceHref}
349
381
  onResourceClick={onResourceClick}
382
+ ResourceLinkIcon={ResourceLinkIcon}
350
383
  />
351
384
  ))}
352
385
  </ul>
@@ -359,6 +392,7 @@ function DiagnosticContext({
359
392
  refForLink={memberRef(issue, ref)}
360
393
  resourceHref={resourceHref}
361
394
  onResourceClick={onResourceClick}
395
+ ResourceLinkIcon={ResourceLinkIcon}
362
396
  />
363
397
  ))}
364
398
  </ul>
@@ -424,10 +458,12 @@ function AffectedResources({
424
458
  issue,
425
459
  resourceHref,
426
460
  onResourceClick,
461
+ ResourceLinkIcon,
427
462
  }: {
428
463
  issue: Issue;
429
464
  resourceHref?: (ref: IssueResourceRef) => string;
430
465
  onResourceClick?: (ref: IssueResourceRef) => void;
466
+ ResourceLinkIcon: ComponentType<{ className?: string }>;
431
467
  }) {
432
468
  const members = issue.members ?? [];
433
469
  // count is the backend fan-out size (members, subject excluded — see
@@ -439,7 +475,7 @@ function AffectedResources({
439
475
  first deep-link; members (the folded pods) follow. ResourceLine emits
440
476
  an <li>, so it needs a list parent of its own. */}
441
477
  <ul className="flex flex-col gap-px">
442
- <ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} />
478
+ <ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
443
479
  </ul>
444
480
  {members.length > 0 && (
445
481
  <>
@@ -453,6 +489,7 @@ function AffectedResources({
453
489
  refForLink={memberRef(issue, m)}
454
490
  resourceHref={resourceHref}
455
491
  onResourceClick={onResourceClick}
492
+ ResourceLinkIcon={ResourceLinkIcon}
456
493
  />
457
494
  ))}
458
495
  </ul>
@@ -472,11 +509,13 @@ function ResourceLine({
472
509
  refForLink,
473
510
  resourceHref,
474
511
  onResourceClick,
512
+ ResourceLinkIcon,
475
513
  }: {
476
514
  label?: string;
477
515
  refForLink: IssueResourceRef;
478
516
  resourceHref?: (ref: IssueResourceRef) => string;
479
517
  onResourceClick?: (ref: IssueResourceRef) => void;
518
+ ResourceLinkIcon: ComponentType<{ className?: string }>;
480
519
  }) {
481
520
  const r = refForLink;
482
521
  const linkable = !!(onResourceClick || resourceHref);
@@ -488,7 +527,7 @@ function ResourceLine({
488
527
  {r.namespace ? `${r.namespace} / ` : ''}
489
528
  {r.name}
490
529
  </span>
491
- {linkable && <ExternalLink className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
530
+ {linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
492
531
  </>
493
532
  );
494
533
  const cls = 'group/r flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60';
@@ -2,8 +2,8 @@
2
2
  // module-internal and don't collide at the top-level barrel with the Checks
3
3
  // queue's identically-named helpers when both land. Issue-prefixed public
4
4
  // names are safe to surface.
5
- export { IssuesView } from './IssuesView';
6
- export type { IssuesViewProps } from './IssuesView';
5
+ export { IssueRow, IssuesView } from './IssuesView';
6
+ export type { IssueRowProps, IssueRowSlotContext, IssuesViewProps } from './IssuesView';
7
7
  export {
8
8
  ISSUE_SEVERITIES,
9
9
  ISSUE_SEVERITY_RANK,
@@ -28,6 +28,13 @@ describe('compareIssues', () => {
28
28
  expect([older, newer].sort(compareIssues).map((i) => i.id)).toEqual(['n', 'o'])
29
29
  })
30
30
 
31
+ it('orders direct startup blockers before generic problem rows at same severity', () => {
32
+ const generic = mk({ id: 'generic', source: 'problem', first_seen: '2026-05-01T00:00:00Z' })
33
+ const blocker = mk({ id: 'blocker', source: 'scheduling', first_seen: '2026-01-01T00:00:00Z' })
34
+ const missing = mk({ id: 'missing', source: 'missing_ref', first_seen: '2026-03-01T00:00:00Z' })
35
+ expect([generic, blocker, missing].sort(compareIssues).map((i) => i.id)).toEqual(['blocker', 'missing', 'generic'])
36
+ })
37
+
31
38
  it('does NOT reshuffle same-severity rows when only last_seen changes (anti-churn)', () => {
32
39
  // Two same-severity rows, same first_seen — order is the deterministic name tiebreak.
33
40
  const a = mk({ id: 'id-a', name: 'a', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-05-01T00:00:00Z' })
@@ -24,6 +24,13 @@ export const ISSUE_SEVERITY_RANK: Record<IssueSeverity, number> = {
24
24
  warning: 1,
25
25
  };
26
26
 
27
+ const ISSUE_SOURCE_RANK: Record<string, number> = {
28
+ scheduling: 4,
29
+ missing_ref: 3,
30
+ condition: 2,
31
+ problem: 1,
32
+ };
33
+
27
34
  export function isIssueSeverity(s: string): s is IssueSeverity {
28
35
  return s === 'critical' || s === 'warning';
29
36
  }
@@ -209,15 +216,18 @@ export function memberRef(issue: Issue, member: IssueResourceRef): IssueResource
209
216
 
210
217
  /**
211
218
  * compareIssues is the queue's stable sort order (extracted from IssuesView so
212
- * it can be unit-tested). Severity first (critical before warning), then ONSET
213
- * first_seen DESC, deliberately NOT last_seen: last_seen bumps to compose-time
214
- * on every poll, so sorting by it would reshuffle same-severity rows on each
215
- * refetch. The remaining keys (cluster namespace name id) are a fully
216
- * deterministic tiebreak so the order never churns under auto-refresh.
219
+ * it can be unit-tested). Severity first (critical before warning), then
220
+ * direct-blocker source priority, then ONSET first_seen DESC, deliberately
221
+ * NOT last_seen: last_seen bumps to compose-time on every poll, so sorting by
222
+ * it would reshuffle same-severity rows on each refetch. The remaining keys
223
+ * (cluster namespace name id) are a fully deterministic tiebreak so the
224
+ * order never churns under auto-refresh.
217
225
  */
218
226
  export function compareIssues(a: Issue, b: Issue): number {
219
227
  const r = ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
220
228
  if (r !== 0) return r;
229
+ const sr = (ISSUE_SOURCE_RANK[b.source] ?? 0) - (ISSUE_SOURCE_RANK[a.source] ?? 0);
230
+ if (sr !== 0) return sr;
221
231
  const fa = a.first_seen ?? '';
222
232
  const fb = b.first_seen ?? '';
223
233
  if (fa !== fb) return fb.localeCompare(fa);
@@ -23,7 +23,7 @@ import { ForceDeleteConfirmDialog, type CascadeDependent } from '../ui/ForceDele
23
23
  import { ConfirmDialog } from '../ui/ConfirmDialog'
24
24
  import { DialogPortal } from '../ui/DialogPortal'
25
25
  import type { SelectedResource, WorkloadRevision } from '../../types'
26
- import { formatKindName } from '../ui/drawer-components'
26
+ import { displayKindName } from '../ui/drawer-components'
27
27
  import { getDefaultContainerName } from '../resources/resource-utils'
28
28
 
29
29
  // ============================================================================
@@ -517,20 +517,20 @@ export function ResourceActionsBar({
517
517
  <Tooltip
518
518
  content={
519
519
  onCompareTo && onCompareAcrossClusters
520
- ? `Compare ${formatKindName(resource.kind).toLowerCase()}`
520
+ ? `Compare ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
521
521
  : onCompareAcrossClusters
522
522
  ? `Compare across clusters`
523
- : `Compare to another ${formatKindName(resource.kind).toLowerCase()}`
523
+ : `Compare to another ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
524
524
  }
525
525
  >
526
526
  <button
527
527
  onClick={onCompareTo ?? onCompareAcrossClusters}
528
528
  aria-label={
529
529
  onCompareTo && onCompareAcrossClusters
530
- ? `Compare ${formatKindName(resource.kind).toLowerCase()}`
530
+ ? `Compare ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
531
531
  : onCompareAcrossClusters
532
532
  ? `Compare across clusters`
533
- : `Compare to another ${formatKindName(resource.kind).toLowerCase()}`
533
+ : `Compare to another ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
534
534
  }
535
535
  className="p-1.5 text-theme-text-secondary border border-theme-border-light rounded-lg hover:text-theme-text-primary hover:bg-theme-elevated transition-colors flex items-center"
536
536
  >
@@ -577,7 +577,7 @@ export function ResourceActionsBar({
577
577
  onClose={() => setShowDeleteConfirm(false)}
578
578
  onConfirm={handleDeleteConfirm}
579
579
  resourceName={resource.name}
580
- resourceKind={formatKindName(resource.kind)}
580
+ resourceKind={displayKindName(resource.kind, data?.kind)}
581
581
  namespaceName={resource.namespace}
582
582
  isLoading={isDeleting ?? false}
583
583
  cascadeDependents={cascadeDependents}
@@ -1,7 +1,6 @@
1
1
  import { useState, useMemo } from 'react'
2
2
  import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
3
3
  import { ConfirmDialog } from './ConfirmDialog'
4
- import { formatKindName } from './drawer-components'
5
4
  import { pluralize } from '../../utils/pluralize'
6
5
 
7
6
  export interface CascadeDependent {
@@ -115,7 +114,7 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
115
114
  <div className="px-3 pb-2.5 space-y-1.5">
116
115
  {grouped.map(([kind, names]) => (
117
116
  <div key={kind} className="text-xs">
118
- <span className="font-medium text-theme-text-primary">{formatKindName(kind)}</span>
117
+ <span className="font-medium text-theme-text-primary">{kind}</span>
119
118
  <span className="text-theme-text-tertiary ml-1">({names.length})</span>
120
119
  <div className="ml-3 mt-0.5 text-theme-text-secondary font-mono break-all">
121
120
  {names.slice(0, MAX_NAMES_PER_KIND).join(', ')}
@@ -701,6 +701,14 @@ export function formatKindName(kind: string): string {
701
701
  return kind
702
702
  }
703
703
 
704
+ // Resolve the display label for a kind chip. The fetched resource's `dataKind`
705
+ // is the authoritative PascalCase kind and is correct for every CRD; prefer it.
706
+ // Before data loads, fall back to deriving from the URL plural — which is only
707
+ // reliable for the core kinds in formatKindName's map.
708
+ export function displayKindName(urlPlural: string, dataKind?: string): string {
709
+ return dataKind || formatKindName(urlPlural)
710
+ }
711
+
704
712
  // Type for copy handler
705
713
  export type CopyHandler = (text: string, key: string) => void
706
714
 
@@ -51,7 +51,7 @@ import { EditableYamlView, SaveSuccessAnimation } from '../shared/EditableYamlVi
51
51
  import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from '../shared/ResourceRendererDispatch'
52
52
  import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
53
53
  import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
54
- import { getKindColorOutline, formatKindName } from '../ui/drawer-components'
54
+ import { getKindColorOutline, displayKindName } from '../ui/drawer-components'
55
55
  import { midTruncate } from '../../utils/format'
56
56
 
57
57
  export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'yaml'
@@ -555,7 +555,7 @@ export function WorkloadView({
555
555
  <div className="flex items-center justify-between px-4 pt-3 pb-2">
556
556
  <div className="flex items-center gap-2 flex-wrap">
557
557
  <span className={clsx('badge', getKindColorOutline(apiKind))}>
558
- {formatKindName(apiKind)}
558
+ {displayKindName(apiKind, resource?.kind)}
559
559
  </span>
560
560
  {status && (
561
561
  <span className={clsx('badge', status.color)}>
@@ -706,7 +706,7 @@ export function WorkloadView({
706
706
  </div>
707
707
  <div className="flex items-center gap-3 text-sm text-theme-text-secondary">
708
708
  <span className={clsx('badge', getKindColorOutline(apiKind))}>
709
- {formatKindName(apiKind)}
709
+ {displayKindName(apiKind, resource?.kind)}
710
710
  </span>
711
711
  {status && (
712
712
  <span className={clsx('badge', status.color)}>
@@ -860,7 +860,7 @@ export function WorkloadView({
860
860
  )}
861
861
  >
862
862
  <span className="truncate text-xs font-medium text-theme-text-primary">{midTruncate(o.name, 26)}</span>
863
- <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{formatKindName(o.kind)}</span>
863
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{o.kind}</span>
864
864
  </button>
865
865
  )
866
866
  })}