@skyhook-io/k8s-ui 1.8.10 → 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 (116) 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/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +3 -3
  17. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  18. package/src/components/gitops/SyncCountdown.tsx +26 -17
  19. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  20. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  21. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  22. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  23. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  24. package/src/components/issues/IssuesView.tsx +51 -38
  25. package/src/components/issues/ResourceIssuesSection.test.tsx +6 -5
  26. package/src/components/issues/ResourceIssuesSection.tsx +28 -146
  27. package/src/components/issues/index.ts +3 -0
  28. package/src/components/issues/issue-timing.ts +53 -0
  29. package/src/components/issues/issues.test.ts +102 -1
  30. package/src/components/issues/severity.ts +2 -0
  31. package/src/components/logs/LogCore.tsx +14 -4
  32. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  33. package/src/components/logs/useLogStream.ts +8 -1
  34. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  35. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  36. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  37. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  38. package/src/components/resources/ResourcesView.tsx +233 -51
  39. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  41. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  42. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  44. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  45. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  46. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  48. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  49. package/src/components/resources/renderers/index.ts +1 -0
  50. package/src/components/resources/resource-utils-capi.ts +1 -1
  51. package/src/components/resources/resource-utils.ts +31 -0
  52. package/src/components/shared/DetailShell.tsx +2 -2
  53. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  54. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  55. package/src/components/timeline/TimelineList.tsx +371 -354
  56. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  57. package/src/components/timeline/TimelineStrip.tsx +836 -0
  58. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  59. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  60. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  61. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  62. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  63. package/src/components/timeline/index.ts +23 -0
  64. package/src/components/timeline/scrubber-math.test.ts +235 -0
  65. package/src/components/timeline/scrubber-math.ts +249 -0
  66. package/src/components/timeline/shared.test.tsx +219 -0
  67. package/src/components/timeline/shared.tsx +232 -447
  68. package/src/components/timeline/timeline-filters.test.ts +196 -0
  69. package/src/components/timeline/timeline-filters.ts +180 -0
  70. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  71. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  72. package/src/components/timeline/timeline-live.test.ts +131 -0
  73. package/src/components/timeline/timeline-live.ts +77 -0
  74. package/src/components/topology/GroupNode.tsx +9 -2
  75. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  76. package/src/components/topology/K8sResourceNode.tsx +387 -243
  77. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  78. package/src/components/topology/TopologyGraph.tsx +89 -16
  79. package/src/components/topology/TopologySearch.tsx +2 -2
  80. package/src/components/topology/layout.ts +10 -2
  81. package/src/components/topology/topology.css +0 -92
  82. package/src/components/ui/Badge.tsx +5 -0
  83. package/src/components/ui/CodeViewer.tsx +2 -2
  84. package/src/components/ui/Collapse.tsx +59 -0
  85. package/src/components/ui/ConfirmDialog.tsx +7 -3
  86. package/src/components/ui/DialogPortal.tsx +31 -9
  87. package/src/components/ui/Input.tsx +24 -0
  88. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  89. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  90. package/src/components/ui/PaneLoader.tsx +10 -2
  91. package/src/components/ui/ResourceBar.tsx +68 -25
  92. package/src/components/ui/SearchBox.tsx +18 -4
  93. package/src/components/ui/SearchPillInput.tsx +2 -2
  94. package/src/components/ui/drawer-components.tsx +39 -26
  95. package/src/components/ui/index.ts +4 -0
  96. package/src/components/ui/severity-tone.ts +11 -4
  97. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  98. package/src/components/workload/WorkloadView.tsx +2682 -472
  99. package/src/components/workload/index.ts +1 -1
  100. package/src/theme/variables.css +1 -1
  101. package/src/types/core.ts +57 -4
  102. package/src/utils/application-topology.test.ts +198 -0
  103. package/src/utils/application-topology.ts +261 -0
  104. package/src/utils/applications.test.ts +167 -1
  105. package/src/utils/applications.ts +1157 -393
  106. package/src/utils/index.ts +2 -0
  107. package/src/utils/navigation.test.ts +32 -1
  108. package/src/utils/navigation.ts +70 -0
  109. package/src/utils/resource-hierarchy.test.ts +990 -1
  110. package/src/utils/resource-hierarchy.ts +905 -267
  111. package/src/utils/resource-icons.ts +2 -0
  112. package/src/utils/topology-neighborhood.test.ts +202 -1
  113. package/src/utils/topology-neighborhood.ts +194 -13
  114. package/src/utils/workflow-execution.test.ts +556 -0
  115. package/src/utils/workflow-execution.ts +814 -0
  116. package/src/utils/workload-colors.ts +2 -3
