@skyhook-io/k8s-ui 1.8.9 → 1.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationsList.tsx +4 -1
  3. package/src/components/applications/ApplicationsView.tsx +40 -9
  4. package/src/components/checks/ChecksView.tsx +80 -49
  5. package/src/components/checks/severity.ts +29 -39
  6. package/src/components/gitops/GitOpsTableView.tsx +11 -56
  7. package/src/components/issues/IssuesView.tsx +186 -123
  8. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -0
  9. package/src/components/issues/ResourceIssuesSection.tsx +21 -3
  10. package/src/components/issues/index.ts +0 -2
  11. package/src/components/issues/issues.test.ts +45 -4
  12. package/src/components/issues/severity.ts +27 -32
  13. package/src/components/resources/ResourcesSidebar.test.tsx +36 -0
  14. package/src/components/resources/ResourcesSidebar.tsx +24 -6
  15. package/src/components/resources/ResourcesView.tsx +10 -2
  16. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  17. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  18. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  19. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  20. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  23. package/src/components/ui/BoardSkeleton.tsx +47 -0
  24. package/src/components/ui/CardSection.tsx +117 -0
  25. package/src/components/ui/Toast.tsx +1 -1
  26. package/src/components/ui/index.ts +2 -0
  27. package/src/components/ui/severity-tone.ts +56 -0
  28. package/src/theme/variables.css +14 -0
  29. package/src/utils/api-resources.test.ts +54 -0
  30. package/src/utils/api-resources.ts +68 -1
  31. package/tsconfig.json +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.8.9",
3
+ "version": "1.8.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -13,9 +13,11 @@ export interface ApplicationsListProps {
13
13
  onSelect: (key: string) => void
14
14
  /** Leading element in the header actions (e.g. a freshness control). */
15
15
  headerActions?: ReactNode
16
+ /** First fetch in flight — chassis renders its shape-stable skeleton. */
17
+ loading?: boolean
16
18
  }
17
19
 
