@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.
- package/package.json +7 -7
- package/src/components/applications/ApplicationsList.tsx +131 -115
- package/src/components/applications/index.ts +3 -1
- package/src/components/audit/AuditCard.tsx +1 -1
- package/src/components/charts/AreaChart.tsx +52 -18
- package/src/components/charts/MetricsSummary.tsx +12 -3
- package/src/components/charts/saturation.test.ts +34 -0
- package/src/components/charts/saturation.ts +1 -0
- package/src/components/charts/types.ts +5 -4
- package/src/components/checks/ChecksView.tsx +12 -23
- package/src/components/checks/severity.ts +11 -11
- package/src/components/gitops/GitOpsTableView.tsx +298 -274
- package/src/components/issues/IssuesView.tsx +4 -6
- package/src/components/issues/severity.ts +19 -11
- package/src/components/issues/types.ts +22 -2
- package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
- package/src/components/resources/ResourcesSidebar.tsx +3 -1
- package/src/components/resources/ResourcesView.tsx +180 -21
- package/src/components/resources/get-pod-problems.test.ts +127 -0
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
- package/src/components/resources/renderers/HPARenderer.tsx +93 -86
- package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
- package/src/components/resources/renderers/PodRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
- package/src/components/resources/resource-utils-hpa.test.ts +30 -0
- package/src/components/resources/resource-utils-hpa.ts +106 -0
- package/src/components/resources/resource-utils.ts +41 -28
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
- package/src/components/ui/DistributionBar.tsx +88 -0
- package/src/components/ui/Facet.tsx +130 -0
- package/src/components/ui/PageHeader.tsx +43 -0
- package/src/components/ui/SearchPillInput.tsx +237 -0
- package/src/components/ui/SortableTh.tsx +58 -0
- package/src/components/ui/SummaryTile.tsx +60 -0
- package/src/components/ui/drawer-components.tsx +56 -17
- package/src/components/ui/index.ts +12 -0
- package/src/components/workload/WorkloadView.tsx +18 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +7 -3
- package/src/types/core.ts +64 -2
- package/src/utils/bulk-workload-actions.test.ts +78 -0
- package/src/utils/bulk-workload-actions.ts +50 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-blast-radius.test.ts +48 -3
- package/src/utils/rbac-blast-radius.ts +14 -4
- 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
|
-
|
|
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
|
-
|
|
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 [
|
|
276
|
-
|
|
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
|
-
|
|
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 (
|
|
358
|
-
|
|
359
|
-
|
|
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,
|
|
376
|
-
}, [allRows,
|
|
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
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
479
|
-
apply: () =>
|
|
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:
|
|
547
|
+
value: syncCounts.get('Reconciling') ?? 0,
|
|
485
548
|
tone: 'info',
|
|
486
|
-
active:
|
|
487
|
-
apply: () =>
|
|
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'
|
|
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
|
-
|
|
506
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
735
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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-
|
|
773
|
-
side === 'right' ? 'border-l max-
|
|
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">
|
|
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
|
-
{
|
|
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
|
-
<
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
//
|
|
898
|
-
// (
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
|
|
902
|
-
export
|
|
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
|
-
//
|
|
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
|
-
<
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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-
|
|
1122
|
-
<tr
|
|
1123
|
-
<
|
|
1124
|
-
<
|
|
1125
|
-
<
|
|
1126
|
-
<
|
|
1127
|
-
<
|
|
1128
|
-
<
|
|
1129
|
-
<
|
|
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
|
-
<
|
|
1192
|
+
<th className={clsx(TH_CLASS, 'w-[6%] text-right')}>
|
|
1132
1193
|
<span className="sr-only">Actions</span>
|
|
1133
|
-
</
|
|
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
|
-
|
|
1203
|
-
<
|
|
1204
|
-
|
|
1205
|
-
|
|
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 === '
|
|
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(
|
|
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
|
-
//
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1666
|
-
//
|
|
1667
|
-
//
|
|
1668
|
-
//
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|