@skyhook-io/k8s-ui 1.7.13 → 1.7.15

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.15",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -69,7 +69,7 @@
69
69
  "@monaco-editor/react": "^4.7.0",
70
70
  "html-to-image": "^1.11.0",
71
71
  "react-virtuoso": "^4.18.7",
72
- "shiki": "^4.0.0"
72
+ "shiki": "^4.2.0"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@xterm/addon-fit": ">=0.10.0",
@@ -85,8 +85,7 @@
85
85
  "yaml": ">=2.0.0"
86
86
  },
87
87
  "devDependencies": {
88
- "@types/diff": "^7.0.2",
89
- "@types/react": "^19.2.14",
88
+ "@types/react": "^19.2.17",
90
89
  "@types/react-dom": "^19.2.3",
91
90
  "@xterm/addon-fit": "^0.11.0",
92
91
  "@xterm/addon-web-links": "^0.12.0",
@@ -96,10 +95,10 @@
96
95
  "diff": "^9.0.0",
97
96
  "elkjs": "^0.11.1",
98
97
  "lucide-react": "^1.16.0",
99
- "react": "^19.2.6",
100
- "react-dom": "^19.2.6",
98
+ "react": "^19.2.7",
99
+ "react-dom": "^19.2.7",
101
100
  "typescript": "^6.0.2",
102
- "vitest": "^4.1.5",
101
+ "vitest": "^4.1.8",
103
102
  "yaml": "^2.9.0"
104
103
  }
105
104
  }
@@ -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'