@@ -17,6 +17,7 @@ import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
17
17
  import { pluralize } from '../../utils/pluralize'
18
18
  import {
19
19
  type AppEntry,
20
+ type AppRow,
20
21
  type AppHealth,
21
22
  type AppWorkloadClass,
22
23
  type AppSource,
@@ -36,12 +37,34 @@ import {
36
37
  isSystemNamespace,
37
38
  searchTextForEntry,
38
39
  foldAppGroups,
40
+ batchSignalForApp,
41
+ batchRuntimeForApp,
39
42
  type FoldedRow,
40
43
  } from '../../utils/applications'
41
44
  import { ReadyBar } from './ReadyBar'
42
- import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
45
+ import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips'
43
46
  import { AppIdentityTooltip, EnvHint } from './AppTooltips'
44
47
 
48
+ function ApplicationRuntimeCell({ apps, workloadClass, ready, desired }: { apps: AppRow[]; workloadClass: AppWorkloadClass; ready: number; desired: number }) {
49
+ if (workloadClass !== 'job') return <ReadyBar ready={ready} desired={desired} />
50
+ const runtimes = apps.map(batchRuntimeForApp)
51
+ const worst = runtimes.reduce((current, runtime) => (HEALTH_RANK[runtime.health] ?? 0) > (HEALTH_RANK[current.health] ?? 0) ? runtime : current, runtimes[0])
52
+ if (!worst) return <span className="text-xs text-theme-text-tertiary">Idle</span>
53
+ const same = runtimes.every((runtime) => runtime.label === worst.label)
54
+ const label = same ? worst.label : `${runtimes.length} states`
55
+ return (
56
+ <Tooltip content={runtimes.map((runtime, index) => `${apps[index]?.name ?? 'app'}: ${runtime.label} — ${runtime.detail}`).join('\n')} delay={150}>
57
+ <span className="inline-flex items-center gap-1.5 text-xs text-theme-text-secondary"><StatusDot tone={mapHealthToTone(worst.health)} />{label}</span>
58
+ </Tooltip>
59
+ )
60
+ }
61
+
62
+ function applicationRuntimeLabel(apps: AppRow[], workloadClass: AppWorkloadClass, health: AppHealth): string {
63
+ if (workloadClass !== 'job') return HEALTH_META[health].label
64
+ const labels = Array.from(new Set(apps.map((app) => batchRuntimeForApp(app).label)))
65
+ return labels.length === 1 ? labels[0] : 'Multiple batch states'
66
+ }
67
+
45
68
  // ApplicationsView — the shared, variant-agnostic core behind the Applications
46
69
  // surface. It owns the entire list chassis: a health hero header (PageHeader +
47
70
  // SummaryTiles + DistributionBar), a left facet rail (Availability / Class /
@@ -52,7 +75,7 @@ import { AppIdentityTooltip, EnvHint } from './AppTooltips'
52
75
  // the per-instance row. Styling mirrors the Resources table so the surfaces
53
76
  // read as one design.
54
77
 
55
- // Availability is the one status facet — map app health onto the shared facet
78
+ // Status is the one health facet — map app health onto the shared facet
56
79
  // tone so its dots read red/amber/green/grey like the GitOps sync+health facets.
57
80
  const HEALTH_TONE: Record<AppHealth, FacetTone> = {
58
81
  unhealthy: 'error',
@@ -72,7 +95,7 @@ function compareEntries(a: AppEntry, b: AppEntry, key: SortKey): number {
72
95
  case 'name':
73
96
  return a.row.name.localeCompare(b.row.name)
74
97
  case 'ready':
75
- return a.readyRatio - b.readyRatio
98
+ return runtimeSortValue(a) - runtimeSortValue(b)
76
99
  case 'version': {
77
100
  // Sort by distinct-version count first (skewed apps cluster), then the
78
101
  // first tag for a stable, human-meaningful order.
@@ -83,6 +106,10 @@ function compareEntries(a: AppEntry, b: AppEntry, key: SortKey): number {
83
106
  }
84
107
  }
85
108
 
109
+ function runtimeSortValue(entry: AppEntry): number {
110
+ return entry.workloadClass === 'job' ? (HEALTH_RANK[entry.health] ?? 0) : entry.readyRatio
111
+ }
112
+
86
113
  // The env token an entry filters under. Single entries carry one env; fleet
87
114
  // entries carry several — a fleet row matches an env facet if ANY of its slices
88
115
  // do (the same inclusive policy as the Class facet).
@@ -348,7 +375,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
348
375
  {initialLoading ? (
349
376
  <BoardRailSkeleton
350
377
  sections={[
351
- ['Availability', 5],
378
+ ['Status', 5],
352
379
  ['Class', 3],
353
380
  ['Type', 4],
354
381
  ['Environment', 3],
@@ -357,7 +384,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
357
384
  />
358
385
  ) : (
359
386
  <>
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)} />
387
+ <Facet icon={HeartPulse} title="Status" 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)} />
361
388
  <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)} />
362
389
  <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)} />
