@skyhook-io/k8s-ui 1.8.9 → 1.8.11

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 (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -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, Input, 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.
@@ -246,8 +265,7 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
246
265
  </div>
247
266
  <div className="relative">
248
267
  <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-text-tertiary" />
249
- <input
250
- type="text"
268
+ <Input
251
269
  placeholder="Search checks…"
252
270
  value={search}
253
271
  onChange={(e) => filters.setString('q', e.target.value)}
@@ -410,17 +428,22 @@ export interface CheckRemediationBlockProps {
410
428
  layout?: 'columns' | 'stack'
411
429
  }
412
430
 
431
+ // Remediation body: WHY IT MATTERS (info, the description) → HOW TO FIX
432
+ // (wrench/emerald, the remediation + doc links), rendered as icon-led sections
433
+ // for every host. Prose runs through renderProse so `inline-code` spans become
434
+ // mono chips when the catalog copy carries them. The 'stack' layout is the
435
+ // compact single-column variant for narrow hosts.
413
436
  export function CheckRemediationBlock({ description, remediation, references, layout = 'columns' }: CheckRemediationBlockProps) {
414
437
  if (!description && !remediation && (!references || references.length === 0)) return null
415
438
 
416
439
  if (layout === 'stack') {
417
440
  return (
418
441
  <div className="flex flex-col gap-2">
419
- {description && <p className="text-[13px] leading-relaxed text-theme-text-secondary">{description}</p>}
442
+ {description && <p className="text-[13px] leading-relaxed text-theme-text-secondary">{renderProse(description)}</p>}
420
443
  {remediation && (
421
444
  <div>
422
445
  <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>
446
+ <p className="mt-0.5 text-[13px] leading-relaxed text-theme-text-secondary">{renderProse(remediation)}</p>
424
447
  </div>
425
448
  )}
426
449
  {references && references.length > 0 && <CheckReferenceLinks references={references} />}
@@ -429,25 +452,19 @@ export function CheckRemediationBlock({ description, remediation, references, la
429
452
  }
430
453
 
431
454
  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
- </>
455
+ <div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
456
+ {description && (
457
+ <CardSection icon={Info} label="Why it matters" tone="neutral">
458
+ <CardBody>{renderProse(description)}</CardBody>
459
+ </CardSection>
460
+ )}
461
+ {(remediation || (references && references.length > 0)) && (
462
+ <CardSection icon={Wrench} label="How to fix" tone="fix">
463
+ {remediation && <CardBody>{renderProse(remediation)}</CardBody>}
464
+ {references && references.length > 0 && <CheckReferenceLinks references={references} />}
465
+ </CardSection>
466
+ )}
467
+ </div>
451
468
  )
452
469
  }
453
470
 
@@ -500,16 +517,27 @@ export function CheckCardShell({
500
517
  dimmed,
501
518
  }: CheckCardShellProps) {
502
519
  const Container = as
520
+ const sev = normalizeCheckSeverity(severity)
521
+ const SeverityIcon = CHECK_SEVERITY_ICON[sev]
503
522
  return (
504
523
  <Container
505
524
  className={[
506
- 'overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm',
525
+ 'overflow-hidden rounded-xl border bg-theme-surface transition-[border-color,box-shadow] duration-200',
526
+ // The open card lifts via elevation — heavier shadow + a bright
527
+ // emphasis edge that clearly separates it from sibling cards. Severity
528
+ // stays rationed to the band + pill; separation is depth, not more color.
529
+ // ring-1 widens the edge to 2px without the layout shift a border-2
530
+ // swap would cause on expand.
531
+ open ? 'border-[var(--border-emphasis)] ring-1 ring-[var(--border-emphasis)] shadow-theme-md' : 'border-theme-border shadow-theme-sm',
507
532
  dimmed ? 'opacity-60' : '',
508
533
  className ?? '',
509
534
  ]
510
535
  .filter(Boolean)
511
536
  .join(' ')}
512
537
  >
538
+ {/* Leading severity icon is the at-a-glance cue; a trailing chevron shows
539
+ open/closed. Collapsed: neutral row + rail. Expanded: severity-tinted
540
+ band + solid pill — the tint is a focus signal, not per-row alarm. */}
513
541
  <div
514
542
  role="button"
515
543
  tabIndex={0}
@@ -522,31 +550,33 @@ export function CheckCardShell({
522
550
  onToggle()
523
551
  }
524
552
  }}
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]}`}
553
+ 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
554
  >
527
- <ChevronRight className={`mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
555
+ <SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${SEVERITY_TEXT_CLASS[sev]}`} aria-hidden />
528
556
 
529
557
  <div className="flex min-w-0 flex-1 flex-col gap-1.5">
530
558
  <div className="flex flex-wrap items-center gap-2">
531
559
  <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>
560
+ <span className={`shrink-0 ${NEUTRAL_CHIP_CLASS}`}>{category}</span>
533
561
  </div>
534
562
  {description}
535
563
  {summary}
536
564
  </div>
537
565
 
538
- <span className={`badge-sm mt-0.5 shrink-0 text-[10px] font-semibold ${SEVERITY_BADGE_CLASS[severity]}`}>
539
- {SEVERITY_LABEL[severity]}
566
+ <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]}`}>
567
+ {SEVERITY_LABEL[sev]}
540
568
  </span>
541
569
  {renderActions?.()}
570
+ <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
542
571
  </div>
543
572
 
544
573
  {/* Kept mounted (not `open &&`) so the grid-rows transition animates the
545
574
  collapse too, matching IssueRow; inert when closed so SR + tab skip
546
- the clipped content. */}
575
+ the clipped content. Body sits on the card surface (not a recessed grey
576
+ panel) so its text keeps enough contrast. */}
547
577
  <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