18
- export function ApplicationsList({ apps, onSelect, headerActions }: ApplicationsListProps) {
20
+ export function ApplicationsList({ apps, onSelect, headerActions, loading }: ApplicationsListProps) {
19
21
  // Env tokens this CLUSTER proved (identity classifications on the wire) feed
20
22
  // the namespace heuristic, so sibling-less rows in discovered env namespaces
21
23
  // still label without any hardcoded vocabulary.
@@ -24,6 +26,7 @@ export function ApplicationsList({ apps, onSelect, headerActions }: Applications
24
26
 
25
27
  return (
26
28
  <ApplicationsView
29
+ loading={loading}
27
30
  variant="single"
28
31
  entries={entries}
29
32
  onSelect={onSelect}
@@ -5,6 +5,7 @@ import { clsx } from 'clsx'
5
5
  import { StatusDot, mapHealthToTone } from '../ui/status-tone'
6
6
  import { Tooltip } from '../ui/Tooltip'
7
7
  import { EmptyState } from '../ui/EmptyState'
8
+ import { BoardTableSkeleton, BoardRailSkeleton } from '../ui/BoardSkeleton'
8
9
  import { SearchBox } from '../ui/SearchBox'
9
10
  import { useFilterState, defineFilterSchema } from '../../filter-state'
10
11
  import { PageHeader } from '../ui/PageHeader'
@@ -100,6 +101,9 @@ export interface ApplicationsViewProps {
100
101
  emptySlot?: ReactNode
101
102
  /** Leading element in the header actions cluster (e.g. a freshness control). */
102
103
  headerActions?: ReactNode
104
+ /** First fetch in flight — render the shape-stable skeleton (pulsing tiles,
105
+ * rail stubs, table rows) instead of collapsing the chassis. */
106
+ loading?: boolean
103
107
  }
104
108
 
105
109
  const APPS_FILTER_SCHEMA = defineFilterSchema({
@@ -112,7 +116,7 @@ const APPS_FILTER_SCHEMA = defineFilterSchema({
112
116
  system: { param: 'system', type: 'boolean' },
113
117
  })
114
118
 
115
- export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot, headerActions }: ApplicationsViewProps) {
119
+ export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot, headerActions, loading }: ApplicationsViewProps) {
116
120
  // Facets + search + show-system live in the URL (shareable, bookmarkable) via
117
121
  // the shared filter-state contract. Sort stays local: it's a compound
118
122
  // {key, dir} view-preference, not a result-narrowing filter, so it isn't
@@ -274,9 +278,16 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
274
278
  .sort((a, b) => (envRank(b[0] === 'none' ? undefined : b[0]) ?? -1) - (envRank(a[0] === 'none' ? undefined : a[0]) ?? -1))
275
279
  .map(([env, count]) => ({ value: env, label: env === 'none' ? 'unlabeled' : env, count }))
276
280
 
281
+ // First fetch, nothing to show yet: drive the shape-stable skeleton. The
282
+ // loaded state hides zero-count health tiles, so without this the header
283
+ // (tiles + distribution bar), rail, and table would all pop in at once.
284
+ const initialLoading = Boolean(loading) && allEntries.length === 0
285
+
277
286
  // Clickable status tile wired to the health facet — tap to filter to that tier.
278
287
  const healthTile = (h: AppHealth, tone: SummaryTone) =>
279
- counts.health[h] ? (
288
+ initialLoading && (h === 'healthy' || h === 'degraded' || h === 'unhealthy') ? (
289
+ <SummaryTile key={h} label={HEALTH_META[h].label} value={0} tone={tone} loading />
290
+ ) : counts.health[h] ? (
280
291
  <SummaryTile key={h} label={HEALTH_META[h].label} value={counts.health[h]} tone={tone} active={fHealth.has(h)} onClick={() => filters.toggle('health', h)} />
281
292
  ) : null
282
293
 
@@ -301,7 +312,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
301
312
  actions={
302
313
  <>
303
314
  {headerActions}
304
- <SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} />
315
+ <SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} loading={initialLoading} />
305
316
  {healthTile('unhealthy', 'error')}
306
317
  {healthTile('degraded', 'warning')}
307
318
  {healthTile('healthy', 'success')}
@@ -310,11 +321,15 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
310
321
  </>
311
322
  }
312
323
  />
313
- <DistributionBar
314
- className="mt-3"
315
- ariaLabel="Health distribution"
316
- segments={HEALTH_ORDER.map((h) => ({ key: h, count: counts.health[h] ?? 0, fillClass: HEALTH_META[h].bar }))}
317
- />
324
+ {initialLoading ? (
325
+ <div className="mt-3 h-1.5 w-full animate-pulse rounded-full bg-theme-hover" aria-hidden />
326
+ ) : (
327
+ <DistributionBar
328
+ className="mt-3"
329
+ ariaLabel="Health distribution"
330
+ segments={HEALTH_ORDER.map((h) => ({ key: h, count: counts.health[h] ?? 0, fillClass: HEALTH_META[h].bar }))}
331
+ />
332
+ )}
318
333
  </div>
319
334
 
320
335
  {/* Body: filter sidebar | content (toolbar + table). */}
@@ -330,6 +345,18 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
330
345
  )}
331
346
  </div>
332
347
  <div className="flex-1 overflow-y-auto">
348
+ {initialLoading ? (
349
+ <BoardRailSkeleton
350
+ sections={[
351
+ ['Availability', 5],
352
+ ['Class', 3],
353
+ ['Type', 4],
354
+ ['Environment', 3],
355
+ ['Source', 3],
356
+ ]}
357
+ />
358
+ ) : (
359
+ <>
333
360
  <Facet icon={HeartPulse} title="Availability" options={HEALTH_ORDER.map((h) => ({ value: h, label: HEALTH_META[h].label, count: counts.health[h] ?? 0, tone: HEALTH_TONE[h] }))} selected={fHealth} onToggle={(v) => filters.toggle('health', v)} />
334
361
  <Facet icon={Layers} title="Class" options={CLASS_ORDER.map((c) => ({ value: c, label: CLASS_META[c].label, count: counts.workloadClass[c] ?? 0 }))} selected={fClass} onToggle={(v) => filters.toggle('class', v)} />
335
362
  <Facet icon={Shapes} title="Type" options={CATEGORY_ORDER.map((c) => ({ value: c, label: CATEGORY_META[c].label, count: counts.category[c] ?? 0, tooltip: CATEGORY_META[c].tooltip }))} selected={fType} onToggle={(v) => filters.toggle('type', v)} />
@@ -342,6 +369,8 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
342
369
  <span className="ml-auto tabular-nums text-theme-text-tertiary">{systemCount}</span>
343
370
  </label>
344
371
  )}
372
+ </>
373
+ )}
345
374
  </div>
