@skyhook-io/k8s-ui 1.7.14 → 1.8.0

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 (48) hide show
  1. package/package.json +7 -7
  2. package/src/components/applications/ApplicationsList.tsx +131 -115
  3. package/src/components/applications/index.ts +3 -1
  4. package/src/components/audit/AuditCard.tsx +1 -1
  5. package/src/components/charts/AreaChart.tsx +52 -18
  6. package/src/components/charts/MetricsSummary.tsx +12 -3
  7. package/src/components/charts/saturation.test.ts +34 -0
  8. package/src/components/charts/saturation.ts +1 -0
  9. package/src/components/charts/types.ts +5 -4
  10. package/src/components/checks/ChecksView.tsx +12 -23
  11. package/src/components/checks/severity.ts +11 -11
  12. package/src/components/gitops/GitOpsTableView.tsx +298 -274
  13. package/src/components/issues/IssuesView.tsx +4 -6
  14. package/src/components/issues/severity.ts +19 -11
  15. package/src/components/issues/types.ts +22 -2
  16. package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
  17. package/src/components/resources/ResourcesSidebar.tsx +3 -1
  18. package/src/components/resources/ResourcesView.tsx +180 -21
  19. package/src/components/resources/get-pod-problems.test.ts +127 -0
  20. package/src/components/resources/index.ts +1 -0
  21. package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
  22. package/src/components/resources/renderers/HPARenderer.tsx +93 -86
  23. package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
  24. package/src/components/resources/renderers/PodRenderer.tsx +1 -1
  25. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  26. package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
  27. package/src/components/resources/resource-utils-hpa.test.ts +30 -0
  28. package/src/components/resources/resource-utils-hpa.ts +106 -0
  29. package/src/components/resources/resource-utils.ts +41 -28
  30. package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
  31. package/src/components/ui/DistributionBar.tsx +88 -0
  32. package/src/components/ui/Facet.tsx +130 -0
  33. package/src/components/ui/PageHeader.tsx +43 -0
  34. package/src/components/ui/SearchPillInput.tsx +237 -0
  35. package/src/components/ui/SortableTh.tsx +58 -0
  36. package/src/components/ui/SummaryTile.tsx +60 -0
  37. package/src/components/ui/drawer-components.tsx +56 -17
  38. package/src/components/ui/index.ts +12 -0
  39. package/src/components/workload/WorkloadView.tsx +18 -1
  40. package/src/theme/tailwind-theme.css +1 -0
  41. package/src/theme/variables.css +7 -3
  42. package/src/types/core.ts +64 -2
  43. package/src/utils/bulk-workload-actions.test.ts +78 -0
  44. package/src/utils/bulk-workload-actions.ts +50 -0
  45. package/src/utils/index.ts +1 -0
  46. package/src/utils/rbac-blast-radius.test.ts +48 -3
  47. package/src/utils/rbac-blast-radius.ts +14 -4
  48. package/tsconfig.json +1 -0
