@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.
- package/package.json +2 -1
- 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 +14 -25
- package/src/components/checks/severity.ts +11 -11
- package/src/components/gitops/GitOpsTableView.tsx +307 -280
- package/src/components/gitops/detail-helpers.test.ts +1 -1
- package/src/components/issues/severity.ts +19 -11
- package/src/components/resources/ResourcesSidebar.tsx +3 -1
- package/src/components/resources/ResourcesView.tsx +13 -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/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 +6 -10
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
- package/src/components/timeline/TimelineList.tsx +31 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +27 -5
- 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 +278 -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 +54 -2
- 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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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 [
|
|
276
|
-
|
|
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
|
-
|
|
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 (
|
|
358
|
-
|
|
359
|
-
|
|
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,
|
|
376
|
-
}, [allRows,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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:
|
|
479
|
-
apply: () =>
|
|
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:
|
|
550
|
+
value: syncCounts.get('Reconciling') ?? 0,
|
|
485
551
|
tone: 'info',
|
|
486
|
-
active:
|
|
487
|
-
apply: () =>
|
|
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'
|
|
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
|
-
|
|
506
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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>
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
735
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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-
|
|
773
|
-
side === 'right' ? 'border-l max-
|
|
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">
|
|
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
|
-
{
|
|
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
|
-
<
|
|
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>
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
//
|
|
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
|
-
<
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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-
|
|
1122
|
-
<tr
|
|
1123
|
-
<
|
|
1124
|
-
<
|
|
1125
|
-
<
|
|
1126
|
-
<
|
|
1127
|
-
<
|
|
1128
|
-
<
|
|
1129
|
-
<
|
|
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
|
-
<
|
|
1195
|
+
<th className={clsx(TH_CLASS, 'w-[6%] text-right')}>
|
|
1132
1196
|
<span className="sr-only">Actions</span>
|
|
1133
|
-
</
|
|
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
|
-
|
|
1203
|
-
<
|
|
1204
|
-
|
|
1205
|
-
|
|
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 === '
|
|
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(
|
|
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
|
-
//
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1666
|
-
//
|
|
1667
|
-
//
|
|
1668
|
-
//
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|