363
390
  <Facet icon={Globe} title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => filters.toggle('env', v)} />
@@ -430,7 +457,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
430
457
  </>
431
458
  )}
432
459
  <th className={TH_CLASS}>Class</th>
433
- <SortableTh label="Ready" sortKey="ready" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} />
460
+ <SortableTh label="Runtime" sortKey="ready" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} />
434
461
  <SortableTh label="Version" sortKey="version" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} />
435
462
  <th className={TH_CLASS}>Workloads</th>
436
463
  <th className={clsx(TH_CLASS, 'w-8')} />
@@ -452,11 +479,12 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
452
479
  <span className="flex items-center gap-2">
453
480
  {/* Left status stripe (worst-child health) — the row status
454
481
  gutter shared with the GitOps table. */}
455
- <Tooltip content={HEALTH_META[r.health].label} delay={150}>
482
+ <Tooltip content={applicationRuntimeLabel(r.members.map((member) => member.row), r.workloadClass, r.health)} delay={150}>
456
483
  <span className={clsx('h-8 w-1 shrink-0 rounded-full', HEALTH_META[r.health].bar)} />
457
484
  </Tooltip>
458
485
  <ChevronRight className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', r.expanded && 'rotate-90')} aria-hidden />
459
486
  <span className="truncate font-semibold text-theme-text-primary">{r.label}</span>
487
+ {r.workloadClass !== 'job' && <BatchSignalChip signal={firstBatchSignal(r.members.map((m) => m.row))} />}
460
488
  <Tooltip
461
489
  content={<AppIdentityTooltip identityKey={r.label} source={r.members[0]?.row.identity?.source} portable={r.members[0]?.row.identity?.portable} fleet={variant === 'fleet'} members={r.members.map((m) => ({ name: m.row.name, env: m.row.identity!.env, confidence: m.row.identity!.confidence, evidence: m.row.identity!.evidence }))} />}
462
490
  delay={150}
@@ -504,7 +532,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
504
532
  </span>
505
533
  </td>
506
534
  <td className="px-2 py-2.5"><ClassBadge workloadClass={r.workloadClass} composition={r.classComposition} /></td>
507
- <td className="px-2 py-2.5"><ReadyBar ready={r.ready} desired={r.desired} /></td>
535
+ <td className="px-2 py-2.5"><ApplicationRuntimeCell apps={r.members.map((member) => member.row)} workloadClass={r.workloadClass} ready={r.ready} desired={r.desired} /></td>
508
536
  <td className="px-2 py-2.5">