@@ -3,7 +3,9 @@ import { clsx } from 'clsx'
3
3
  import {
4
4
  AlertTriangle,
5
5
  ArrowDownUp,
6
+ Check,
6
7
  CheckCircle2,
8
+ ChevronDown,
7
9
  CircleAlert,
8
10
  CircleDot,
9
11
  GitBranch,
@@ -25,7 +27,13 @@ import {
25
27
 
26
28
  import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
27
29
  import { Tooltip } from '../ui/Tooltip'
30
+ import { PageHeader } from '../ui/PageHeader'
31
+ import { SummaryTile, type SummaryTone } from '../ui/SummaryTile'
32
+ import { FacetSection, FacetButton } from '../ui/Facet'
33
+ import { SortableTh, TH_CLASS, type SortDir } from '../ui/SortableTh'
34
+ import { DistributionBar } from '../ui/DistributionBar'
28
35
  import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
36
+ import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
29
37
  import { getGitOpsResourceStatus } from './detail-helpers'
30
38
  import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
31
39
  import { toggleSet } from './GitOpsGraphFilterRail'
@@ -59,8 +67,16 @@ import { parseContextName } from '../../utils/context-name'
59
67
  // ----- Types -----------------------------------------------------------------
60
68
 
61
69
  export type GitOpsMode = 'applications' | 'sources' | 'projects' | 'alerts'
70
+ // Resource categories with an implemented view. Sources/Projects/Alerts are
71
+ // still stubbed ("queued"), so only `applications` ships today — which is why
72
+ // the Scope switcher stays hidden until a second category lands. A one-option
73
+ // switcher is just noise (an always-selected pill that can't be changed).
74
+ const AVAILABLE_MODES: GitOpsMode[] = ['applications']
62
75
  export type GitOpsViewMode = 'table' | 'tiles'
63
- export type SortKey = 'name' | 'health' | 'sync' | 'lastSync' | 'project'
76
+ // 'urgency' is the curated DEFAULT order (what needs attention first) — not a
77
+ // column, so no header shows it as active; clicking any header replaces it with
78
+ // that column's own semantics.
79
+ export type SortKey = 'urgency' | 'name' | 'health' | 'sync' | 'lastSync' | 'project'
64
80
 
65
81
  // Row-level actions surfaced from the table's three-dot menu. The set
66
82
  // mirrors what the detail page exposes today; callers wire the mutations
@@ -141,15 +157,17 @@ export interface GitOpsRow {
141
157
 
142
158
  export type DestinationFilter = 'all' | 'this-cluster' | 'cross-cluster' | 'unmatched'
143
159
 
144
- type SummaryTone = 'neutral' | 'warning' | 'error' | 'info'
145
-
146
160
  interface SummaryTileSpec {
147
161
  key: string
148
162
  label: string
149
163
  value: number
150
164
  tone: SummaryTone
151
165
  active: boolean
166
+ // A status tile toggles its OWN dimension: `apply` sets it (when off),
167
+ // `clear` resets just that dimension (when on) — composing with other
168
+ // facets + search instead of wiping them. The Total tile resets everything.
152
169
  apply?: () => void
170
+ clear?: () => void
153
171
  }
154
172
 
155
173
  // ----- Component props -------------------------------------------------------
@@ -270,10 +288,30 @@ export function GitOpsTableView({
270
288
  const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set())
271
289
  const [showLabelsDropdown, setShowLabelsDropdown] = useState(false)
272
290
  const [labelSearch, setLabelSearch] = useState('')
273
- const [automationFilter, setAutomationFilter] = useState<'all' | 'auto' | 'manual' | 'suspended'>('all')
291
+ // Auto-sync / Manual / Suspended are INDEPENDENT row attributes (a Flux app can
292
+ // be auto-reconciling AND suspended), so this is a multi-select facet like Sync
293
+ // and Health — not the old single-select that conflated the mode (auto vs
294
+ // manual) with the orthogonal suspended state.
295
+ const [automationFilters, setAutomationFilters] = useState<Set<'auto' | 'manual' | 'suspended'>>(new Set())
296
+ const toggleAutomation = useCallback((value: 'auto' | 'manual' | 'suspended') => {
297
+ setAutomationFilters((prev) => {
298
+ const next = new Set(prev)
299
+ next.has(value) ? next.delete(value) : next.add(value)
300
+ return next
301
+ })
302
+ }, [])
274
303
  const [lifecycleFilter, setLifecycleFilter] = useState<'all' | 'terminating' | 'active'>('all')
275
- const [reconcilingOnly, setReconcilingOnly] = useState(false)
276
- const [sortKey, setSortKey] = useState<SortKey>('health')
304
+ const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: 'urgency', dir: 'asc' })
305
+ // Shared refresh feedback (spin ≥400ms → checkmark) so clicking Refresh gives
306
+ // the same visual confirmation as every other view, even when the refetch is
307
+ // instant (the cache is already warm).
308
+ const [triggerRefresh, , refreshPhase] = useRefreshAnimation(onRefresh ?? (() => {}))
309
+ // Clicking a column sorts by it (starting at the column's natural direction —
310
+ // e.g. last-sync newest-first); clicking the active column reverses.
311
+ const onSort = useCallback(
312
+ (key: SortKey) => setSort((prev) => (prev.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: SORT_DEFAULT_DIR[key] })),
313
+ [],
314
+ )
277
315
 
278
316
  const hasLocalFilters =
279
317
  !!search ||
@@ -282,7 +320,7 @@ export function GitOpsTableView({
282
320
  projectFilters.size > 0 ||
283
321
  namespaceFilters.size > 0 ||
284
322
  labelFilters.size > 0 ||
285
- automationFilter !== 'all' ||
323
+ automationFilters.size > 0 ||
286
324
  lifecycleFilter !== 'all'
287
325
  const hasGlobalNamespaceFilter = !!onClearNamespaces && (globalNamespaces?.length ?? 0) > 0
288
326
  const hasAnyFilter = hasLocalFilters || hasGlobalNamespaceFilter
@@ -327,6 +365,25 @@ export function GitOpsTableView({
327
365
  )
328
366
  const syncCounts = useMemo(() => countMap(allRows.map((row) => row.sync)), [allRows])
329
367
  const healthCounts = useMemo(() => countMap(allRows.map((row) => row.health)), [allRows])
368
+ const automationCounts = useMemo(() => ({
369
+ auto: allRows.filter((row) => row.autoSync).length,
370
+ manual: allRows.filter((row) => !row.autoSync).length,
371
+ suspended: allRows.filter((row) => row.suspended).length,
372
+ }), [allRows])
373
+ // The Destination column earns its width only when destinations actually vary
374
+ // — in single-cluster OSS every row is the same in-cluster API server, so the
375
+ // column is dead weight. Show it when any row is cross-cluster (Hub) or when
376
+ // destinations differ; hide when they all collapse to the in-cluster default.
377
+ const showDestination = useMemo(() => {
378
+ // Fleet/Hub mode owns the Destination column outright (it's the multi-cluster
379
+ // product surface — the destination filter + cross-cluster matching live here).
380
+ if (onDestinationFilterChange) return true
381
+ if (allRows.some((r) => r._destination && r._destination.match !== 'in_cluster')) return true
382
+ const dests = new Set(allRows.map((r) => r.destination).filter(Boolean))
383
+ if (dests.size > 1) return true
384
+ const only = [...dests][0] ?? ''
385
+ return !!only && !/kubernetes\.default\.svc/i.test(only)
386
+ }, [allRows, onDestinationFilterChange])
330
387
  const labels = useMemo(() => countLabels(allRows), [allRows])
331
388
  const filteredRows = useMemo(() => {
332
389
  const q = search.trim().toLowerCase()
@@ -354,12 +411,13 @@ export function GitOpsTableView({
354
411
  if (projectFilters.size > 0 && !projectFilters.has(row.project || '(none)')) return false
355
412
  if (namespaceFilters.size > 0 && !namespaceFilters.has(row.namespace || '(cluster)')) return false
356
413
  if (activeLabels.length > 0 && !activeLabels.every(({ key, value }) => row.labels[key] === value)) return false
357
- if (automationFilter === 'auto' && !row.autoSync) return false
358
- if (automationFilter === 'manual' && row.autoSync) return false
359
- if (automationFilter === 'suspended' && !row.suspended) return false
414
+ if (automationFilters.size > 0 && !(
415
+ (automationFilters.has('auto') && row.autoSync) ||
416
+ (automationFilters.has('manual') && !row.autoSync) ||
417
+ (automationFilters.has('suspended') && row.suspended)
418
+ )) return false
360
419
  if (lifecycleFilter === 'terminating' && !row.terminating) return false
361
420
  if (lifecycleFilter === 'active' && row.terminating) return false
362
- if (reconcilingOnly && row.sync !== 'Reconciling' && row.health !== 'Progressing') return false
363
421
  if (destinationFilter && destinationFilter !== 'all') {
364
422
  const match = row._destination?.match
365
423
  if (destinationFilter === 'this-cluster' && match !== 'in_cluster') return false
@@ -372,8 +430,8 @@ export function GitOpsTableView({
372
430
  }
373
431
  return true
374
432
  })
375
- return [...rows].sort((a, b) => compareRows(a, b, sortKey))
376
- }, [allRows, automationFilter, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sortKey, syncFilters, destinationFilter, reconcilingOnly])
433
+ return [...rows].sort((a, b) => compareRows(a, b, sort.key) * (sort.dir === 'asc' ? 1 : -1))
434
+ }, [allRows, automationFilters, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sort, syncFilters, destinationFilter])
377
435
 
378
436
  const terminatingCount = useMemo(() => allRows.filter((row) => row.terminating).length, [allRows])
379
437
 
@@ -384,29 +442,24 @@ export function GitOpsTableView({
384
442
  setProjectFilters(new Set())
385
443
  setNamespaceFilters(new Set())
386
444
  setLabelFilters(new Set())
387
- setAutomationFilter('all')
445
+ setAutomationFilters(new Set())
388
446
  setLifecycleFilter('all')
389
- setReconcilingOnly(false)
390
447
  onClearNamespaces?.()
391
448
  onDestinationFilterChange?.('all')
392
449
  }, [onClearNamespaces, onDestinationFilterChange])
393
450
 
451
+ // True when nothing is filtered at all — backs the Total tile's active state.
394
452
  const noOtherFiltersActive = useCallback(
395
- (
396
- exclude: 'sync' | 'health' | 'automation' | 'destination' | 'reconciling' | null = null,
397
- ) => {
398
- if (search !== '') return false
399
- if (exclude !== 'sync' && syncFilters.size > 0) return false
400
- if (exclude !== 'health' && healthFilters.size > 0) return false
401
- if (projectFilters.size > 0) return false
402
- if (namespaceFilters.size > 0) return false
403
- if (labelFilters.size > 0) return false
404
- if (exclude !== 'automation' && automationFilter !== 'all') return false
405
- if (lifecycleFilter !== 'all') return false
406
- if (exclude !== 'destination' && destinationFilter && destinationFilter !== 'all') return false
407
- if (exclude !== 'reconciling' && reconcilingOnly) return false
408
- return true
409
- },
453
+ () =>
454
+ search === '' &&
455
+ syncFilters.size === 0 &&
456
+ healthFilters.size === 0 &&
457
+ projectFilters.size === 0 &&
458
+ namespaceFilters.size === 0 &&
459
+ labelFilters.size === 0 &&
460
+ automationFilters.size === 0 &&
461
+ lifecycleFilter === 'all' &&
462
+ (!destinationFilter || destinationFilter === 'all'),
410
463
  [
411
464
  search,
412
465
  syncFilters,
@@ -414,10 +467,9 @@ export function GitOpsTableView({
414
467
  projectFilters,
415
468
  namespaceFilters,
416
469
  labelFilters,
417
- automationFilter,
470
+ automationFilters,
418
471
  lifecycleFilter,
419
472
  destinationFilter,
420
- reconcilingOnly,
421
473
  ],
422
474
  )
423
475
 
@@ -426,7 +478,10 @@ export function GitOpsTableView({
426
478
  // namespace-scoped zero is NOT the same as cluster-empty. Fall through
427
479
  // to the actionable empty state below when the host owns a namespace
428
480
  // pick we can clear; otherwise the user lands here with no escape hatch.
429
- if (totalGitOps === 0 && !loading && !hasGlobalNamespaceFilter) {
481
+ // Also require zero actual rows: the cold-cache retry can populate `rows`
482
+ // before the separate counts map catches up, and a populated table must not
483
+ // be hidden behind a "nothing here" screen.
484
+ if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter) {
430
485
  return (
431
486
  <div className="flex h-full min-h-0 flex-1 items-center justify-center bg-theme-base p-4">
432
487
  <div className="rounded-lg border border-theme-border bg-theme-surface p-8 text-center">
@@ -444,6 +499,11 @@ export function GitOpsTableView({
444
499
 
445
500
  const showCrossClusterTile = typeof crossClusterCount === 'number' && mode === 'applications'
446
501
 
502
+ // Header tiles unify with the facet rail: each STATUS tile toggles its own
503
+ // dimension and composes with the other facets + search (clicking "Out of
504
+ // sync" adds sync=OutOfSync without wiping an active health filter or your
505
+ // search). `active` = "this dimension is exactly this value". The Total tile
506
+ // is the reset — it clears everything.
447
507
  const summaryTiles: SummaryTileSpec[] = [
448
508
  {
449
509
  key: 'total',
@@ -451,40 +511,44 @@ export function GitOpsTableView({
451
511
  value: allRows.length,
452
512
  tone: 'neutral',
453
513
  active: noOtherFiltersActive(),
514
+ apply: clearAllFilters,
515
+ clear: clearAllFilters,
454
516
  },
455
517
  {
456
518
  key: 'outOfSync',
457
519
  label: 'Out of sync',
458
520
  value: statusSummary.outOfSync,
459
521
  tone: 'warning',
460
- active:
461
- syncFilters.size === 1 && syncFilters.has('OutOfSync') && noOtherFiltersActive('sync'),
522
+ active: syncFilters.size === 1 && syncFilters.has('OutOfSync'),
462
523
  apply: () => setSyncFilters(new Set(['OutOfSync'])),
524
+ clear: () => setSyncFilters(new Set()),
463
525
  },
464
526
  {
465
527
  key: 'degraded',
466
528
  label: 'Degraded',
467
529
  value: statusSummary.degraded,
468
530
  tone: 'error',
469
- active:
470
- healthFilters.size === 1 && healthFilters.has('Degraded') && noOtherFiltersActive('health'),
531
+ active: healthFilters.size === 1 && healthFilters.has('Degraded'),
471
532
  apply: () => setHealthFilters(new Set(['Degraded'])),
533
+ clear: () => setHealthFilters(new Set()),
472
534
  },
473
535
  {
474
536
  key: 'suspended',
475
537
  label: 'Suspended',
476
538
  value: statusSummary.suspended,
477
539
  tone: 'warning',
478
- active: automationFilter === 'suspended' && noOtherFiltersActive('automation'),
479
- apply: () => setAutomationFilter('suspended'),
540
+ active: automationFilters.size === 1 && automationFilters.has('suspended'),
541
+ apply: () => setAutomationFilters(new Set(['suspended'])),
542
+ clear: () => setAutomationFilters(new Set()),
480
543
  },
481
544
  {
482
545
  key: 'reconciling',
483
546
  label: 'Reconciling',
484
- value: statusSummary.reconciling,
547
+ value: syncCounts.get('Reconciling') ?? 0,
485
548
  tone: 'info',
486
- active: reconcilingOnly && noOtherFiltersActive('reconciling'),
487
- apply: () => setReconcilingOnly(true),
549
+ active: syncFilters.size === 1 && syncFilters.has('Reconciling'),
550
+ apply: () => setSyncFilters(new Set(['Reconciling'])),
551
+ clear: () => setSyncFilters(new Set()),
488
552
  },
489
553
  ...(showCrossClusterTile
490
554
  ? [
@@ -493,19 +557,42 @@ export function GitOpsTableView({
493
557
  label: 'Cross-cluster',
494
558
  value: crossClusterCount!,
495
559
  tone: 'info' as const,
496
- active: destinationFilter === 'cross-cluster' && noOtherFiltersActive('destination'),
560
+ active: destinationFilter === 'cross-cluster',
497
561
  apply: () => onDestinationFilterChange?.('cross-cluster'),
562
+ clear: () => onDestinationFilterChange?.('all'),
498
563
  },
499
564
  ]
500
565
  : []),
501
566
  ]
502
567
 
503
568
  return (
504
- <div
505
- className={`flex h-full min-w-0 flex-1 overflow-hidden bg-theme-base max-lg:flex-col ${
506
- filtersSide === 'right' ? 'lg:flex-row-reverse' : ''
507
- }`}
508
- >
569
+ <div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-theme-base">
570
+ {/* Full-width header on top; the filter facet + content sit below it. */}
571
+ <div className="shrink-0 border-b border-theme-border bg-theme-base px-4 py-4">
572
+ <PageHeader
573
+ icon={GitBranch}
574
+ title="GitOps"
575
+ description="Applications and reconciliations with source, destination, sync, and health state."
576
+ actions={summaryTiles.map((tile) => (
577
+ <SummaryTile
578
+ key={tile.key}
579
+ label={tile.label}
580
+ value={tile.value}
581
+ tone={tile.tone}
582
+ active={tile.active}
583
+ onClick={() => {
584
+ if (tile.active) tile.clear?.()
585
+ else tile.apply?.()
586
+ }}
587
+ />
588
+ ))}
589
+ />
590
+ </div>
591
+ <div
592
+ className={`flex min-w-0 flex-1 overflow-hidden max-[899px]:flex-col ${
593
+ filtersSide === 'right' ? 'min-[900px]:flex-row-reverse' : ''
594
+ }`}
595
+ >
509
596
  <GitOpsFilterSidebar
510
597
  side={filtersSide}
511
598
  mode={mode}
@@ -517,8 +604,9 @@ export function GitOpsTableView({
517
604
  healthCounts={healthCounts}
518
605
  healthFilters={healthFilters}
519
606
  onToggleHealth={(value) => toggleSet(healthFilters, setHealthFilters, value)}
520
- automationFilter={automationFilter}
521
- onAutomationFilterChange={setAutomationFilter}
607
+ automationFilters={automationFilters}
608
+ automationCounts={automationCounts}
609
+ onToggleAutomation={toggleAutomation}
522
610
  lifecycleFilter={lifecycleFilter}
523
611
  onLifecycleFilterChange={setLifecycleFilter}
524
612
  terminatingCount={terminatingCount}
@@ -532,32 +620,6 @@ export function GitOpsTableView({
532
620
  />
533
621
 
534
622
  <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
535
- <div className="shrink-0 border-b border-theme-border bg-theme-base px-4 py-3">
536
- <div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
537
- <div className="min-w-0">
538
- <h1 className="text-lg font-semibold text-theme-text-primary">GitOps</h1>
539
- <p className="truncate text-sm text-theme-text-secondary">
540
- Applications and reconciliations with source, destination, sync, and health state.
541
- </p>
542
- </div>
543
- <div className="flex shrink-0 flex-wrap justify-end gap-2">
544
- {summaryTiles.map((tile) => (
545
- <SummaryTile
546
- key={tile.key}
547
- label={tile.label}
548
- value={tile.value}
549
- tone={tile.tone}
550
- active={tile.active}
551
- onClick={() => {
552
- clearAllFilters()
553
- if (!tile.active && tile.apply) tile.apply()
554
- }}
555
- />
556
- ))}
557
- </div>
558
- </div>
559
- </div>
560
-
561
623
  {forbiddenSecretsClusters && forbiddenSecretsClusters.length > 0 && mode === 'applications' && (
562
624
  // Hub-only graceful-degradation note: when the user lacks `get
563
625
  // secrets` in the argocd namespace on a controller, the hub
@@ -589,17 +651,12 @@ export function GitOpsTableView({
589
651
  Showing {filteredRows.length} of {allRows.length}
590
652
  </span>
591
653
  )}
592
- <select
593
- value={sortKey}
594
- onChange={(e) => setSortKey(e.target.value as SortKey)}
595
- className="h-8 rounded-md border border-theme-border bg-theme-base px-2 text-xs text-theme-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
596
- >
597
- <option value="health">Sort: health</option>
598
- <option value="sync">Sort: sync</option>
599
- <option value="lastSync">Sort: last sync</option>
600
- <option value="project">Sort: project</option>
601
- <option value="name">Sort: name</option>
602
- </select>
654
+ {/* Table mode sorts via clickable column headers (Resources-table
655
+ pattern); tile mode has no headers, so it keeps a compact sort
656
+ control wired to the same sort state. */}
657
+ {viewMode === 'tiles' && (
658
+ <GitOpsSortMenu sortKey={sort.key} onChange={(k) => setSort({ key: k, dir: SORT_DEFAULT_DIR[k] })} />
659
+ )}
603
660
  {labels.length > 0 && (
604
661
  <LabelsDropdown
605
662
  labels={labels}
@@ -651,10 +708,12 @@ export function GitOpsTableView({
651
708
  <Tooltip content="Refresh GitOps resources">
652
709
  <button
653
710
  type="button"
654
- onClick={onRefresh}
711
+ onClick={triggerRefresh}
655
712
  className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-theme-border bg-theme-base text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
656
713
  >
657
- <RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
714
+ {refreshPhase === 'success'
715
+ ? <Check className="h-3.5 w-3.5 text-emerald-500" />
716
+ : <RefreshCw className={clsx('h-3.5 w-3.5', (refreshPhase === 'spinning' || loading) && 'animate-spin')} />}
658
717
  </button>
659
718
  </Tooltip>
660
719
  )}
@@ -701,8 +760,11 @@ export function GitOpsTableView({
701
760
  ) : (
702
761
  <GitOpsTable
703
762
  rows={filteredRows}
763
+ sort={sort}
764
+ onSort={onSort}
704
765
  onOpen={onRowClick}
705
766
  hrefFor={rowHrefFor}
767
+ showDestination={showDestination}
706
768
  onDestinationClick={onDestinationClick}
707
769
  destinationHrefFor={destinationHrefFor}
708
770
  onRowAction={onRowAction}
@@ -711,6 +773,7 @@ export function GitOpsTableView({
711
773
  )}
712
774
  </div>
713
775
  </div>
776
+ </div>
714
777
  </div>
715
778
  )
716
779
  }
@@ -731,8 +794,9 @@ function GitOpsFilterSidebar({
731
794
  healthCounts,
732
795
  healthFilters,
733
796
  onToggleHealth,
734
- automationFilter,
735
- onAutomationFilterChange,
797
+ automationFilters,
798
+ automationCounts,
799
+ onToggleAutomation,
736
800
  lifecycleFilter,
737
801
  onLifecycleFilterChange,
738
802
  terminatingCount,
@@ -754,8 +818,9 @@ function GitOpsFilterSidebar({
754
818
  healthCounts: Map<string, number>
755
819
  healthFilters: Set<string>
756
820
  onToggleHealth: (value: string) => void
757
- automationFilter: 'all' | 'auto' | 'manual' | 'suspended'
758
- onAutomationFilterChange: (value: 'all' | 'auto' | 'manual' | 'suspended') => void
821
+ automationFilters: Set<'auto' | 'manual' | 'suspended'>
822
+ automationCounts: { auto: number; manual: number; suspended: number }
823
+ onToggleAutomation: (value: 'auto' | 'manual' | 'suspended') => void
759
824
  lifecycleFilter: 'all' | 'terminating' | 'active'
760
825
  onLifecycleFilterChange: (value: 'all' | 'terminating' | 'active') => void
761
826
  terminatingCount: number
@@ -769,20 +834,21 @@ function GitOpsFilterSidebar({
769
834
  }) {
770
835
  return (
771
836
  <aside
772
- className={`flex w-72 shrink-0 flex-col overflow-hidden border-theme-border bg-theme-surface/90 max-lg:max-h-72 max-lg:w-full max-lg:border-b ${
773
- side === 'right' ? 'border-l max-lg:border-l-0' : 'border-r max-lg:border-r-0'
837
+ className={`flex w-72 shrink-0 flex-col overflow-hidden border-theme-border bg-theme-surface/90 max-[899px]:max-h-72 max-[899px]:w-full max-[899px]:border-b ${
838
+ side === 'right' ? 'border-l max-[899px]:border-l-0' : 'border-r max-[899px]:border-r-0'
774
839
  }`}
775
840
  >
776
841
  <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
777
- <span className="text-sm font-medium text-theme-text-secondary">GitOps Filters</span>
842
+ <span className="text-sm font-medium text-theme-text-secondary">Filters</span>
778
843
  <button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
779
844
  Clear
780
845
  </button>
781
846
  </div>
782
847
  <div className="flex-1 overflow-y-auto">
848
+ {AVAILABLE_MODES.length > 1 && (
783
849
  <GitOpsFilterSection icon={GitBranch} title="Scope">
784
850
  <div className="grid grid-cols-2 gap-1">
785
- {(['applications'] as GitOpsMode[]).map((item) => (
851
+ {AVAILABLE_MODES.map((item) => (
786
852
  <button
787
853
  key={item}
788
854
  type="button"
@@ -799,6 +865,7 @@ function GitOpsFilterSidebar({
799
865
  ))}
800
866
  </div>
801
867
  </GitOpsFilterSection>
868
+ )}
802
869
 
803
870
  <GitOpsFilterSection icon={CheckCircle2} title="Sync">
804
871
  <GitOpsFacetButton label="Synced" count={syncCounts.get('Synced') ?? 0} active={syncFilters.has('Synced')} tone="success" onClick={() => onToggleSync('Synced')} />
@@ -815,28 +882,10 @@ function GitOpsFilterSidebar({
815
882
  <GitOpsFacetButton label="Unknown" count={healthCounts.get('Unknown') ?? 0} active={healthFilters.has('Unknown')} onClick={() => onToggleHealth('Unknown')} />
816
883
  </GitOpsFilterSection>
817
884
 
818
- <GitOpsFilterSection icon={CircleDot} title="Automation">
819
- <div className="grid grid-cols-2 gap-1">
820
- {([
821
- ['all', 'All'],
822
- ['auto', 'Auto-sync'],
823
- ['manual', 'Manual'],
824
- ['suspended', 'Suspended'],
825
- ] as const).map(([value, label]) => (
826
- <button
827
- key={value}
828
- type="button"
829
- onClick={() => onAutomationFilterChange(value)}
830
- className={`rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors ${
831
- automationFilter === value
832
- ? 'bg-skyhook-500 text-white'
833
- : 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
834
- }`}
835
- >
836
- {label}
837
- </button>
838
- ))}
839
- </div>
885
+ <GitOpsFilterSection icon={CircleDot} title="Automation (Sync policy)">
886
+ <GitOpsFacetButton label="Auto-sync" count={automationCounts.auto} active={automationFilters.has('auto')} onClick={() => onToggleAutomation('auto')} />
887
+ <GitOpsFacetButton label="Manual" count={automationCounts.manual} active={automationFilters.has('manual')} onClick={() => onToggleAutomation('manual')} />
888
+ <GitOpsFacetButton label="Suspended" count={automationCounts.suspended} active={automationFilters.has('suspended')} tone="warning" onClick={() => onToggleAutomation('suspended')} />
840
889
  </GitOpsFilterSection>
841
890
 
842
891
  {terminatingCount > 0 && (
@@ -894,62 +943,14 @@ function GitOpsFilterSidebar({
894
943
  )
895
944
  }
896
945
 
897
- // Exported so consumers can build their own GitOps-flavored filter rails
898
- // (e.g. OSS's GitOpsGraphFilterRail in the detail view's Topology tab,
899
- // or hub-web's destination filter sub-bar) without re-implementing the
900
- // section/facet/toggle primitives. The styles stay in lockstep with the
901
- // main table's filter sidebar — change once, both surfaces follow.
902
- export function GitOpsFilterSection({ icon: Icon, title, children }: { icon: ComponentType<{ className?: string }>; title: string; children: ReactNode }) {
903
- return (
904
- <section className="border-b border-theme-border px-3 py-2">
905
- <div className="mb-1.5 flex items-center gap-2">
906
- <Icon className="h-3.5 w-3.5 text-theme-text-tertiary" />
907
- <span className="text-[10px] font-medium uppercase tracking-wider text-theme-text-tertiary">{title}</span>
908
- </div>
909
- <div className="space-y-0.5">{children}</div>
910
- </section>
911
- )
912
- }
946
+ // GitOps's filter rail is built from the shared faceted-filter primitives
947
+ // (ui/Facet). These aliases keep the existing GitOps* import surface
948
+ // (GitOpsGraphFilterRail imports them from here) while the look + behavior live
949
+ // in one place — change the facet once, every rail follows.
950
+ export const GitOpsFilterSection = FacetSection
951
+ export const GitOpsFacetButton = FacetButton
913
952
 
914
- // Exported alongside GitOpsFilterSection — same reuse motivation. OSS's
915
- // GitOpsGraphFilterRail (detail view Topology tab) imports it.
916
- export function GitOpsFacetButton({
917
- label,
918
- count,
919
- active,
920
- tone = 'neutral',
921
- onClick,
922
- }: {
923
- label: string
924
- count: number
925
- active: boolean
926
- tone?: 'neutral' | 'success' | 'warning' | 'error' | 'info'
927
- onClick: () => void
928
- }) {
929
- const dot = {
930
- neutral: 'bg-theme-text-tertiary',
931
- success: 'bg-emerald-500',
932
- warning: 'bg-amber-500',
933
- error: 'bg-red-500',
934
- info: 'bg-sky-500',
935
- }[tone]
936
- return (
937
- <button
938
- type="button"
939
- onClick={onClick}
940
- className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[11px] transition-colors ${
941
- active ? 'bg-blue-500/15 text-blue-500' : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
942
- }`}
943
- >
944
- <span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} />
945
- <span className="min-w-0 flex-1 truncate font-medium">{label}</span>
946
- {count > 0 && <span className="tabular-nums text-theme-text-tertiary">{count}</span>}
947
- </button>
948
- )
949
- }
950
-
951
- // Exported alongside GitOpsFilterSection / GitOpsFacetButton for the same
952
- // reuse story.
953
+ // A view-mode toggle (table/tiles) — not a facet; stays local.
953
954
  export function GitOpsIconToggle({ active, label, icon: Icon, onClick }: { active: boolean; label: string; icon: ComponentType<{ className?: string }>; onClick: () => void }) {
954
955
  return (
955
956
  <Tooltip content={label}>
@@ -966,6 +967,60 @@ export function GitOpsIconToggle({ active, label, icon: Icon, onClick }: { activ
966
967
  )
967
968
  }
968
969
 
970
+ // Tile mode has no sortable column headers, so it gets this themed sort menu
971
+ // (a native <select> can't be styled to match the rest of the toolbar).
972
+ const SORT_OPTIONS: [SortKey, string][] = [
973
+ ['urgency', 'Priority'],
974
+ ['health', 'Health'],
975
+ ['sync', 'Sync'],
976
+ ['lastSync', 'Last sync'],
977
+ ['project', 'Project'],
978
+ ['name', 'Name'],
979
+ ]
980
+ function GitOpsSortMenu({ sortKey, onChange }: { sortKey: SortKey; onChange: (key: SortKey) => void }) {
981
+ const [open, setOpen] = useState(false)
982
+ const ref = useRef<HTMLDivElement>(null)
983
+ useEffect(() => {
984
+ if (!open) return
985
+ const onDown = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false) }
986
+ document.addEventListener('mousedown', onDown)
987
+ return () => document.removeEventListener('mousedown', onDown)
988
+ }, [open])
989
+ const current = SORT_OPTIONS.find(([k]) => k === sortKey)?.[1] ?? 'Name'
990
+ return (
991
+ <div ref={ref} className="relative">
992
+ <button
993
+ type="button"
994
+ onClick={() => setOpen((o) => !o)}
995
+ aria-label="Sort"
996
+ className="inline-flex h-8 items-center gap-1.5 rounded-md border border-theme-border bg-theme-base px-2.5 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
997
+ >
998
+ <ArrowDownUp className="h-3.5 w-3.5" />
999
+ Sort: {current}
1000
+ <ChevronDown className="h-3 w-3 opacity-60" />
1001
+ </button>
1002
+ {open && (
1003
+ <div className="absolute right-0 top-full z-50 mt-1 w-40 overflow-hidden rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl">
1004
+ {SORT_OPTIONS.map(([key, label]) => (
1005
+ <button
1006
+ key={key}
1007
+ type="button"
1008
+ onClick={() => { onChange(key); setOpen(false) }}
1009
+ className={clsx(
1010
+ 'flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-theme-hover',
1011
+ key === sortKey ? 'font-medium text-theme-text-primary' : 'text-theme-text-secondary',
1012
+ )}
1013
+ >
1014
+ <Check className={clsx('h-3.5 w-3.5 shrink-0', key === sortKey ? 'opacity-100 text-skyhook-500' : 'opacity-0')} />
1015
+ {label}
1016
+ </button>
1017
+ ))}
1018
+ </div>
1019
+ )}
1020
+ </div>
1021
+ )
1022
+ }
1023
+
969
1024
  function LabelsDropdown({
970
1025
  labels,
971
1026
  activeLabels,
@@ -988,8 +1043,17 @@ function LabelsDropdown({
988
1043
  const filtered = search.trim()
989
1044
  ? labels.filter((label) => label.name.toLowerCase().includes(search.trim().toLowerCase()))
990
1045
  : labels
1046
+ const containerRef = useRef<HTMLDivElement>(null)
1047
+ useEffect(() => {
1048
+ if (!open) return
1049
+ const onDown = (e: MouseEvent) => {
1050
+ if (!containerRef.current?.contains(e.target as Node)) onOpenChange(false)
1051
+ }
1052
+ document.addEventListener('mousedown', onDown)
1053
+ return () => document.removeEventListener('mousedown', onDown)
1054
+ }, [open, onOpenChange])
991
1055
  return (
992
- <div className="relative">
1056
+ <div ref={containerRef} className="relative">
993
1057
  <button
994
1058
  type="button"
995
1059
  onClick={() => onOpenChange(!open)}
@@ -1076,40 +1140,37 @@ function StatusDistribution({ rows }: { rows: GitOpsRow[] }) {
1076
1140
  // the bar — sync state is visible elsewhere (the OutOfSync summary
1077
1141
  // tile, the Sync column, the filter rail).
1078
1142
  const summary = summarizeGitOpsRows(rows)
1079
- const total = rows.length || 1
1080
- const segments = [
1081
- { key: 'healthy', value: summary.healthy, className: 'bg-emerald-500' },
1082
- { key: 'progressing', value: summary.progressing, className: 'bg-sky-500' },
1083
- { key: 'degraded', value: summary.degraded, className: 'bg-red-500' },
1084
- { key: 'unknown', value: Math.max(0, rows.length - summary.healthy - summary.progressing - summary.degraded), className: 'bg-theme-text-tertiary/40' },
1085
- ].filter((segment) => segment.value > 0)
1086
1143
  return (
1087
- <div className="h-2 overflow-hidden rounded-full bg-theme-elevated">
1088
- <div className="flex h-full w-full">
1089
- {segments.map((segment) => (
1090
- <div
1091
- key={segment.key}
1092
- className={segment.className}
1093
- style={{ width: `${Math.max(1, (segment.value / total) * 100)}%` }}
1094
- />
1095
- ))}
1096
- </div>
1097
- </div>
1144
+ <DistributionBar
1145
+ ariaLabel="Health distribution"
1146
+ segments={[
1147
+ { key: 'degraded', count: summary.degraded, fillClass: 'bg-red-500' },
1148
+ { key: 'progressing', count: summary.progressing, fillClass: 'bg-sky-500' },
1149
+ { key: 'healthy', count: summary.healthy, fillClass: 'bg-emerald-500' },
1150
+ { key: 'unknown', count: Math.max(0, rows.length - summary.healthy - summary.progressing - summary.degraded), fillClass: 'bg-theme-text-tertiary/40' },
1151
+ ]}
1152
+ />
1098
1153
  )
1099
1154
  }
1100
1155
 
1101
1156
  function GitOpsTable({
1102
1157
  rows,
1158
+ sort,
1159
+ onSort,
1103
1160
  onOpen,
1104
1161
  hrefFor,
1162
+ showDestination = true,
1105
1163
  onDestinationClick,
1106
1164
  destinationHrefFor,
1107
1165
  onRowAction,
1108
1166
  pendingRowActions,
1109
1167
  }: {
1110
1168
  rows: GitOpsRow[]
1169
+ sort: { key: SortKey; dir: SortDir }
1170
+ onSort: (key: SortKey) => void
1111
1171
  onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
1112
1172
  hrefFor?: (row: GitOpsRow) => string
1173
+ showDestination?: boolean
1113
1174
  onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
1114
1175
  destinationHrefFor?: (row: GitOpsRow, destination: FleetDestinationStamp) => string
1115
1176
  onRowAction?: (row: GitOpsRow, action: GitOpsRowAction) => void
@@ -1118,19 +1179,19 @@ function GitOpsTable({
1118
1179
  const showActions = !!onRowAction
1119
1180
  return (
1120
1181
  <table className="w-full min-w-[1040px] table-fixed border-separate border-spacing-0 text-sm">
1121
- <thead className="sticky top-0 z-10 bg-theme-surface">
1122
- <tr className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
1123
- <TableHead className={showActions ? 'w-[16%]' : 'w-[22%]'}>Application</TableHead>
1124
- <TableHead className="w-[9%]">Project</TableHead>
1125
- <TableHead className="w-[9%]">Sync</TableHead>
1126
- <TableHead className="w-[9%]">Health</TableHead>
1127
- <TableHead className="w-[20%]">Source</TableHead>
1128
- <TableHead className="w-[14%]">Destination</TableHead>
1129
- <TableHead className="w-[10%]">Last Sync</TableHead>
1182
+ <thead className="sticky top-0 z-10 bg-theme-base">
1183
+ <tr>
1184
+ <SortableTh label="Application" sortKey="name" activeKey={sort.key} direction={sort.dir} onSort={onSort} className={showActions ? 'w-[16%]' : 'w-[22%]'} />
1185
+ <SortableTh label="Project" sortKey="project" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1186
+ <SortableTh label="Sync" sortKey="sync" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1187
+ <SortableTh label="Health" sortKey="health" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1188
+ <th className={clsx(TH_CLASS, showDestination ? 'w-[20%]' : 'w-[28%]')}>Source</th>
1189
+ {showDestination && <th className={clsx(TH_CLASS, 'w-[14%]')}>Destination</th>}
1190
+ <SortableTh label="Last Sync" sortKey="lastSync" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[10%]" />
1130
1191
  {showActions && (
1131
- <TableHead className="w-[6%] text-right">
1192
+ <th className={clsx(TH_CLASS, 'w-[6%] text-right')}>
1132
1193
  <span className="sr-only">Actions</span>
1133
- </TableHead>
1194
+ </th>
1134
1195
  )}
1135
1196
  </tr>
1136
1197
  </thead>
@@ -1199,10 +1260,12 @@ function GitOpsTable({
1199
1260
  <div className="truncate text-theme-text-primary">{row.repository || row.chart || '-'}</div>
1200
1261
  <div className="truncate text-xs text-theme-text-tertiary">{[row.targetRevision, row.path || row.chart].filter(Boolean).join(' · ') || '-'}</div>
1201
1262
  </TableCell>
1202
- <TableCell>
1203
- <DestinationCell row={row} onDestinationClick={onDestinationClick} destinationHrefFor={destinationHrefFor} />
1204
- <div className="truncate text-xs text-theme-text-tertiary">{row.destinationNamespace || row.namespace || '-'}</div>
1205
- </TableCell>
1263
+ {showDestination && (
1264
+ <TableCell>
1265
+ <DestinationCell row={row} onDestinationClick={onDestinationClick} destinationHrefFor={destinationHrefFor} />
1266
+ <div className="truncate text-xs text-theme-text-tertiary">{row.destinationNamespace || row.namespace || '-'}</div>
1267
+ </TableCell>
1268
+ )}
1206
1269
  <TableCell>
1207
1270
  {row.terminating
1208
1271
  ? <span className="text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</span>
@@ -1462,61 +1525,6 @@ function GitOpsTile({
1462
1525
  )
1463
1526
  }
1464
1527
 
1465
- function SummaryTile({
1466
- label,
1467
- value,
1468
- tone = 'neutral',
1469
- onClick,
1470
- active = false,
1471
- }: {
1472
- label: string
1473
- value: number
1474
- tone?: SummaryTone
1475
- onClick?: () => void
1476
- active?: boolean
1477
- }) {
1478
- const toneClass = {
1479
- neutral: 'text-theme-text-primary',
1480
- warning: 'text-amber-600 dark:text-amber-300',
1481
- error: 'text-red-600 dark:text-red-300',
1482
- info: 'text-sky-600 dark:text-sky-300',
1483
- }[tone]
1484
- const activeBorderClass = {
1485
- neutral: 'border-skyhook-500',
1486
- warning: 'border-amber-500',
1487
- error: 'border-red-500',
1488
- info: 'border-sky-500',
1489
- }[tone]
1490
- const value$ = <div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
1491
- const label$ = <div className="text-xs text-theme-text-tertiary">{label}</div>
1492
- if (!onClick) {
1493
- return (
1494
- <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
1495
- {value$}
1496
- {label$}
1497
- </div>
1498
- )
1499
- }
1500
- return (
1501
- <button
1502
- type="button"
1503
- onClick={onClick}
1504
- aria-pressed={active}
1505
- className={clsx(
1506
- 'cursor-pointer rounded-md border bg-theme-base px-3 py-2 text-left transition-colors hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-skyhook-500',
1507
- active ? activeBorderClass : 'border-theme-border',
1508
- )}
1509
- >
1510
- {value$}
1511
- {label$}
1512
- </button>
1513
- )
1514
- }
1515
-
1516
- function TableHead({ children, className = '' }: { children: ReactNode; className?: string }) {
1517
- return <th className={`border-b border-theme-border px-3 py-2 font-medium ${className}`}>{children}</th>
1518
- }
1519
-
1520
1528
  // DestinationCell renders line 1 of the Destination column. Three modes:
1521
1529
  // - No fleet stamp (single-cluster OSS): show the raw `row.destination`
1522
1530
  // string from the Argo/Flux spec (typically `https://kubernetes.default.svc`
@@ -1652,23 +1660,39 @@ export function summarizeGitOpsRows(rows: GitOpsRow[]) {
1652
1660
  )
1653
1661
  }
1654
1662
 
1663
+ // Natural direction per column, used the first time a column is clicked: the
1664
+ // urgency-ordered facets default ascending (most-urgent first); recency defaults
1665
+ // to newest-first.
1666
+ const SORT_DEFAULT_DIR: Record<SortKey, SortDir> = { urgency: 'asc', name: 'asc', health: 'asc', sync: 'asc', lastSync: 'desc', project: 'asc' }
1667
+
1668
+ // Ascending comparator per column (the caller flips it for descending). health
1669
+ // ascends worst→best (urgencyRank 0 = broken); lastSync ascends oldest→newest.
1655
1670
  function compareRows(a: GitOpsRow, b: GitOpsRow, sortKey: SortKey) {
1656
- if (sortKey === 'health') return urgencyRank(a) - urgencyRank(b) || a.name.localeCompare(b.name)
1671
+ if (sortKey === 'urgency') return urgencyRank(a) - urgencyRank(b) || a.name.localeCompare(b.name)
1672
+ if (sortKey === 'health') return healthRank(a) - healthRank(b) || a.name.localeCompare(b.name)
1657
1673
  if (sortKey === 'sync') return syncRank(a.sync) - syncRank(b.sync) || a.name.localeCompare(b.name)
1658
- if (sortKey === 'lastSync') return (Date.parse(b.lastSync || b.createdAt) || 0) - (Date.parse(a.lastSync || a.createdAt) || 0)
1674
+ if (sortKey === 'lastSync') return (Date.parse(a.lastSync || a.createdAt) || 0) - (Date.parse(b.lastSync || b.createdAt) || 0)
1659
1675
  if (sortKey === 'project') return a.project.localeCompare(b.project) || a.name.localeCompare(b.name)
1660
1676
  return a.name.localeCompare(b.name)
1661
1677
  }
1662
1678
 
1663
- // urgencyRank groups rows by what the operator should do about them.
1664
- // Tiers:
1665
- // 0 — broken (Terminating, Degraded, Missing). Won't self-heal.
1666
- // 1 — OutOfSync, no auto-sync. Drifted, waiting for a human.
1667
- // 2 — OutOfSync with auto-sync. Healing in progress.
1668
- // 3 — Progressing / Reconciling. Mid-rollout.
1669
- // 4 — Unknown. Indeterminate.
1670
- // 5 — Suspended. Intentional non-green.
1671
- // 6 — Synced + Healthy. Calm.
1679
+ // Sort the Health column by health STATUS, worst first — so clicking the column
1680
+ // groups rows the way its header implies. (This is deliberately NOT the old
1681
+ // composite "urgency" rank that also weighed sync / auto-sync: that conflated two
1682
+ // dimensions and made the Health column look broken — an OutOfSync-but-Healthy
1683
+ // row sorting above a Synced-Progressing one. A sync-aware triage ordering is a
1684
+ // reasonable separate default, but not what "sort by Health" should mean.)
1685
+ const HEALTH_RANK: Record<string, number> = {
1686
+ Degraded: 0, Missing: 0, Suspended: 1, Unknown: 2, Progressing: 3, Healthy: 4,
1687
+ }
1688
+ function healthRank(row: GitOpsRow): number {
1689
+ return HEALTH_RANK[row.health] ?? 2
1690
+ }
1691
+
1692
+ // urgencyRank groups rows by what the operator should do about them — the curated
1693
+ // DEFAULT order (sortKey 'urgency'), surfacing broken/drifted apps first.
1694
+ // 0 broken (Terminating/Degraded/Missing) · 1 OutOfSync, manual · 2 OutOfSync,
1695
+ // auto · 3 Progressing/Reconciling · 4 Unknown · 5 Suspended · 6 Synced+Healthy.
1672
1696
  function urgencyRank(row: GitOpsRow): number {
1673
1697
  if (row.terminating) return 0
1674
1698
  if (row.health === 'Degraded' || row.health === 'Missing') return 0