548
578
  <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>
579
+ <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
580
  </div>
551
581
  </div>
552
582
  </Container>
@@ -561,7 +591,9 @@ export interface CheckClusterBreakdownGroup {
561
591
  }
562
592
 
563
593
  export interface CheckClusterBreakdownShellProps<T extends CheckClusterBreakdownGroup> {
564
- heading: ReactNode
594
+ /** Optional section heading. Omit when the caller already provides one (e.g.
595
+ * a wrapping CardSection owns the "Affected resources" eyebrow). */
596
+ heading?: ReactNode
565
597
  groups: T[]
566
598
  renderGroupBody: (group: T) => ReactNode
567
599
  clusterCap?: number
@@ -584,7 +616,7 @@ export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>
584
616
 
585
617
  return (
586
618
  <section className="flex flex-col gap-1.5">
587
- <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{heading}</h4>
619
+ {heading ? <h4 className="text-[11px] font-semibold uppercase tracking-[0.06em] text-theme-text-tertiary">{heading}</h4> : null}
588
620
  <ul className="flex flex-col gap-1">
589
621
  {shown.map((group) => {
590
622
  const isOpen = openClusters.has(group.id)
@@ -687,18 +719,17 @@ function FleetCheckRow({
687
719
  >
688
720
  <CheckRemediationBlock description={meta?.description} remediation={meta?.remediation} references={meta?.references} />
689
721
 
690
- <div className="border-t border-theme-border/70 pt-3">
722
+ <CardSection
723
+ icon={Layers}
724
+ label="Affected resources"
725
+ labelExtra={single ? `· ${fc.totalResources}` : `· ${fc.totalResources} · ${clusterCount} clusters`}
726
+ >
691
727
  {single ? (
692
- <ResourceList
693
- label={`Affected resources (${fc.totalResources})`}
694
- check={fc.clusters[0]}
695
- resourceHref={resourceHref}
696
- onResourceClick={onResourceClick}
697
- />
728
+ <ResourceList check={fc.clusters[0]} resourceHref={resourceHref} onResourceClick={onResourceClick} />
698
729
  ) : (
699
730
  <ClusterBreakdown fc={fc} clusterLabel={clusterLabel} resourceHref={resourceHref} onResourceClick={onResourceClick} />
700
731
  )}
701
- </div>
732
+ </CardSection>
702
733
  </CheckCardShell>
703
734
  )
704
735
  }
@@ -720,11 +751,8 @@ function ClusterBreakdown({
720
751
  }) {
721
752
  return (
722
753
  <CheckClusterBreakdownShell
723
- heading={
724
- <>
725
- Affected resources <span className="tabular-nums">({fc.totalResources})</span> · {fc.clusters.length} clusters
726
- </>
727
- }
754
+ // Heading omitted — the wrapping CardSection (Layers · "Affected
755
+ // resources") already owns the eyebrow.
728
756
  groups={fc.clusters.map((c) => ({
729
757
  id: c.subject.cluster_id,
730
758
  label: <ClusterName name={clusterLabel?.(c) || c.subject.cluster_id} />,
@@ -822,7 +850,9 @@ function FindingLine({
822
850
  {showMessage && <span className="ml-1 truncate text-xs text-theme-text-tertiary">{finding.message}</span>}
823
851
  </>
824
852
  )
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'
853
+ // items-baseline so the smaller mono kind label shares a baseline with the
854
+ // larger resource name (their line-heights differ).
855
+ 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
856
  return (
827
857
  <li>
828
858
  {onResourceClick ? (
@@ -1,9 +1,33 @@
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
- // 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.
13
+ // red=critical, orange=high, yellow=medium, neutral=low — read the queue's left
14
+ // rail top-to-bottom and severity is obvious without reading a word. Medium is
15
+ // yellow (not amber) so it sits a clear hue-step below high's orange rather than
16
+ // blurring into it. The color strings are shared with the Issue card via the
17
+ // tone module; here we only map each tier onto its tone.
18
+ const CHECK_SEVERITY_TONE: Record<CheckSeverity, SeverityTone> = {
19
+ critical: 'red',
20
+ high: 'orange',
21
+ medium: 'yellow',
22
+ low: 'slate',
23
+ }
24
+
25
+ const byTone = <T,>(toneMap: Record<SeverityTone, T>): Record<CheckSeverity, T> => ({
26
+ critical: toneMap[CHECK_SEVERITY_TONE.critical],
27
+ high: toneMap[CHECK_SEVERITY_TONE.high],
28
+ medium: toneMap[CHECK_SEVERITY_TONE.medium],
29
+ low: toneMap[CHECK_SEVERITY_TONE.low],
30
+ })
7
31
 
8
32
  export const SEVERITY_LABEL: Record<CheckSeverity, string> = {
9
33
  critical: 'Critical',
@@ -12,52 +36,22 @@ export const SEVERITY_LABEL: Record<CheckSeverity, string> = {
12
36
  low: 'Low',
13
37
  }
14
38
 
15
- // Pill badge — the loud, explicit severity signal on rows + drawer header.
16
- // Uses the canonical Badge severity tones (BADGE_SEVERITY_COLORS) so the queue's
17
- // severity pills read identically to status badges everywhere else (rendered
18
- // with `badge-sm`).
39
+ // Soft pill badge — the loud, explicit severity signal on rows + drawer header.
40
+ // Critical/high/low reuse the canonical Badge tones; medium takes an explicit
41
+ // yellow (matching its tone above) rather than the shared amber `warning` tone,
42
+ // so it reads a clear step below high's orange without shifting the amber
43
+ // `warning` badge used elsewhere (Issues, GitOps). yellow-800 on yellow-100
44
+ // clears WCAG for the pill text.
19
45
  export const SEVERITY_BADGE_CLASS: Record<CheckSeverity, string> = {
20
46
  critical: sev.error,
21
47
  high: sev.alert,
22
- medium: sev.warning,
48
+ medium:
49
+ 'bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-950/50 dark:text-yellow-400 dark:border-yellow-700/40',
23
50
  low: sev.neutral,
24
51
  }
25
52
 
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
- }
53
+ export const SEVERITY_FILL_CLASS = byTone(TONE_FILL_CLASS)
54
+ export const SEVERITY_TEXT_CLASS = byTone(TONE_TEXT_CLASS)
55
+ export const SEVERITY_RAIL_CLASS = byTone(TONE_RAIL_CLASS)
56
+ export const SEVERITY_SOLID_CLASS = byTone(TONE_SOLID_CLASS)
57
+ export const SEVERITY_HEADER_BAND_CLASS = byTone(TONE_HEADER_BAND_CLASS)
@@ -9,6 +9,7 @@ import {
9
9
  } from 'react'
10
10
  import { ChevronDown, Check, FolderOpen, Loader2, Search, Server, X } from 'lucide-react'
11
11
  import { ClusterName } from '../ui/ClusterName'
12
+ import { Input } from '../ui/Input'
12
13
  import { MiddleEllipsis } from '../ui/MiddleEllipsis'
13
14
  import { StatusDot, type StatusTone } from '../ui/status-tone'
14
15
 
@@ -269,9 +270,8 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
269
270
  <div className="p-2 border-b border-theme-border">
270
271
  <div className="relative">
271
272
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
272
- <input
273
+ <Input
273
274
  ref={searchInputRef}
274
- type="text"
275
275
  value={search}
276
276
  onChange={e => setSearch(e.target.value)}
277
277
  onKeyDown={onSearchKeyDown}
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
  import { GitCompare, Search, X } from 'lucide-react'
4
4
  import { DialogPortal } from '../ui/DialogPortal'
5
+ import { Input } from '../ui/Input'
5
6
  import { pluralToKind } from '../../utils/navigation'
6
7
  import type { CompareResourceRef } from './ResourceCompareView'
7
8
  import { sortCandidates, filterCandidates } from './sort'
@@ -120,9 +121,8 @@ export function CompareResourcePicker({
120
121
  </div>
121
122
  <div className="relative">
122
123
  <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-theme-text-tertiary pointer-events-none" />
123
- <input
124
+ <Input
124
125
  autoFocus
125
- type="text"
126
126
  value={query}
127
127
  onChange={e => setQuery(e.target.value)}
128
128
  onKeyDown={handleKeyDown}
@@ -1,6 +1,7 @@
1
1
  import { CheckCircle2, CircleDot, GitBranch, HeartPulse, LayoutGrid, List, Search } from 'lucide-react'
2
2
 
3
3
  import { GitOpsFacetButton, GitOpsFilterSection } from './GitOpsTableView'
4
+ import { Input } from '../ui/Input'
4
5
  import type { GitOpsResourceTree } from '../../types/gitops-tree'
5
6
  import type { GitOpsTreePreset } from './tree'
6
7
 
@@ -79,7 +80,7 @@ export function GitOpsGraphFilterRail({
79
80
  <div className="border-b border-theme-border px-3 py-3">
80
81
  <div className="relative">
81
82
  <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-theme-text-tertiary" />
82
- <input
83
+ <Input
83
84
  value={search}
84
85
  onChange={(event) => onSearchChange(event.target.value)}
85
86
  placeholder="Filter resources..."
@@ -26,12 +26,14 @@ import {
26
26
 
27
27
  import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
28
28
  import { Tooltip } from '../ui/Tooltip'
29
+ import { Input } from '../ui/Input'
29
30
  import { PageHeader } from '../ui/PageHeader'
30
31
  import { SummaryTile, type SummaryTone } from '../ui/SummaryTile'
31
32
  import { FacetSection, FacetButton } from '../ui/Facet'
32
33
  import { SortableTh, TH_CLASS, type SortDir } from '../ui/SortableTh'
33
34
  import { DistributionBar } from '../ui/DistributionBar'
34
35
  import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
36
+ import { BoardTableSkeleton, BoardRailSkeleton } from '../ui/BoardSkeleton'
35
37
  import { getGitOpsResourceStatus } from './detail-helpers'
36
38
  import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
37
39
  import { toggleSet } from './GitOpsGraphFilterRail'
@@ -686,7 +688,7 @@ export function GitOpsTableView({
686
688
  <div className="mt-3 flex flex-wrap items-center gap-2">
687
689
  <div className="relative w-full max-w-md">
688
690
  <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-theme-text-tertiary" />
689
- <input
691
+ <Input
690
692
  ref={searchInputRef}
691
693
  value={search}
692
694
  onChange={(e) => filters.setString('q', e.target.value)}
@@ -766,7 +768,7 @@ export function GitOpsTableView({
766
768
  {modeLabel(mode)} view is queued behind the application list.
767
769
  </div>
768
770
  ) : initialLoading ? (
769
- <GitOpsTableSkeleton />
771
+ <BoardTableSkeleton />
770
772
  ) : error ? (
771
773
  <div className="p-4 text-sm text-red-500">Failed to load GitOps applications: {error.message}</div>
772
774
  ) : filteredRows.length === 0 ? (
@@ -816,60 +818,6 @@ export function GitOpsTableView({
816
818
  // GitOpsTableView's visual language and not generally useful elsewhere.
817
819
  // =============================================================================
818
820
 
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
821
  function GitOpsFilterSidebar({
874
822
  loading,
875
823
  side,
@@ -936,7 +884,15 @@ function GitOpsFilterSidebar({
936
884
  </div>
937
885
  <div className="flex-1 overflow-y-auto">
938
886
  {loading ? (
939
- <GitOpsSidebarSkeleton />
887
+ <BoardRailSkeleton
888
+ sections={[
889
+ ['Sync', 4],
890
+ ['Health', 5],
891
+ ['Automation', 3],
892
+ ['Projects', 4],
893
+ ['Namespaces', 4],
894
+ ]}
895
+ />
940
896
  ) : (
941
897
  <>
942
898
  {AVAILABLE_MODES.length > 1 && (
@@ -1174,8 +1130,7 @@ function LabelsDropdown({
1174
1130
  <div className="flex items-center gap-2">
1175
1131
  <div className="relative flex-1">
1176
1132
  <Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-theme-text-tertiary" />
1177
- <input
1178
- type="text"
1133
+ <Input
1179
1134
  value={search}
1180
1135
  onChange={(e) => onSearchChange(e.target.value)}
1181
1136
  placeholder="Search labels..."
@@ -1,9 +1,10 @@
1
1
  import { useState } from 'react'
2
- import { ChevronDown, ChevronRight, Package, ExternalLink } from 'lucide-react'
2
+ import { Package, ExternalLink } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import type { ManagedResource, GitOpsHealthStatus } from '../../types/gitops'
5
5
  import { groupManagedResourcesByKind } from '../../types/gitops'
6
6
  import { pluralize } from '../../utils/pluralize'
7
+ import { Collapse, CollapseChevron } from '../ui/Collapse'
7
8
 
8
9
  interface ManagedResourcesListProps {
9
10
  resources: ManagedResource[]
@@ -45,18 +46,14 @@ export function ManagedResourcesList({
45
46
  onClick={() => setExpanded(!expanded)}
46
47
  className="flex items-center gap-2 w-full text-left mb-2 hover:text-theme-text-primary transition-colors"
47
48
  >
48
- {expanded ? (
49
- <ChevronDown className="w-4 h-4 text-theme-text-tertiary" />
50
- ) : (
51
- <ChevronRight className="w-4 h-4 text-theme-text-tertiary" />
52
- )}
49
+ <CollapseChevron open={expanded} className="w-4 h-4" />
53
50
  <Package className="w-4 h-4 text-theme-text-secondary" />
54
51
  <span className="text-sm font-medium text-theme-text-secondary">
55
52
  {title} ({resources.length})
56
53
  </span>
57
54
  </button>
58
55
 
59
- {expanded && (
56
+ <Collapse open={expanded} mountLazily>
60
57
  <div className="pl-6 space-y-3">
61
58
  {sortedGroups.map(([kind, kindResources]) => (
62
59
  <ResourceKindGroup
@@ -77,7 +74,7 @@ export function ManagedResourcesList({
77
74
  </button>
78
75
  )}
79
76
  </div>
80
- )}
77
+ </Collapse>
81
78
  </div>
82
79
  )
83
80
  }
@@ -98,12 +95,12 @@ function ResourceKindGroup({ kind, resources, onResourceClick, showHealth }: Res
98
95
  onClick={() => setExpanded(!expanded)}
99
96
  className="flex items-center gap-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors mb-1"
100
97
  >
101
- {expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
98
+ <CollapseChevron open={expanded} className="w-3 h-3" />
102
99
  <span className="font-medium">{kind}</span>
103
100
  <span className="text-theme-text-tertiary">({resources.length})</span>
104
101
  </button>
105
102
 
106
- {expanded && (
103
+ <Collapse open={expanded} mountLazily>
107
104
  <div className="ml-4 space-y-0.5">
108
105
  {resources.map((resource, idx) => (
109
106
  <ResourceItem
@@ -114,7 +111,7 @@ function ResourceKindGroup({ kind, resources, onResourceClick, showHealth }: Res
114
111
  />
115
112
  ))}
116
113
  </div>
117
- )}
114
+ </Collapse>
118
115
  </div>
119
116
  )
120
117
  }
@@ -93,23 +93,32 @@ function parseInterval(interval: string): number | null {
93
93
  if (!interval) return null
94
94
 
95
95
  let totalSeconds = 0
96
- const regex = /(\d+)(h|m|s)/g
97
- let match
98
-
99
- while ((match = regex.exec(interval)) !== null) {
100
- const value = parseInt(match[1], 10)
101
- const unit = match[2]
102
-
103
- switch (unit) {
104
- case 'h':
105
- totalSeconds += value * 3600
106
- break
107
- case 'm':
108
- totalSeconds += value * 60
109
- break
110
- case 's':
111
- totalSeconds += value
112
- break
96
+ let index = 0
97
+
98
+ while (index < interval.length) {
99
+ const start = index
100
+ while (index < interval.length) {
101
+ const code = interval.charCodeAt(index)
102
+ if (code < 48 || code > 57) break
103
+ index += 1
104
+ }
105
+ if (start === index) {
106
+ index += 1
107
+ continue
108
+ }
109
+ const value = parseInt(interval.slice(start, index), 10)
110
+ const unit = interval[index]
111
+ if (unit === 'h') {
112
+ totalSeconds += value * 3600
113
+ index += 1
114
+ } else if (unit === 'm') {
115
+ totalSeconds += value * 60
116
+ index += 1
117
+ } else if (unit === 's') {
118
+ totalSeconds += value
119
+ index += 1
120
+ } else if (index < interval.length) {
121
+ index += 1
113
122
  }
114
123
  }
115
124