346
375
  </aside>
347
376
 
@@ -369,7 +398,9 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
369
398
  </div>
370
399
 
371
400
  <div className="min-w-0 flex-1 overflow-auto bg-theme-base">
372
- {entries.length === 0 ? (
401
+ {initialLoading ? (
402
+ <BoardTableSkeleton />
403
+ ) : entries.length === 0 ? (
373
404
  emptySlot && allEntries.length === 0 ? (
374
405
  emptySlot
375
406
  ) : (
@@ -1,21 +1,40 @@
1
- import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
1
+ import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { ChevronDown, ChevronRight, ExternalLink, EyeOff, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
4
- import { ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip } from '../ui'
3
+ import { AlertCircle, AlertOctagon, AlertTriangle, ChevronDown, ChevronRight, ExternalLink, EyeOff, Info, Layers, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
4
+ import { CardBody, CardSection, ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip, NEUTRAL_CHIP_CLASS, renderProse } from '../ui'
5
5
  import { useFilterState, defineFilterSchema } from '../../filter-state'
6
6
  import type { CheckMeta, CheckReference } from '../audit'
7
7
  import { CHECK_SEVERITIES, CHECK_SEVERITY_RANK, type Check, type CheckSeverity, type EffectiveCheckFinding, type CheckResourceRef } from './types'
8
8
  import {
9
9
  SEVERITY_BADGE_CLASS,
10
10
  SEVERITY_FILL_CLASS,
11
+ SEVERITY_HEADER_BAND_CLASS,
11
12
  SEVERITY_LABEL,
12
13
  SEVERITY_RAIL_CLASS,
14
+ SEVERITY_SOLID_CLASS,
13
15
  SEVERITY_TEXT_CLASS,
14
- categoryBadgeClass,
15
16
  } from './severity'
16
17
 
17
18
  const CATEGORIES: readonly string[] = ['Security', 'Reliability', 'Efficiency']
18
19
 
20
+ // Leading severity glyph, one per tier of the 4-tier ladder: critical = octagon,
21
+ // high = triangle, medium = circle, low = info.
22
+ const CHECK_SEVERITY_ICON: Record<CheckSeverity, ComponentType<{ className?: string }>> = {
23
+ critical: AlertOctagon,
24
+ high: AlertTriangle,
25
+ medium: AlertCircle,
26
+ low: Info,
27
+ }
28
+
29
+ // An out-of-contract severity the backend might emit is coerced to this tier
30
+ // once, up front (normalizeCheckSeverity), so the icon AND every color map
31
+ // (text/rail/band/pill) resolve together — a raw miss would crash the icon and
32
+ // silently drop the tint everywhere else.
33
+ const CHECK_SEVERITY_FALLBACK: CheckSeverity = 'medium'
34
+ // Object.hasOwn (not `in`) so inherited keys like "toString" don't slip past.
35
+ const normalizeCheckSeverity = (s: CheckSeverity): CheckSeverity =>
36
+ Object.hasOwn(CHECK_SEVERITY_ICON, s) ? s : CHECK_SEVERITY_FALLBACK
37
+
19
38
  // Affected-resources shown inline before "View all". A check can fail on
20
39
  // thousands of resources; the card stays scannable and only the rare big-list
21
40
  // case pays the cost of a full expand.
@@ -410,17 +429,22 @@ export interface CheckRemediationBlockProps {
410
429
  layout?: 'columns' | 'stack'
411
430
  }
412
431
 
432
+ // Remediation body: WHY IT MATTERS (info, the description) → HOW TO FIX
433
+ // (wrench/emerald, the remediation + doc links), rendered as icon-led sections
434
+ // for every host. Prose runs through renderProse so `inline-code` spans become
435
+ // mono chips when the catalog copy carries them. The 'stack' layout is the
436
+ // compact single-column variant for narrow hosts.
413
437
  export function CheckRemediationBlock({ description, remediation, references, layout = 'columns' }: CheckRemediationBlockProps) {
414
438
  if (!description && !remediation && (!references || references.length === 0)) return null
415
439
 
416
440
  if (layout === 'stack') {
417
441
  return (
418
442
  <div className="flex flex-col gap-2">
419
- {description && <p className="text-[13px] leading-relaxed text-theme-text-secondary">{description}</p>}
443
+ {description && <p className="text-[13px] leading-relaxed text-theme-text-secondary">{renderProse(description)}</p>}
420
444
  {remediation && (
421
445
  <div>
422
446
  <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>
447
+ <p className="mt-0.5 text-[13px] leading-relaxed text-theme-text-secondary">{renderProse(remediation)}</p>
424
448
  </div>
425
449
  )}
426
450
  {references && references.length > 0 && <CheckReferenceLinks references={references} />}
@@ -429,25 +453,19 @@ export function CheckRemediationBlock({ description, remediation, references, la
429
453
  }
430
454
 
431
455
  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
- </>
456
+ <div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
457
+ {description && (
458
+ <CardSection icon={Info} label="Why it matters" tone="neutral">
459
+ <CardBody>{renderProse(description)}</CardBody>
460
+ </CardSection>
461
+ )}
462
+ {(remediation || (references && references.length > 0)) && (
463
+ <CardSection icon={Wrench} label="How to fix" tone="fix">
464
+ {remediation && <CardBody>{renderProse(remediation)}</CardBody>}
465
+ {references && references.length > 0 && <CheckReferenceLinks references={references} />}
466
+ </CardSection>
467
+ )}
468
+ </div>
451
469
  )
452
470
  }
453
471
 
@@ -500,16 +518,27 @@ export function CheckCardShell({
500
518
  dimmed,
501
519
  }: CheckCardShellProps) {
502
520
  const Container = as
521
+ const sev = normalizeCheckSeverity(severity)
522
+ const SeverityIcon = CHECK_SEVERITY_ICON[sev]
503
523
  return (
504
524
  <Container
505
525
  className={[
506
- 'overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm',
526
+ 'overflow-hidden rounded-xl border bg-theme-surface transition-[border-color,box-shadow] duration-200',
527
+ // The open card lifts via elevation — heavier shadow + a bright
528
+ // emphasis edge that clearly separates it from sibling cards. Severity
529
+ // stays rationed to the band + pill; separation is depth, not more color.
530
+ // ring-1 widens the edge to 2px without the layout shift a border-2
531
+ // swap would cause on expand.
532
+ open ? 'border-[var(--border-emphasis)] ring-1 ring-[var(--border-emphasis)] shadow-theme-md' : 'border-theme-border shadow-theme-sm',
507
533
  dimmed ? 'opacity-60' : '',
508
534
  className ?? '',
509
535
  ]
510
536
  .filter(Boolean)
511
537
  .join(' ')}
512
538
  >
539
+ {/* Leading severity icon is the at-a-glance cue; a trailing chevron shows
540
+ open/closed. Collapsed: neutral row + rail. Expanded: severity-tinted
541
+ band + solid pill — the tint is a focus signal, not per-row alarm. */}
513
542
  <div
514
543
  role="button"
515
544
  tabIndex={0}
@@ -522,31 +551,33 @@ export function CheckCardShell({
522
551
  onToggle()
523
552
  }
524
553
  }}
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]}`}
554
+ className={`group flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${open ? SEVERITY_HEADER_BAND_CLASS[sev] : SEVERITY_RAIL_CLASS[sev]}`}
526
555
  >
527
- <ChevronRight className={`mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
556
+ <SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${SEVERITY_TEXT_CLASS[sev]}`} aria-hidden />
528
557
 
529
558
  <div className="flex min-w-0 flex-1 flex-col gap-1.5">
530
559
  <div className="flex flex-wrap items-center gap-2">
531
560
  <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>
561
+ <span className={`shrink-0 ${NEUTRAL_CHIP_CLASS}`}>{category}</span>
533
562
  </div>
534
563
  {description}
535
564
  {summary}
536
565
  </div>
537
566
 
538
- <span className={`badge-sm mt-0.5 shrink-0 text-[10px] font-semibold ${SEVERITY_BADGE_CLASS[severity]}`}>
539
- {SEVERITY_LABEL[severity]}
567
+ <span className={`badge-sm shrink-0 px-2.5 py-0.5 text-xs font-semibold ${open ? SEVERITY_SOLID_CLASS[sev] : SEVERITY_BADGE_CLASS[sev]}`}>
568
+ {SEVERITY_LABEL[sev]}
540
569
  </span>
541
570
  {renderActions?.()}
571
+ <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
542
572
  </div>
543
573
 
544
574
  {/* Kept mounted (not `open &&`) so the grid-rows transition animates the
545
575
  collapse too, matching IssueRow; inert when closed so SR + tab skip
546
- the clipped content. */}
576
+ the clipped content. Body sits on the card surface (not a recessed grey
577
+ panel) so its text keeps enough contrast. */}
547
578
  <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
548
579
  <div className="overflow-hidden" inert={!open || undefined}>
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>
580
+ <div className="flex flex-col divide-y divide-theme-border/70 border-t border-theme-border bg-theme-surface py-4 pl-6 pr-4 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">{children}</div>
550
581
  </div>
551
582
  </div>
552
583
  </Container>
@@ -561,7 +592,9 @@ export interface CheckClusterBreakdownGroup {
561
592
  }
562
593
 
563
594
  export interface CheckClusterBreakdownShellProps<T extends CheckClusterBreakdownGroup> {
564
- heading: ReactNode
595
+ /** Optional section heading. Omit when the caller already provides one (e.g.
596
+ * a wrapping CardSection owns the "Affected resources" eyebrow). */
597
+ heading?: ReactNode
565
598
  groups: T[]
566
599
  renderGroupBody: (group: T) => ReactNode
567
600
  clusterCap?: number
@@ -584,7 +617,7 @@ export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>
584
617
 
585
618
  return (
586
619
  <section className="flex flex-col gap-1.5">
587
- <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{heading}</h4>
620
+ {heading ? <h4 className="text-[11px] font-semibold uppercase tracking-[0.06em] text-theme-text-tertiary">{heading}</h4> : null}
588
621
  <ul className="flex flex-col gap-1">
589
622
  {shown.map((group) => {
590
623
  const isOpen = openClusters.has(group.id)
@@ -687,18 +720,17 @@ function FleetCheckRow({
687
720
  >
688
721
  <CheckRemediationBlock description={meta?.description} remediation={meta?.remediation} references={meta?.references} />
689
722
 
690
- <div className="border-t border-theme-border/70 pt-3">
723
+ <CardSection
724
+ icon={Layers}
725
+ label="Affected resources"
726
+ labelExtra={single ? `· ${fc.totalResources}` : `· ${fc.totalResources} · ${clusterCount} clusters`}
727
+ >
691
728
  {single ? (
692
- <ResourceList
693
- label={`Affected resources (${fc.totalResources})`}
694
- check={fc.clusters[0]}
695
- resourceHref={resourceHref}
696
- onResourceClick={onResourceClick}
697
- />
729
+ <ResourceList check={fc.clusters[0]} resourceHref={resourceHref} onResourceClick={onResourceClick} />
698
730
  ) : (
699
731
  <ClusterBreakdown fc={fc} clusterLabel={clusterLabel} resourceHref={resourceHref} onResourceClick={onResourceClick} />
700
732
  )}
701
- </div>
733
+ </CardSection>
702
734
  </CheckCardShell>
703
735
  )
704
736
  }
@@ -720,11 +752,8 @@ function ClusterBreakdown({
720
752
  }) {
721
753
  return (
722
754
  <CheckClusterBreakdownShell
723
- heading={
724
- <>
725
- Affected resources <span className="tabular-nums">({fc.totalResources})</span> · {fc.clusters.length} clusters
726
- </>
727
- }
755
+ // Heading omitted — the wrapping CardSection (Layers · "Affected
756
+ // resources") already owns the eyebrow.
728
757
  groups={fc.clusters.map((c) => ({
729
758
  id: c.subject.cluster_id,
730
759
  label: <ClusterName name={clusterLabel?.(c) || c.subject.cluster_id} />,
@@ -822,7 +851,9 @@ function FindingLine({
822
851
  {showMessage && <span className="ml-1 truncate text-xs text-theme-text-tertiary">{finding.message}</span>}
823
852
  </>
824
853
  )
825
- const cls = 'group/f flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60'
854
+ // items-baseline so the smaller mono kind label shares a baseline with the
855
+ // larger resource name (their line-heights differ).
856
+ const cls = 'group/f flex w-full items-baseline gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60'
826
857
  return (
827
858
  <li>
828
859
  {onResourceClick ? (
@@ -1,9 +1,32 @@
1
1
  import type { CheckSeverity } from './types'
2
2
  import { BADGE_SEVERITY_COLORS as sev } from '../ui/Badge'
3
+ import {
4
+ TONE_FILL_CLASS,
5
+ TONE_HEADER_BAND_CLASS,
6
+ TONE_RAIL_CLASS,
7
+ TONE_SOLID_CLASS,
8
+ TONE_TEXT_CLASS,
9
+ type SeverityTone,
10
+ } from '../ui/severity-tone'
3
11
 
4
12
  // The visual language for the 4-tier Checks severity ladder. One hue per tier:
5
13
  // red=critical, orange=high, amber=medium, neutral=low — read the queue's left
6
- // rail top-to-bottom and severity is obvious without reading a word.
14
+ // rail top-to-bottom and severity is obvious without reading a word. The actual
15
+ // color strings are shared with the Issue card via the tone module; here we only
16
+ // map each tier onto its tone.
17
+ const CHECK_SEVERITY_TONE: Record<CheckSeverity, SeverityTone> = {
18
+ critical: 'red',
19
+ high: 'orange',
20
+ medium: 'amber',
21
+ low: 'slate',
22
+ }
23
+
24
+ const byTone = <T,>(toneMap: Record<SeverityTone, T>): Record<CheckSeverity, T> => ({
25
+ critical: toneMap[CHECK_SEVERITY_TONE.critical],
26
+ high: toneMap[CHECK_SEVERITY_TONE.high],
27
+ medium: toneMap[CHECK_SEVERITY_TONE.medium],
28
+ low: toneMap[CHECK_SEVERITY_TONE.low],
29
+ })
7
30
 
8
31
  export const SEVERITY_LABEL: Record<CheckSeverity, string> = {
9
32
  critical: 'Critical',
@@ -23,41 +46,8 @@ export const SEVERITY_BADGE_CLASS: Record<CheckSeverity, string> = {
23
46
  low: sev.neutral,
24
47
  }
25
48
 
26
- // Solid fill dots + the proportional distribution bar segments.
27
- export const SEVERITY_FILL_CLASS: Record<CheckSeverity, string> = {
28
- critical: 'bg-red-500',
29
- high: 'bg-orange-500',
30
- medium: 'bg-amber-500',
31
- low: 'bg-slate-400',
32
- }
33
-
34
- export const SEVERITY_TEXT_CLASS: Record<CheckSeverity, string> = {
35
- critical: 'text-red-600 dark:text-red-400',
36
- high: 'text-orange-600 dark:text-orange-400',
37
- medium: 'text-amber-600 dark:text-amber-400',
38
- low: 'text-slate-500 dark:text-slate-400',
39
- }
40
-
41
- // Left accent rail on a queue row — the scan-down severity cue. Pairs a colored
42
- // 2px border with a faint severity-tinted background that deepens on hover.
43
- export const SEVERITY_RAIL_CLASS: Record<CheckSeverity, string> = {
44
- critical: 'border-l-red-500 hover:bg-red-50/40 dark:hover:bg-red-950/20',
45
- high: 'border-l-orange-500 hover:bg-orange-50/40 dark:hover:bg-orange-950/20',
46
- medium: 'border-l-amber-500 hover:bg-amber-50/30 dark:hover:bg-amber-950/15',
47
- low: 'border-l-slate-300 dark:border-l-slate-600 hover:bg-theme-hover/40',
48
- }
49
-
50
- // Category accent — a quiet tag (severity is the loud one). Security is the
51
- // headline beat, so it gets the most distinct hue.
52
- const CATEGORY_BADGE_CLASS: Record<string, string> = {
53
- Security: 'bg-violet-50 text-violet-700 ring-1 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900',
54
- Reliability: 'bg-sky-50 text-sky-700 ring-1 ring-sky-200 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-900',
55
- Efficiency: 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 dark:bg-teal-950/40 dark:text-teal-300 dark:ring-teal-900',
56
- }
57
-
58
- export function categoryBadgeClass(category: string): string {
59
- return (
60
- CATEGORY_BADGE_CLASS[category] ??
61
- 'bg-theme-elevated text-theme-text-secondary ring-1 ring-theme-border'
62
- )
63
- }
49
+ export const SEVERITY_FILL_CLASS = byTone(TONE_FILL_CLASS)
50
+ export const SEVERITY_TEXT_CLASS = byTone(TONE_TEXT_CLASS)
51
+ export const SEVERITY_RAIL_CLASS = byTone(TONE_RAIL_CLASS)
52
+ export const SEVERITY_SOLID_CLASS = byTone(TONE_SOLID_CLASS)
53
+ export const SEVERITY_HEADER_BAND_CLASS = byTone(TONE_HEADER_BAND_CLASS)
@@ -32,6 +32,7 @@ import { FacetSection, FacetButton } from '../ui/Facet'
32
32
  import { SortableTh, TH_CLASS, type SortDir } from '../ui/SortableTh'
33
33
  import { DistributionBar } from '../ui/DistributionBar'
34
34
  import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
35
+ import { BoardTableSkeleton, BoardRailSkeleton } from '../ui/BoardSkeleton'
35
36
  import { getGitOpsResourceStatus } from './detail-helpers'
36
37
  import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
37
38
  import { toggleSet } from './GitOpsGraphFilterRail'
@@ -766,7 +767,7 @@ export function GitOpsTableView({
766
767
  {modeLabel(mode)} view is queued behind the application list.
767
768
  </div>
768
769
  ) : initialLoading ? (
769
- <GitOpsTableSkeleton />
770
+ <BoardTableSkeleton />
770
771
  ) : error ? (
771
772
  <div className="p-4 text-sm text-red-500">Failed to load GitOps applications: {error.message}</div>
772
773
  ) : filteredRows.length === 0 ? (
@@ -816,60 +817,6 @@ export function GitOpsTableView({
816
817
  // GitOpsTableView's visual language and not generally useful elsewhere.
817
818
  // =============================================================================
818
819
 
819
- // Shape-stable loading stand-ins. Both mirror the loaded anatomy so data
820
- // resolves in place: rows appear inside the table frame, facets inside the
821
- // rail — no half-height rail dangling next to a centered spinner, no layout
822
- // jump when the response lands.
823
- function GitOpsTableSkeleton() {
824
- return (
825
- <div aria-live="polite" aria-label="Loading GitOps applications…" className="divide-y divide-theme-border-light">
826
- {Array.from({ length: 10 }, (_, i) => (
827
- <div key={i} className="flex items-center gap-6 px-4 py-3" style={{ opacity: 1 - i * 0.09 }}>
828
- <div className="min-w-0 flex-[2] space-y-2">
829
- <div className="h-4 w-2/3 animate-pulse rounded bg-theme-hover" />
830
- <div className="h-3 w-1/2 animate-pulse rounded bg-theme-hover" />
831
- </div>
832
- <div className="hidden h-4 flex-1 animate-pulse rounded bg-theme-hover md:block" />
833
- <div className="h-5 w-20 animate-pulse rounded-full bg-theme-hover" />
834
- <div className="h-5 w-20 animate-pulse rounded-full bg-theme-hover" />
835
- <div className="hidden h-4 flex-[1.5] animate-pulse rounded bg-theme-hover lg:block" />
836
- </div>
837
- ))}
838
- </div>
839
- )
840
- }
841
-
842
- function GitOpsSidebarSkeleton() {
843
- // Section stubs sized like the loaded rail (Sync ~4 rows, Health ~5,
844
- // Automation ~3, Projects/Namespaces a few each) so the rail keeps its
845
- // height while counts are unknown — showing real facet buttons with "0"
846
- // counts would be false zeros.
847
- const sections: Array<[string, number]> = [
848
- ['Sync', 4],
849
- ['Health', 5],
850
- ['Automation', 3],
851
- ['Projects', 4],
852
- ['Namespaces', 4],
853
- ]
854
- return (
855
- <div aria-hidden>
856
- {sections.map(([title, rows]) => (
857
- <div key={title} className="border-b border-theme-border-light px-3 py-3">
858
- <div className="mb-2 h-3 w-24 animate-pulse rounded bg-theme-hover" />
859
- <div className="space-y-1.5">
860
- {Array.from({ length: rows }, (_, i) => (
861
- <div key={i} className="flex items-center justify-between px-1.5 py-1">
862
- <div className="h-3.5 animate-pulse rounded bg-theme-hover" style={{ width: `${52 - i * 6}%` }} />
863
- <div className="h-3.5 w-6 animate-pulse rounded bg-theme-hover" />
864
- </div>
865
- ))}
866
- </div>
867
- </div>
868
- ))}
869
- </div>
870
- )
871
- }
872
-
873
820
  function GitOpsFilterSidebar({
874
821
  loading,
875
822
  side,
@@ -936,7 +883,15 @@ function GitOpsFilterSidebar({
936
883
  </div>
937
884
  <div className="flex-1 overflow-y-auto">
938
885
  {loading ? (
939
- <GitOpsSidebarSkeleton />
886
+ <BoardRailSkeleton
887
+ sections={[
888
+ ['Sync', 4],
889
+ ['Health', 5],
890
+ ['Automation', 3],
891
+ ['Projects', 4],
892
+ ['Namespaces', 4],
893
+ ]}
894
+ />
940
895
  ) : (
941
896
  <>
942
897
  {AVAILABLE_MODES.length > 1 && (