@skyhook-io/k8s-ui 1.7.15 → 1.8.1

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 (42) hide show
  1. package/package.json +2 -1
  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 +14 -25
  11. package/src/components/checks/severity.ts +11 -11
  12. package/src/components/gitops/GitOpsTableView.tsx +307 -280
  13. package/src/components/gitops/detail-helpers.test.ts +1 -1
  14. package/src/components/issues/severity.ts +19 -11
  15. package/src/components/resources/ResourcesSidebar.tsx +3 -1
  16. package/src/components/resources/ResourcesView.tsx +13 -0
  17. package/src/components/resources/index.ts +1 -0
  18. package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
  19. package/src/components/resources/renderers/HPARenderer.tsx +93 -86
  20. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  21. package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
  22. package/src/components/resources/resource-utils-hpa.test.ts +30 -0
  23. package/src/components/resources/resource-utils-hpa.ts +106 -0
  24. package/src/components/resources/resource-utils.ts +6 -10
  25. package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
  26. package/src/components/timeline/TimelineList.tsx +31 -3
  27. package/src/components/timeline/TimelineSwimlanes.tsx +27 -5
  28. package/src/components/ui/DistributionBar.tsx +88 -0
  29. package/src/components/ui/Facet.tsx +130 -0
  30. package/src/components/ui/PageHeader.tsx +43 -0
  31. package/src/components/ui/SearchPillInput.tsx +278 -0
  32. package/src/components/ui/SortableTh.tsx +58 -0
  33. package/src/components/ui/SummaryTile.tsx +60 -0
  34. package/src/components/ui/drawer-components.tsx +56 -17
  35. package/src/components/ui/index.ts +12 -0
  36. package/src/components/workload/WorkloadView.tsx +18 -1
  37. package/src/theme/tailwind-theme.css +1 -0
  38. package/src/theme/variables.css +7 -3
  39. package/src/types/core.ts +54 -2
  40. package/src/utils/rbac-blast-radius.test.ts +48 -3
  41. package/src/utils/rbac-blast-radius.ts +14 -4
  42. 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
@@ -86,7 +102,7 @@ export interface FleetClusterStamp {
86
102
  name: string
87
103
  }
88
104
  export type FleetDestinationMatch = 'in_cluster' | 'exact' | 'inferred' | 'unmatched'
89
- // FleetDestinationConfidence is a coarse signal the SPA uses to style
105
+ // FleetDestinationConfidence is a coarse signal the frontend uses to style
90
106
  // the destination chip. `high` = URL-equality match (either direct or
91
107
  // via Argo cluster-secret), `medium` = name-equality match (more
92
108
  // fragile, more likely to be a false positive when two clusters share