509
537
  {r.lag ? (
510
538
  <Tooltip content={`Promotion lag: ${r.lag} (${r.cells.filter((c) => c.version).map((c) => `${c.env}=${c.version}`).join(', ')})`} delay={150}>
@@ -531,10 +559,11 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
531
559
  >
532
560
  <td className={clsx('py-2.5 pr-2', r.child ? 'pl-10' : 'pl-3')}>
533
561
  <span className="flex items-center gap-2">
534
- <Tooltip content={HEALTH_META[e.health].label} delay={150}>
562
+ <Tooltip content={applicationRuntimeLabel([e.row], e.workloadClass, e.health)} delay={150}>
535
563
  <span className={clsx('h-8 w-1 shrink-0 rounded-full', HEALTH_META[e.health].bar)} />
536
564
  </Tooltip>
537
565
  <span className="truncate font-medium text-theme-text-primary">{e.row.name}</span>
566
+ {e.workloadClass !== 'job' && <BatchSignalChip signal={batchSignalForApp(e.row)} />}
538
567
  <ProvenanceBadge tier={e.row.tier} appKey={e.row.key} confidence={e.row.confidence} />
539
568
  <CategoryChip category={e.category} addonReason={e.row.addonReason} />
540
569
  </span>
@@ -601,7 +630,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
601
630
  </>
602
631
  )}
603
632
  <td className="px-2 py-2.5"><ClassBadge workloadClass={e.workloadClass} composition={e.classComposition} /></td>
604
- <td className="px-2 py-2.5"><ReadyBar ready={e.ready} desired={e.desired} /></td>
633
+ <td className="px-2 py-2.5"><ApplicationRuntimeCell apps={[e.row]} workloadClass={e.workloadClass} ready={e.ready} desired={e.desired} /></td>
605
634
  <td className="px-2 py-2.5">
606
635
  {e.variant === 'fleet' ? (
607
636
  e.versionSkew ? (
@@ -640,3 +669,26 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
640
669
  </div>
641
670
  )
642
671
  }
672
+
673
+ function firstBatchSignal(rows: AppRow[]) {
674
+ let best = null as ReturnType<typeof batchSignalForApp>
675
+ const rank = (tone: string | undefined) => {
676
+ switch (tone) {
677
+ case 'rose':
678
+ return 5
679
+ case 'amber':
680
+ return 4
681
+ case 'sky':
682
+ return 3
683
+ case 'emerald':
684
+ return 2
685
+ default:
686
+ return 0
687
+ }
688
+ }
689
+ for (const row of rows) {
690
+ const signal = batchSignalForApp(row)
691
+ if (signal && rank(signal.tone) > rank(best?.tone)) best = signal
692
+ }
693
+ return best
694
+ }
@@ -67,6 +67,53 @@ describe('buildSingleAppEntry', () => {
67
67
  expect(e.env).toBe('staging')
68
68
  expect(e.envInferred).toBe(false)
69
69
  })
70
+
71
+ it('uses delivery degradation in the displayed application health', () => {
72
+ const e = buildSingleAppEntry(app({
73
+ health: 'degraded',
74
+ runtimeHealth: 'healthy',
75
+ sourceStatus: { sync: 'Synced', health: 'Degraded' },
76
+ }))
77
+
78
+ expect(e.health).toBe('degraded')
79
+ })
80
+
81
+ it('preserves the latest retained batch outcome when delivery adds no degradation', () => {
82
+ const e = buildSingleAppEntry(app({
83
+ health: 'unhealthy',
84
+ runtimeHealth: 'unhealthy',
85
+ workload_class: 'job',
86
+ workloads: [wl({
87
+ kind: 'CronJob',
88
+ workload_class: 'job',
89
+ health: 'unhealthy',
90
+ ready: 0,
91
+ desired: 0,
92
+ batch: { retainedRuns: 2, failedRuns: 1, succeededRuns: 1, latestRunPhase: 'Succeeded' },
93
+ })],
94
+ }))
95
+
96
+ expect(e.health).toBe('healthy')
97
+ })
98
+
99
+ it('applies delivery degradation to a batch app with a successful latest run', () => {
100
+ const e = buildSingleAppEntry(app({
101
+ health: 'unhealthy',
102
+ runtimeHealth: 'unhealthy',
103
+ workload_class: 'job',
104
+ sourceStatus: { sync: 'Synced', health: 'Degraded' },
105
+ workloads: [wl({
106
+ kind: 'CronJob',
107
+ workload_class: 'job',
108
+ health: 'unhealthy',
109
+ ready: 0,
110
+ desired: 0,
111
+ batch: { retainedRuns: 2, failedRuns: 1, succeededRuns: 1, latestRunPhase: 'Succeeded' },
112
+ })],
113
+ }))
114
+
115
+ expect(e.health).toBe('degraded')
116
+ })
70
117
  })
71
118
 
72
119
  describe('searchTextForEntry (single)', () => {
@@ -7,7 +7,7 @@ export { buildSingleAppEntry } from '../../utils/applications'
7
7
  // Facet moved to the shared ui/ primitives; re-exported here for compatibility.
8
8
  export { Facet } from '../ui/Facet'
9
9
  export { ApplicationDetail } from './ApplicationDetail'
10
- export type { ApplicationDetailProps, SelectedAppWorkload, AppIdentityInstance } from './ApplicationDetail'
10
+ export type { ApplicationDetailProps, SelectedAppWorkload, AppIdentityInstance, ApplicationView } from './ApplicationDetail'
11
11
  export { CenteredEmpty } from '../ui/CenteredEmpty'
12
12
  export { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
13
13
  export { ProvenanceTooltip, CategoryTooltip, VersionTooltip, AppIdentityTooltip } from './AppTooltips'
@@ -41,14 +41,15 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
41
41
  const warnings = findings.filter(f => f.severity === 'warning').length
42
42
 
43
43
  return (
44
- <div className="border-b-subtle pb-4 last:border-0">
44
+ <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
45
45
  <button
46
+ type="button"
46
47
  onClick={() => setExpanded(!expanded)}
47
- className="flex items-center gap-2 w-full text-left mb-2 hover:text-theme-text-primary transition-colors"
48
+ className="flex w-full items-center gap-2 rounded-md text-left transition-colors hover:text-theme-text-primary"
48
49
  >
49
50
  <ChevronRight className={clsx('w-4 h-4 text-theme-text-tertiary transition-transform duration-200', expanded && 'rotate-90')} />
50
51
  <ClipboardCheck className="w-4 h-4 text-theme-text-secondary" />
51
- <span className="text-sm font-medium text-theme-text-secondary">Audit Findings</span>
52
+ <span className="text-sm font-semibold text-theme-text-primary">Audit Findings</span>
52
53
  <div className="flex items-center gap-2 ml-1">
53
54
  {dangers > 0 && (
54
55
  <span className={clsx('text-xs font-medium tabular-nums', SEVERITY_TEXT.error)}>{dangers} critical</span>
@@ -64,7 +65,7 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
64
65
  style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
65
66
  >
66
67
  <div className="overflow-hidden">
67
- <div className="pl-6 pt-1 flex flex-col gap-0.5">
68
+ <div className="flex flex-col gap-0.5 pt-3">
68
69
  {findings.map((f, i) => {
69
70
  const isDanger = f.severity === 'danger'
70
71
  return (
@@ -74,17 +75,20 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
74
75
  ) : (
75
76
  <AlertTriangle className={clsx('w-3.5 h-3.5 shrink-0 mt-0.5', SEVERITY_TEXT.warning)} />
76
77
  )}
77
- <span className="text-xs text-theme-text-secondary flex-1">{f.message}</span>
78
- <span className={clsx('badge-sm text-[10px] shrink-0', BP_CATEGORY_BADGE[f.category] || DEFAULT_BADGE_COLOR)}>
79
- {f.category}
80
- </span>
78
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
79
+ <span className="text-xs text-theme-text-secondary">{f.message}</span>
80
+ <span className={clsx('badge-sm text-[10px] shrink-0', BP_CATEGORY_BADGE[f.category] || DEFAULT_BADGE_COLOR)}>
81
+ {f.category}
82
+ </span>
83
+ </div>
81
84
  </div>
82
85
  )
83
86
  })}
84
87
  {onViewAll && (
85
88
  <button
89
+ type="button"
86
90
  onClick={(e) => { e.stopPropagation(); onViewAll() }}
87
- className="flex items-center gap-1 text-xs text-skyhook-500 hover:text-skyhook-400 mt-1 py-1 transition-colors"
91
+ className="mt-1 flex items-center gap-1 py-1 text-xs text-accent-text transition-colors hover:text-accent"
88
92
  >
89
93
  View all findings
90
94
  <ArrowRight className="w-3 h-3" />
@@ -93,6 +97,6 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
93
97
  </div>
94
98
  </div>
95
99
  </div>
96
- </div>
100
+ </section>
97
101
  )
98
102
  }
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
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'
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'
@@ -265,8 +265,7 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
265
265
  </div>
266
266
  <div className="relative">
267
267
  <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-text-tertiary" />
268
- <input
269
- type="text"
268
+ <Input
270
269
  placeholder="Search checks…"
271
270
  value={search}
272
271
  onChange={(e) => filters.setString('q', e.target.value)}
@@ -10,14 +10,15 @@ import {
10
10
  } from '../ui/severity-tone'
11
11
 
12
12
  // The visual language for the 4-tier Checks severity ladder. One hue per tier:
13
- // red=critical, orange=high, amber=medium, neutral=low — read the queue's left
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.
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.
17
18
  const CHECK_SEVERITY_TONE: Record<CheckSeverity, SeverityTone> = {
18
19
  critical: 'red',
19
20
  high: 'orange',
20
- medium: 'amber',
21
+ medium: 'yellow',
21
22
  low: 'slate',
22
23
  }
23
24
 
@@ -35,14 +36,17 @@ export const SEVERITY_LABEL: Record<CheckSeverity, string> = {
35
36
  low: 'Low',
36
37
  }
37
38
 
38
- // Pill badge — the loud, explicit severity signal on rows + drawer header.
39
- // Uses the canonical Badge severity tones (BADGE_SEVERITY_COLORS) so the queue's
40
- // severity pills read identically to status badges everywhere else (rendered
41
- // 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.
42
45
  export const SEVERITY_BADGE_CLASS: Record<CheckSeverity, string> = {
43
46
  critical: sev.error,
44
47
  high: sev.alert,
45
- 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',
46
50
  low: sev.neutral,
47
51
  }
48
52
 
@@ -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,6 +26,7 @@ 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'
@@ -687,7 +688,7 @@ export function GitOpsTableView({
687
688
  <div className="mt-3 flex flex-wrap items-center gap-2">
688
689
  <div className="relative w-full max-w-md">
689
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" />
690
- <input
691
+ <Input
691
692
  ref={searchInputRef}
692
693
  value={search}
693
694
  onChange={(e) => filters.setString('q', e.target.value)}
@@ -1129,8 +1130,7 @@ function LabelsDropdown({
1129
1130
  <div className="flex items-center gap-2">
1130
1131
  <div className="relative flex-1">
1131
1132
  <Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-theme-text-tertiary" />
1132
- <input
1133
- type="text"
1133
+ <Input
1134
1134
  value={search}
1135
1135
  onChange={(e) => onSearchChange(e.target.value)}
1136
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
 
@@ -2,6 +2,7 @@ import { useState, useEffect, type ComponentType } from 'react'
2
2
  import { Loader2, RefreshCw } from 'lucide-react'
3
3
 
4
4
  import { DialogPortal } from '../ui/DialogPortal'
5
+ import { Input } from '../ui/Input'
5
6
 
6
7
  // =============================================================================
7
8
  // SyncOptionsDialog — Argo CD Sync drawer, shared between per-cluster
@@ -83,8 +84,7 @@ export function SyncOptionsDialog({ open, appLabel, pending, onCancel, onConfirm
83
84
  <div className="space-y-4 px-4 py-4 text-sm">
84
85
  <label className="block">
85
86
  <span className="text-xs font-medium text-theme-text-secondary">Revision (optional)</span>
86
- <input
87
- type="text"
87
+ <Input
88
88
  value={revision}
89
89
  onChange={(e) => setRevision(e.target.value)}
90
90
  placeholder="HEAD"