@@ -100,7 +116,7 @@ export interface FleetDestinationStamp {
100
116
  // Confidence + reason are populated for non-unmatched rows. They power
101
117
  // the chip's visual treatment (a checkmark on high-confidence matches)
102
118
  // and its title= tooltip respectively. Both come from the hub —
103
- // adding new values shouldn't break the SPA (unknown confidence
119
+ // adding new values shouldn't break the frontend (unknown confidence
104
120
  // falls back to no special styling).
105
121
  confidence?: FleetDestinationConfidence
106
122
  reason?: string
@@ -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 -------------------------------------------------------
@@ -166,7 +184,7 @@ export interface GitOpsTableViewProps {
166
184
  onRefresh?: () => void
167
185
  // Row click — caller routes to its own detail page. When the host also
168
186
  // passes `rowHrefFor`, the callback receives the MouseEvent so it can
169
- // `preventDefault()` for SPA-local nav (e.g. react-router) or skip the
187
+ // `preventDefault()` for same-tree nav (e.g. react-router) or skip the
170
188
  // preventDefault to let the anchor's default full-page navigation run
171
189
  // (required for cross-router-boundary links).
172
190
  onRowClick: (row: GitOpsRow, event?: ReactMouseEvent) => void
@@ -199,8 +217,11 @@ export interface GitOpsTableViewProps {
199
217
  searchHotkey?: boolean
200
218
  // emptyStateTitle / emptyStateBody override the "No GitOps resources
201
219
  // detected" copy. Hub passes "No GitOps resources across the fleet".
202
- emptyStateTitle?: string
203
- emptyStateBody?: string
220
+ // ReactNode (not just string) so a host can split the body across lines
221
+ // (e.g. a `<br/>` between "what's wrong" and "what to do") or emphasize
222
+ // part of it. Strings still work unchanged.
223
+ emptyStateTitle?: ReactNode
224
+ emptyStateBody?: ReactNode
204
225
  /**
205
226
  * Which side the filter rail sits on. Default 'left' (OSS Radar, which
206
227
  * has no app sidebar). A host with its own left navigation rail (the
@@ -270,10 +291,30 @@ export function GitOpsTableView({
270
291
  const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set())
271
292
  const [showLabelsDropdown, setShowLabelsDropdown] = useState(false)
272
293
  const [labelSearch, setLabelSearch] = useState('')
273
- const [automationFilter, setAutomationFilter] = useState<'all' | 'auto' | 'manual' | 'suspended'>('all')
294
+ // Auto-sync / Manual / Suspended are INDEPENDENT row attributes (a Flux app can
295
+ // be auto-reconciling AND suspended), so this is a multi-select facet like Sync
296
+ // and Health — not the old single-select that conflated the mode (auto vs
297
+ // manual) with the orthogonal suspended state.
298
+ const [automationFilters, setAutomationFilters] = useState<Set<'auto' | 'manual' | 'suspended'>>(new Set())
299
+ const toggleAutomation = useCallback((value: 'auto' | 'manual' | 'suspended') => {
300
+ setAutomationFilters((prev) => {
301
+ const next = new Set(prev)
302
+ next.has(value) ? next.delete(value) : next.add(value)
303
+ return next
304
+ })
305
+ }, [])
274
306
  const [lifecycleFilter, setLifecycleFilter] = useState<'all' | 'terminating' | 'active'>('all')
275
- const [reconcilingOnly, setReconcilingOnly] = useState(false)
276
- const [sortKey, setSortKey] = useState<SortKey>('health')
307
+ const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: 'urgency', dir: 'asc' })
308
+ // Shared refresh feedback (spin ≥400ms → checkmark) so clicking Refresh gives
309
+ // the same visual confirmation as every other view, even when the refetch is
310
+ // instant (the cache is already warm).
311
+ const [triggerRefresh, , refreshPhase] = useRefreshAnimation(onRefresh ?? (() => {}))
312
+ // Clicking a column sorts by it (starting at the column's natural direction —
313
+ // e.g. last-sync newest-first); clicking the active column reverses.
314
+ const onSort = useCallback(
315
+ (key: SortKey) => setSort((prev) => (prev.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: SORT_DEFAULT_DIR[key] })),
316
+ [],
317
+ )
277
318
 
278
319
  const hasLocalFilters =
279
320
  !!search ||
@@ -282,7 +323,7 @@ export function GitOpsTableView({
282
323
  projectFilters.size > 0 ||
283
324
  namespaceFilters.size > 0 ||
284
325
  labelFilters.size > 0 ||
285
- automationFilter !== 'all' ||
326
+ automationFilters.size > 0 ||
286
327
  lifecycleFilter !== 'all'
287
328
  const hasGlobalNamespaceFilter = !!onClearNamespaces && (globalNamespaces?.length ?? 0) > 0
288
329
  const hasAnyFilter = hasLocalFilters || hasGlobalNamespaceFilter
@@ -327,6 +368,25 @@ export function GitOpsTableView({
327
368
  )
328
369
  const syncCounts = useMemo(() => countMap(allRows.map((row) => row.sync)), [allRows])
329
370
  const healthCounts = useMemo(() => countMap(allRows.map((row) => row.health)), [allRows])
371
+ const automationCounts = useMemo(() => ({
372
+ auto: allRows.filter((row) => row.autoSync).length,
373
+ manual: allRows.filter((row) => !row.autoSync).length,
374
+ suspended: allRows.filter((row) => row.suspended).length,
375
+ }), [allRows])
376
+ // The Destination column earns its width only when destinations actually vary
377
+ // — in single-cluster OSS every row is the same in-cluster API server, so the
378
+ // column is dead weight. Show it when any row is cross-cluster (Hub) or when
379
+ // destinations differ; hide when they all collapse to the in-cluster default.
380
+ const showDestination = useMemo(() => {
381
+ // Fleet/Hub mode owns the Destination column outright (it's the multi-cluster
382
+ // product surface — the destination filter + cross-cluster matching live here).
383
+ if (onDestinationFilterChange) return true
384
+ if (allRows.some((r) => r._destination && r._destination.match !== 'in_cluster')) return true
385
+ const dests = new Set(allRows.map((r) => r.destination).filter(Boolean))
386
+ if (dests.size > 1) return true
387
+ const only = [...dests][0] ?? ''
388
+ return !!only && !/kubernetes\.default\.svc/i.test(only)
389
+ }, [allRows, onDestinationFilterChange])
330
390
  const labels = useMemo(() => countLabels(allRows), [allRows])
331
391
  const filteredRows = useMemo(() => {
332
392
  const q = search.trim().toLowerCase()
@@ -354,12 +414,13 @@ export function GitOpsTableView({
354
414
  if (projectFilters.size > 0 && !projectFilters.has(row.project || '(none)')) return false
355
415
  if (namespaceFilters.size > 0 && !namespaceFilters.has(row.namespace || '(cluster)')) return false
356
416
  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
417
+ if (automationFilters.size > 0 && !(
418
+ (automationFilters.has('auto') && row.autoSync) ||
419
+ (automationFilters.has('manual') && !row.autoSync) ||
420
+ (automationFilters.has('suspended') && row.suspended)
421
+ )) return false
360
422
  if (lifecycleFilter === 'terminating' && !row.terminating) return false
361
423
  if (lifecycleFilter === 'active' && row.terminating) return false
362
- if (reconcilingOnly && row.sync !== 'Reconciling' && row.health !== 'Progressing') return false
363
424
  if (destinationFilter && destinationFilter !== 'all') {
364
425
  const match = row._destination?.match
365
426
  if (destinationFilter === 'this-cluster' && match !== 'in_cluster') return false
@@ -372,8 +433,8 @@ export function GitOpsTableView({
372
433
  }
373
434
  return true
374
435
  })
375
- return [...rows].sort((a, b) => compareRows(a, b, sortKey))
376
- }, [allRows, automationFilter, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sortKey, syncFilters, destinationFilter, reconcilingOnly])
436
+ return [...rows].sort((a, b) => compareRows(a, b, sort.key) * (sort.dir === 'asc' ? 1 : -1))
437
+ }, [allRows, automationFilters, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sort, syncFilters, destinationFilter])
377
438
 
378
439
  const terminatingCount = useMemo(() => allRows.filter((row) => row.terminating).length, [allRows])
379
440
 
@@ -384,29 +445,24 @@ export function GitOpsTableView({
384
445
  setProjectFilters(new Set())
385
446
  setNamespaceFilters(new Set())
386
447
  setLabelFilters(new Set())
387
- setAutomationFilter('all')
448
+ setAutomationFilters(new Set())
388
449
  setLifecycleFilter('all')
389
- setReconcilingOnly(false)
390
450
  onClearNamespaces?.()
391
451
  onDestinationFilterChange?.('all')
392
452
  }, [onClearNamespaces, onDestinationFilterChange])
393
453
 
454
+ // True when nothing is filtered at all — backs the Total tile's active state.
394
455
  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
- },
456
+ () =>
457
+ search === '' &&
458
+ syncFilters.size === 0 &&
459
+ healthFilters.size === 0 &&
460
+ projectFilters.size === 0 &&
461
+ namespaceFilters.size === 0 &&
462
+ labelFilters.size === 0 &&
463
+ automationFilters.size === 0 &&
464
+ lifecycleFilter === 'all' &&
465
+ (!destinationFilter || destinationFilter === 'all'),
410
466
  [
411
467
  search,
412
468
  syncFilters,
@@ -414,10 +470,9 @@ export function GitOpsTableView({
414
470
  projectFilters,
415
471
  namespaceFilters,
416
472
  labelFilters,
417
- automationFilter,
473
+ automationFilters,
418
474
  lifecycleFilter,
419
475
  destinationFilter,
420
- reconcilingOnly,
421
476
  ],
422
477
  )
423
478
 
@@ -426,9 +481,12 @@ export function GitOpsTableView({
426
481
  // namespace-scoped zero is NOT the same as cluster-empty. Fall through
427
482
  // to the actionable empty state below when the host owns a namespace
428
483
  // pick we can clear; otherwise the user lands here with no escape hatch.
429
- if (totalGitOps === 0 && !loading && !hasGlobalNamespaceFilter) {
484
+ // Also require zero actual rows: the cold-cache retry can populate `rows`
485
+ // before the separate counts map catches up, and a populated table must not
486
+ // be hidden behind a "nothing here" screen.
487
+ if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter) {
430
488
  return (
431
- <div className="flex h-full min-h-0 flex-1 items-center justify-center bg-theme-base p-4">
489
+ <div className="flex h-full min-h-0 flex-1 items-start justify-center bg-theme-base px-4 pb-4 pt-[22vh]">
432
490
  <div className="rounded-lg border border-theme-border bg-theme-surface p-8 text-center">
433
491
  <GitBranch className="mx-auto h-8 w-8 text-theme-text-tertiary" />
434
492
  <h2 className="mt-3 text-base font-semibold text-theme-text-primary">
@@ -444,6 +502,11 @@ export function GitOpsTableView({
444
502
 
445
503
  const showCrossClusterTile = typeof crossClusterCount === 'number' && mode === 'applications'
446
504
 
505
+ // Header tiles unify with the facet rail: each STATUS tile toggles its own
506
+ // dimension and composes with the other facets + search (clicking "Out of
507
+ // sync" adds sync=OutOfSync without wiping an active health filter or your
508
+ // search). `active` = "this dimension is exactly this value". The Total tile
509
+ // is the reset — it clears everything.
447
510
  const summaryTiles: SummaryTileSpec[] = [
448
511
  {
449
512
  key: 'total',
@@ -451,40 +514,44 @@ export function GitOpsTableView({
451
514
  value: allRows.length,
452
515
  tone: 'neutral',
453
516
  active: noOtherFiltersActive(),
517
+ apply: clearAllFilters,
518
+ clear: clearAllFilters,
454
519
  },
455
520
  {
456
521
  key: 'outOfSync',
457
522
  label: 'Out of sync',
458
523
  value: statusSummary.outOfSync,
459
524
  tone: 'warning',
460
- active:
461
- syncFilters.size === 1 && syncFilters.has('OutOfSync') && noOtherFiltersActive('sync'),
525
+ active: syncFilters.size === 1 && syncFilters.has('OutOfSync'),
462
526
  apply: () => setSyncFilters(new Set(['OutOfSync'])),
527
+ clear: () => setSyncFilters(new Set()),
463
528
  },
464
529
  {
465
530
  key: 'degraded',
466
531
  label: 'Degraded',
467
532
  value: statusSummary.degraded,
468
533
  tone: 'error',
469
- active:
470
- healthFilters.size === 1 && healthFilters.has('Degraded') && noOtherFiltersActive('health'),
534
+ active: healthFilters.size === 1 && healthFilters.has('Degraded'),
471
535
  apply: () => setHealthFilters(new Set(['Degraded'])),
536
+ clear: () => setHealthFilters(new Set()),
472
537
  },
473
538
  {
474
539
  key: 'suspended',
475
540
  label: 'Suspended',
476
541
  value: statusSummary.suspended,
477
542
  tone: 'warning',
478
- active: automationFilter === 'suspended' && noOtherFiltersActive('automation'),
479
- apply: () => setAutomationFilter('suspended'),
543
+ active: automationFilters.size === 1 && automationFilters.has('suspended'),
544
+ apply: () => setAutomationFilters(new Set(['suspended'])),
545
+ clear: () => setAutomationFilters(new Set()),
480
546
  },
481
547
  {
482
548
  key: 'reconciling',
483
549
  label: 'Reconciling',
484
- value: statusSummary.reconciling,
550
+ value: syncCounts.get('Reconciling') ?? 0,
485
551
  tone: 'info',
486
- active: reconcilingOnly && noOtherFiltersActive('reconciling'),
487
- apply: () => setReconcilingOnly(true),
552
+ active: syncFilters.size === 1 && syncFilters.has('Reconciling'),
553
+ apply: () => setSyncFilters(new Set(['Reconciling'])),
554
+ clear: () => setSyncFilters(new Set()),
488
555
  },
489
556
  ...(showCrossClusterTile
490
557
  ? [
@@ -493,19 +560,42 @@ export function GitOpsTableView({
493
560
  label: 'Cross-cluster',
494
561
  value: crossClusterCount!,
495
562
  tone: 'info' as const,
496
- active: destinationFilter === 'cross-cluster' && noOtherFiltersActive('destination'),
563
+ active: destinationFilter === 'cross-cluster',
497
564
  apply: () => onDestinationFilterChange?.('cross-cluster'),
565
+ clear: () => onDestinationFilterChange?.('all'),
498
566
  },
499
567
  ]
500
568
  : []),
501
569
  ]
502
570
 
503
571
  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
- >
572
+ <div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-theme-base">
573
+ {/* Full-width header on top; the filter facet + content sit below it. */}
574
+ <div className="shrink-0 border-b border-theme-border bg-theme-base px-4 py-4">
575
+ <PageHeader
576
+ icon={GitBranch}
577
+ title="GitOps"
578
+ description="Applications and reconciliations with source, destination, sync, and health state."
579
+ actions={summaryTiles.map((tile) => (
580
+ <SummaryTile
581
+ key={tile.key}
582
+ label={tile.label}
583
+ value={tile.value}
584
+ tone={tile.tone}
585
+ active={tile.active}
586
+ onClick={() => {
587
+ if (tile.active) tile.clear?.()
588
+ else tile.apply?.()
589
+ }}
590
+ />
591
+ ))}
592
+ />
593
+ </div>
594
+ <div
595
+ className={`flex min-w-0 flex-1 overflow-hidden max-[899px]:flex-col ${
596
+ filtersSide === 'right' ? 'min-[900px]:flex-row-reverse' : ''
597
+ }`}
598
+ >
509
599
  <GitOpsFilterSidebar
510
600
  side={filtersSide}
511
601
  mode={mode}
@@ -517,8 +607,9 @@ export function GitOpsTableView({
517
607
  healthCounts={healthCounts}
518
608
  healthFilters={healthFilters}
519
609
  onToggleHealth={(value) => toggleSet(healthFilters, setHealthFilters, value)}
520
- automationFilter={automationFilter}
521
- onAutomationFilterChange={setAutomationFilter}
610
+ automationFilters={automationFilters}
611
+ automationCounts={automationCounts}
612
+ onToggleAutomation={toggleAutomation}
522
613
  lifecycleFilter={lifecycleFilter}
523
614
  onLifecycleFilterChange={setLifecycleFilter}
524
615
  terminatingCount={terminatingCount}
@@ -532,32 +623,6 @@ export function GitOpsTableView({
532
623
  />
533
624
 
534
625
  <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
626
  {forbiddenSecretsClusters && forbiddenSecretsClusters.length > 0 && mode === 'applications' && (
562
627
  // Hub-only graceful-degradation note: when the user lacks `get
563
628
  // secrets` in the argocd namespace on a controller, the hub
@@ -589,17 +654,12 @@ export function GitOpsTableView({
589
654
  Showing {filteredRows.length} of {allRows.length}
590
655
  </span>
591
656
  )}
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>
657
+ {/* Table mode sorts via clickable column headers (Resources-table
658
+ pattern); tile mode has no headers, so it keeps a compact sort
659
+ control wired to the same sort state. */}
660
+ {viewMode === 'tiles' && (
661
+ <GitOpsSortMenu sortKey={sort.key} onChange={(k) => setSort({ key: k, dir: SORT_DEFAULT_DIR[k] })} />
662
+ )}
603
663
  {labels.length > 0 && (
604
664
  <LabelsDropdown
605
665
  labels={labels}
@@ -651,10 +711,12 @@ export function GitOpsTableView({
651
711
  <Tooltip content="Refresh GitOps resources">
652
712
  <button
653
713
  type="button"
654
- onClick={onRefresh}
714
+ onClick={triggerRefresh}
655
715
  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
716
  >
657
- <RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
717
+ {refreshPhase === 'success'
718
+ ? <Check className="h-3.5 w-3.5 text-emerald-500" />
719
+ : <RefreshCw className={clsx('h-3.5 w-3.5', (refreshPhase === 'spinning' || loading) && 'animate-spin')} />}
658
720
  </button>
659
721
  </Tooltip>
660
722
  )}
@@ -701,8 +763,11 @@ export function GitOpsTableView({
701
763
  ) : (
702
764
  <GitOpsTable
703
765
  rows={filteredRows}
766
+ sort={sort}
767
+ onSort={onSort}
704
768
  onOpen={onRowClick}
705
769
  hrefFor={rowHrefFor}
770
+ showDestination={showDestination}
706
771
  onDestinationClick={onDestinationClick}
707
772
  destinationHrefFor={destinationHrefFor}
708
773
  onRowAction={onRowAction}
@@ -711,6 +776,7 @@ export function GitOpsTableView({
711
776
  )}
712
777
  </div>
713
778
  </div>
779
+ </div>
714
780
  </div>
715
781
  )
716
782
  }
@@ -731,8 +797,9 @@ function GitOpsFilterSidebar({
731
797
  healthCounts,
732
798
  healthFilters,
733
799
  onToggleHealth,
734
- automationFilter,
735
- onAutomationFilterChange,
800
+ automationFilters,
801
+ automationCounts,
802
+ onToggleAutomation,
736
803
  lifecycleFilter,
737
804
  onLifecycleFilterChange,
738
805
  terminatingCount,
@@ -754,8 +821,9 @@ function GitOpsFilterSidebar({
754
821
  healthCounts: Map<string, number>
755
822
  healthFilters: Set<string>
756
823
  onToggleHealth: (value: string) => void
757
- automationFilter: 'all' | 'auto' | 'manual' | 'suspended'
758
- onAutomationFilterChange: (value: 'all' | 'auto' | 'manual' | 'suspended') => void
824
+ automationFilters: Set<'auto' | 'manual' | 'suspended'>
825
+ automationCounts: { auto: number; manual: number; suspended: number }
826
+ onToggleAutomation: (value: 'auto' | 'manual' | 'suspended') => void
759
827
  lifecycleFilter: 'all' | 'terminating' | 'active'
760
828
  onLifecycleFilterChange: (value: 'all' | 'terminating' | 'active') => void
761
829
  terminatingCount: number
@@ -769,20 +837,21 @@ function GitOpsFilterSidebar({
769
837
  }) {
770
838
  return (
771
839
  <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'
840
+ 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 ${
841
+ side === 'right' ? 'border-l max-[899px]:border-l-0' : 'border-r max-[899px]:border-r-0'
774
842
  }`}
775
843
  >
776
844
  <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>
845
+ <span className="text-sm font-medium text-theme-text-secondary">Filters</span>
778
846
  <button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
779
847
  Clear
780
848
  </button>
781
849
  </div>
782
850
  <div className="flex-1 overflow-y-auto">
851
+ {AVAILABLE_MODES.length > 1 && (
783
852
  <GitOpsFilterSection icon={GitBranch} title="Scope">
784
853
  <div className="grid grid-cols-2 gap-1">
785
- {(['applications'] as GitOpsMode[]).map((item) => (
854
+ {AVAILABLE_MODES.map((item) => (
786
855
  <button
787
856
  key={item}
788
857
  type="button"
@@ -799,6 +868,7 @@ function GitOpsFilterSidebar({
799
868
  ))}
800
869
  </div>
801
870
  </GitOpsFilterSection>
871
+ )}
802
872
 
803
873
  <GitOpsFilterSection icon={CheckCircle2} title="Sync">
804
874
  <GitOpsFacetButton label="Synced" count={syncCounts.get('Synced') ?? 0} active={syncFilters.has('Synced')} tone="success" onClick={() => onToggleSync('Synced')} />
@@ -815,28 +885,10 @@ function GitOpsFilterSidebar({
815
885
  <GitOpsFacetButton label="Unknown" count={healthCounts.get('Unknown') ?? 0} active={healthFilters.has('Unknown')} onClick={() => onToggleHealth('Unknown')} />
816
886
  </GitOpsFilterSection>
817
887
 
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>
888
+ <GitOpsFilterSection icon={CircleDot} title="Automation (Sync policy)">
889
+ <GitOpsFacetButton label="Auto-sync" count={automationCounts.auto} active={automationFilters.has('auto')} onClick={() => onToggleAutomation('auto')} />
890
+ <GitOpsFacetButton label="Manual" count={automationCounts.manual} active={automationFilters.has('manual')} onClick={() => onToggleAutomation('manual')} />
891
+ <GitOpsFacetButton label="Suspended" count={automationCounts.suspended} active={automationFilters.has('suspended')} tone="warning" onClick={() => onToggleAutomation('suspended')} />
840
892
  </GitOpsFilterSection>
841
893
 
842
894
  {terminatingCount > 0 && (
@@ -894,62 +946,14 @@ function GitOpsFilterSidebar({
894
946
  )
895
947
  }
896
948
 
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
- }
949
+ // GitOps's filter rail is built from the shared faceted-filter primitives
950
+ // (ui/Facet). These aliases keep the existing GitOps* import surface
951
+ // (GitOpsGraphFilterRail imports them from here) while the look + behavior live
952
+ // in one place — change the facet once, every rail follows.
953
+ export const GitOpsFilterSection = FacetSection
954
+ export const GitOpsFacetButton = FacetButton
913
955
 
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.
956
+ // A view-mode toggle (table/tiles) — not a facet; stays local.
953
957
  export function GitOpsIconToggle({ active, label, icon: Icon, onClick }: { active: boolean; label: string; icon: ComponentType<{ className?: string }>; onClick: () => void }) {
954
958
  return (
955
959
  <Tooltip content={label}>
@@ -966,6 +970,60 @@ export function GitOpsIconToggle({ active, label, icon: Icon, onClick }: { activ
966
970
  )
967
971
  }
968
972
 
973
+ // Tile mode has no sortable column headers, so it gets this themed sort menu
974
+ // (a native <select> can't be styled to match the rest of the toolbar).
975
+ const SORT_OPTIONS: [SortKey, string][] = [
976
+ ['urgency', 'Priority'],
977
+ ['health', 'Health'],
978
+ ['sync', 'Sync'],
979
+ ['lastSync', 'Last sync'],
980
+ ['project', 'Project'],
981
+ ['name', 'Name'],
982
+ ]
983
+ function GitOpsSortMenu({ sortKey, onChange }: { sortKey: SortKey; onChange: (key: SortKey) => void }) {
984
+ const [open, setOpen] = useState(false)
985
+ const ref = useRef<HTMLDivElement>(null)
986
+ useEffect(() => {
987
+ if (!open) return
988
+ const onDown = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false) }
989
+ document.addEventListener('mousedown', onDown)
990
+ return () => document.removeEventListener('mousedown', onDown)
991
+ }, [open])
992
+ const current = SORT_OPTIONS.find(([k]) => k === sortKey)?.[1] ?? 'Name'
993
+ return (
994
+ <div ref={ref} className="relative">
995
+ <button
996
+ type="button"
997
+ onClick={() => setOpen((o) => !o)}
998
+ aria-label="Sort"
999
+ 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"
1000
+ >
1001
+ <ArrowDownUp className="h-3.5 w-3.5" />
1002
+ Sort: {current}
1003
+ <ChevronDown className="h-3 w-3 opacity-60" />
1004
+ </button>
1005
+ {open && (
1006
+ <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">
1007
+ {SORT_OPTIONS.map(([key, label]) => (
1008
+ <button
1009
+ key={key}
1010
+ type="button"
1011
+ onClick={() => { onChange(key); setOpen(false) }}
1012
+ className={clsx(
1013
+ 'flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-theme-hover',
1014
+ key === sortKey ? 'font-medium text-theme-text-primary' : 'text-theme-text-secondary',
1015
+ )}
1016
+ >
1017
+ <Check className={clsx('h-3.5 w-3.5 shrink-0', key === sortKey ? 'opacity-100 text-skyhook-500' : 'opacity-0')} />
1018
+ {label}
1019
+ </button>
1020
+ ))}
1021
+ </div>
1022
+ )}
1023
+ </div>
1024
+ )
1025
+ }
1026
+
969
1027
  function LabelsDropdown({
970
1028
  labels,
971
1029
  activeLabels,
@@ -988,8 +1046,17 @@ function LabelsDropdown({
988
1046
  const filtered = search.trim()
989
1047
  ? labels.filter((label) => label.name.toLowerCase().includes(search.trim().toLowerCase()))
990
1048
  : labels
1049
+ const containerRef = useRef<HTMLDivElement>(null)
1050
+ useEffect(() => {
1051
+ if (!open) return
1052
+ const onDown = (e: MouseEvent) => {
1053
+ if (!containerRef.current?.contains(e.target as Node)) onOpenChange(false)
1054
+ }
1055
+ document.addEventListener('mousedown', onDown)
1056
+ return () => document.removeEventListener('mousedown', onDown)
1057
+ }, [open, onOpenChange])
991
1058
  return (
992
- <div className="relative">
1059
+ <div ref={containerRef} className="relative">
993
1060
  <button
994
1061
  type="button"
995
1062
  onClick={() => onOpenChange(!open)}
@@ -1076,40 +1143,37 @@ function StatusDistribution({ rows }: { rows: GitOpsRow[] }) {
1076
1143
  // the bar — sync state is visible elsewhere (the OutOfSync summary
1077
1144
  // tile, the Sync column, the filter rail).
1078
1145
  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
1146
  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>
1147
+ <DistributionBar
1148
+ ariaLabel="Health distribution"
1149
+ segments={[
1150
+ { key: 'degraded', count: summary.degraded, fillClass: 'bg-red-500' },
1151
+ { key: 'progressing', count: summary.progressing, fillClass: 'bg-sky-500' },
1152
+ { key: 'healthy', count: summary.healthy, fillClass: 'bg-emerald-500' },
1153
+ { key: 'unknown', count: Math.max(0, rows.length - summary.healthy - summary.progressing - summary.degraded), fillClass: 'bg-theme-text-tertiary/40' },
1154
+ ]}
1155
+ />
1098
1156
  )
1099
1157
  }
1100
1158
 
1101
1159
  function GitOpsTable({
1102
1160
  rows,
1161
+ sort,
1162
+ onSort,
1103
1163
  onOpen,
1104
1164
  hrefFor,
1165
+ showDestination = true,
1105
1166
  onDestinationClick,
1106
1167
  destinationHrefFor,
1107
1168
  onRowAction,
1108
1169
  pendingRowActions,
1109
1170
  }: {
1110
1171
  rows: GitOpsRow[]
1172
+ sort: { key: SortKey; dir: SortDir }
1173
+ onSort: (key: SortKey) => void
1111
1174
  onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
1112
1175
  hrefFor?: (row: GitOpsRow) => string
1176
+ showDestination?: boolean
1113
1177
  onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
1114
1178
  destinationHrefFor?: (row: GitOpsRow, destination: FleetDestinationStamp) => string
1115
1179
  onRowAction?: (row: GitOpsRow, action: GitOpsRowAction) => void
@@ -1118,19 +1182,19 @@ function GitOpsTable({
1118
1182
  const showActions = !!onRowAction
1119
1183
  return (
1120
1184
  <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>
1185
+ <thead className="sticky top-0 z-10 bg-theme-base">
1186
+ <tr>
1187
+ <SortableTh label="Application" sortKey="name" activeKey={sort.key} direction={sort.dir} onSort={onSort} className={showActions ? 'w-[16%]' : 'w-[22%]'} />
1188
+ <SortableTh label="Project" sortKey="project" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1189
+ <SortableTh label="Sync" sortKey="sync" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1190
+ <SortableTh label="Health" sortKey="health" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1191
+ <th className={clsx(TH_CLASS, showDestination ? 'w-[20%]' : 'w-[28%]')}>Source</th>
1192
+ {showDestination && <th className={clsx(TH_CLASS, 'w-[14%]')}>Destination</th>}
1193
+ <SortableTh label="Last Sync" sortKey="lastSync" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[10%]" />
1130
1194
  {showActions && (
1131
- <TableHead className="w-[6%] text-right">
1195
+ <th className={clsx(TH_CLASS, 'w-[6%] text-right')}>
1132
1196
  <span className="sr-only">Actions</span>
1133
- </TableHead>
1197
+ </th>
1134
1198
  )}
1135
1199
  </tr>
1136
1200
  </thead>
@@ -1199,10 +1263,12 @@ function GitOpsTable({
1199
1263
  <div className="truncate text-theme-text-primary">{row.repository || row.chart || '-'}</div>
1200
1264
  <div className="truncate text-xs text-theme-text-tertiary">{[row.targetRevision, row.path || row.chart].filter(Boolean).join(' · ') || '-'}</div>
1201
1265
  </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>
1266
+ {showDestination && (
1267
+ <TableCell>
1268
+ <DestinationCell row={row} onDestinationClick={onDestinationClick} destinationHrefFor={destinationHrefFor} />
1269
+ <div className="truncate text-xs text-theme-text-tertiary">{row.destinationNamespace || row.namespace || '-'}</div>
1270
+ </TableCell>
1271
+ )}
1206
1272
  <TableCell>
1207
1273
  {row.terminating
1208
1274
  ? <span className="text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</span>
@@ -1462,61 +1528,6 @@ function GitOpsTile({
1462
1528
  )
1463
1529
  }
1464
1530
 
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
1531
  // DestinationCell renders line 1 of the Destination column. Three modes:
1521
1532
  // - No fleet stamp (single-cluster OSS): show the raw `row.destination`
1522
1533
  // string from the Argo/Flux spec (typically `https://kubernetes.default.svc`
@@ -1652,23 +1663,39 @@ export function summarizeGitOpsRows(rows: GitOpsRow[]) {
1652
1663
  )
1653
1664
  }
1654
1665
 
1666
+ // Natural direction per column, used the first time a column is clicked: the
1667
+ // urgency-ordered facets default ascending (most-urgent first); recency defaults
1668
+ // to newest-first.
1669
+ const SORT_DEFAULT_DIR: Record<SortKey, SortDir> = { urgency: 'asc', name: 'asc', health: 'asc', sync: 'asc', lastSync: 'desc', project: 'asc' }
1670
+
1671
+ // Ascending comparator per column (the caller flips it for descending). health
1672
+ // ascends worst→best (urgencyRank 0 = broken); lastSync ascends oldest→newest.
1655
1673
  function compareRows(a: GitOpsRow, b: GitOpsRow, sortKey: SortKey) {
1656
- if (sortKey === 'health') return urgencyRank(a) - urgencyRank(b) || a.name.localeCompare(b.name)
1674
+ if (sortKey === 'urgency') return urgencyRank(a) - urgencyRank(b) || a.name.localeCompare(b.name)
1675
+ if (sortKey === 'health') return healthRank(a) - healthRank(b) || a.name.localeCompare(b.name)
1657
1676
  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)
1677
+ if (sortKey === 'lastSync') return (Date.parse(a.lastSync || a.createdAt) || 0) - (Date.parse(b.lastSync || b.createdAt) || 0)
1659
1678
  if (sortKey === 'project') return a.project.localeCompare(b.project) || a.name.localeCompare(b.name)
1660
1679
  return a.name.localeCompare(b.name)
1661
1680
  }
1662
1681
 
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.
1682
+ // Sort the Health column by health STATUS, worst first — so clicking the column
1683
+ // groups rows the way its header implies. (This is deliberately NOT the old
1684
+ // composite "urgency" rank that also weighed sync / auto-sync: that conflated two
1685
+ // dimensions and made the Health column look broken — an OutOfSync-but-Healthy
1686
+ // row sorting above a Synced-Progressing one. A sync-aware triage ordering is a
1687
+ // reasonable separate default, but not what "sort by Health" should mean.)
1688
+ const HEALTH_RANK: Record<string, number> = {
1689
+ Degraded: 0, Missing: 0, Suspended: 1, Unknown: 2, Progressing: 3, Healthy: 4,
1690
+ }
1691
+ function healthRank(row: GitOpsRow): number {
1692
+ return HEALTH_RANK[row.health] ?? 2
1693
+ }
1694
+
1695
+ // urgencyRank groups rows by what the operator should do about them — the curated
1696
+ // DEFAULT order (sortKey 'urgency'), surfacing broken/drifted apps first.
1697
+ // 0 broken (Terminating/Degraded/Missing) · 1 OutOfSync, manual · 2 OutOfSync,
1698
+ // auto · 3 Progressing/Reconciling · 4 Unknown · 5 Suspended · 6 Synced+Healthy.
1672
1699
  function urgencyRank(row: GitOpsRow): number {
1673
1700
  if (row.terminating) return 0
1674
1701
  if (row.health === 'Degraded' || row.health === 'Missing') return 0