@skyhook-io/k8s-ui 1.5.13 → 1.6.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 +4 -4
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -3
- package/src/components/dock/BottomDock.tsx +24 -17
- package/src/components/dock/DockContext.tsx +39 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
- package/src/components/gitops/GitOpsTableView.tsx +1441 -0
- package/src/components/gitops/RollbackDialog.tsx +117 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
- package/src/components/gitops/detail-helpers.test.ts +97 -0
- package/src/components/gitops/detail-helpers.ts +112 -0
- package/src/components/gitops/index.ts +55 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
- package/src/components/gitops/insights/index.ts +6 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
- package/src/components/gitops/insights/insights-helpers.ts +99 -0
- package/src/components/gitops/short-cluster-name.test.ts +36 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
- package/src/components/gitops/tree/index.ts +6 -0
- package/src/components/gitops/tree/merge.test.ts +240 -0
- package/src/components/gitops/tree/merge.ts +160 -0
- package/src/components/gitops/tree/tree-helpers.ts +42 -0
- package/src/components/resources/ResourcesSidebar.tsx +42 -15
- package/src/components/resources/ResourcesView.tsx +136 -30
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
- package/src/components/resources/renderers/PodRenderer.tsx +4 -3
- package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
- package/src/components/shared/EditableYamlView.tsx +28 -17
- package/src/components/shared/ManagedByChip.tsx +45 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +3 -3
- package/src/components/topology/TopologyGraph.tsx +3 -2
- package/src/components/ui/Tooltip.tsx +10 -1
- package/src/components/ui/drawer-components.tsx +9 -21
- package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
- package/src/components/workload/WorkloadView.tsx +66 -0
- package/src/hooks/useKeyboardShortcuts.tsx +3 -2
- package/src/index.ts +3 -0
- package/src/types/core.ts +48 -6
- package/src/types/gitops-insights.ts +193 -0
- package/src/types/gitops-tree.ts +57 -0
- package/src/types/index.ts +2 -0
- package/src/utils/badge-colors.ts +31 -1
- package/src/utils/format.ts +28 -0
- package/src/utils/gitops-owner.test.ts +95 -0
- package/src/utils/gitops-owner.ts +55 -0
- package/src/utils/gitops-route.test.ts +78 -0
- package/src/utils/gitops-route.ts +104 -0
- package/src/utils/helm-status.test.ts +50 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.ts +14 -0
- package/src/utils/resource-hierarchy.ts +47 -3
- package/src/utils/yaml.test.ts +101 -0
- package/src/utils/yaml.ts +26 -0
|
@@ -0,0 +1,1441 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import {
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
CheckCircle2,
|
|
6
|
+
CircleAlert,
|
|
7
|
+
CircleDot,
|
|
8
|
+
GitBranch,
|
|
9
|
+
HeartPulse,
|
|
10
|
+
LayoutGrid,
|
|
11
|
+
List,
|
|
12
|
+
Loader2,
|
|
13
|
+
RefreshCw,
|
|
14
|
+
Search,
|
|
15
|
+
Tag,
|
|
16
|
+
Trash2,
|
|
17
|
+
} from 'lucide-react'
|
|
18
|
+
|
|
19
|
+
import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
|
|
20
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
21
|
+
import { getGitOpsResourceStatus } from './detail-helpers'
|
|
22
|
+
import { toggleSet } from './GitOpsGraphFilterRail'
|
|
23
|
+
import { parseContextName } from '../../utils/context-name'
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// GitOpsTableView — the canonical GitOps fleet list (Argo + Flux).
|
|
27
|
+
//
|
|
28
|
+
// Originally inline in radar/web/src/components/gitops/GitOpsView.tsx as the
|
|
29
|
+
// per-cluster Radar UI. Extracted here so Radar Hub's cross-cluster fleet
|
|
30
|
+
// GitOps view can mount the same component with different data wiring
|
|
31
|
+
// rather than reinventing the table, filter sidebar, status distribution,
|
|
32
|
+
// status pills, and tile view from scratch — bringing OSS and Hub to
|
|
33
|
+
// visual parity.
|
|
34
|
+
//
|
|
35
|
+
// Composition model:
|
|
36
|
+
// - Pure presentational; all data flows in via `rows`, `counts`, `loading`
|
|
37
|
+
// - Caller owns data fetching + normalization (use the exported
|
|
38
|
+
// normalizeArgoApplication / normalizeFluxKustomization /
|
|
39
|
+
// normalizeFluxHelmRelease helpers to convert raw CRDs to GitOpsRow)
|
|
40
|
+
// - Fleet info (controller cluster, destination match) is rendered
|
|
41
|
+
// INLINE in the canonical Application + Destination cells via
|
|
42
|
+
// `row._cluster` / `row._destination` stamps. OSS rows leave these
|
|
43
|
+
// undefined; the cells fall back to the standard rendering.
|
|
44
|
+
// - Optional `crossClusterCount` + destination filter chips render only
|
|
45
|
+
// when the caller passes them (Hub-only — single-cluster Radar omits)
|
|
46
|
+
// - All UI state (mode, search, filters, sort, view mode) is owned
|
|
47
|
+
// internally; caller doesn't need to thread state through props
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
// ----- Types -----------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export type GitOpsMode = 'applications' | 'sources' | 'projects' | 'alerts'
|
|
53
|
+
export type GitOpsViewMode = 'table' | 'tiles'
|
|
54
|
+
export type SortKey = 'name' | 'health' | 'sync' | 'lastSync' | 'project'
|
|
55
|
+
|
|
56
|
+
// FleetClusterStamp + FleetDestinationStamp — optional fields the hub-side
|
|
57
|
+
// `_cluster` / `_destination` stamping projects into. Keep the types here so
|
|
58
|
+
// callers don't need to import a separate fleet types module; OSS leaves
|
|
59
|
+
// these undefined.
|
|
60
|
+
export interface FleetClusterStamp {
|
|
61
|
+
id: string
|
|
62
|
+
name: string
|
|
63
|
+
}
|
|
64
|
+
export type FleetDestinationMatch = 'in_cluster' | 'exact' | 'inferred' | 'unmatched'
|
|
65
|
+
// FleetDestinationConfidence is a coarse signal the SPA uses to style
|
|
66
|
+
// the destination chip. `high` = URL-equality match (either direct or
|
|
67
|
+
// via Argo cluster-secret), `medium` = name-equality match (more
|
|
68
|
+
// fragile, more likely to be a false positive when two clusters share
|
|
69
|
+
// a name). `unmatched` rows omit this field.
|
|
70
|
+
export type FleetDestinationConfidence = 'high' | 'medium'
|
|
71
|
+
export interface FleetDestinationStamp {
|
|
72
|
+
cluster_id?: string
|
|
73
|
+
cluster_name?: string
|
|
74
|
+
namespace?: string
|
|
75
|
+
match: FleetDestinationMatch
|
|
76
|
+
// Confidence + reason are populated for non-unmatched rows. They power
|
|
77
|
+
// the chip's visual treatment (a checkmark on high-confidence matches)
|
|
78
|
+
// and its title= tooltip respectively. Both come from the hub —
|
|
79
|
+
// adding new values shouldn't break the SPA (unknown confidence
|
|
80
|
+
// falls back to no special styling).
|
|
81
|
+
confidence?: FleetDestinationConfidence
|
|
82
|
+
reason?: string
|
|
83
|
+
raw_server?: string
|
|
84
|
+
raw_name?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface GitOpsRow {
|
|
88
|
+
id: string
|
|
89
|
+
mode: GitOpsMode
|
|
90
|
+
tool: 'argo' | 'flux'
|
|
91
|
+
kindName: string // URL-segment kind ('applications', 'kustomizations', …)
|
|
92
|
+
kind: string // human-facing kind ('Application', 'Kustomization', …)
|
|
93
|
+
group: string
|
|
94
|
+
name: string
|
|
95
|
+
namespace: string
|
|
96
|
+
project: string
|
|
97
|
+
labels: Record<string, string>
|
|
98
|
+
sync: string
|
|
99
|
+
health: string
|
|
100
|
+
suspended: boolean
|
|
101
|
+
repository: string
|
|
102
|
+
targetRevision: string
|
|
103
|
+
path: string
|
|
104
|
+
chart: string
|
|
105
|
+
destination: string
|
|
106
|
+
destinationNamespace: string
|
|
107
|
+
createdAt: string
|
|
108
|
+
lastSync: string
|
|
109
|
+
autoSync: boolean
|
|
110
|
+
terminating: boolean
|
|
111
|
+
terminationStartedAt?: string
|
|
112
|
+
raw: any
|
|
113
|
+
// Hub-only fleet stamps; OSS leaves these undefined.
|
|
114
|
+
_cluster?: FleetClusterStamp
|
|
115
|
+
_destination?: FleetDestinationStamp
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type DestinationFilter = 'all' | 'this-cluster' | 'cross-cluster' | 'unmatched'
|
|
119
|
+
|
|
120
|
+
// ----- Component props -------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
export interface GitOpsTableViewProps {
|
|
123
|
+
// Data
|
|
124
|
+
rows: GitOpsRow[]
|
|
125
|
+
loading?: boolean
|
|
126
|
+
error?: Error | null
|
|
127
|
+
// counts keyed "group/Kind" — e.g. "argoproj.io/Application" → 17. Drives
|
|
128
|
+
// the Scope-section mode tabs and the empty-state check.
|
|
129
|
+
counts: Record<string, number>
|
|
130
|
+
// Caller refresh — typically invalidates its useQuery + refetches.
|
|
131
|
+
onRefresh?: () => void
|
|
132
|
+
// Row click — caller routes to its own detail page.
|
|
133
|
+
onRowClick: (row: GitOpsRow) => void
|
|
134
|
+
|
|
135
|
+
// Called when the user clicks the destination cluster chip in the
|
|
136
|
+
// Destination cell. Fleet-only; OSS leaves undefined. Caller routes to
|
|
137
|
+
// the destination cluster's workloads view (filtered by the Argo
|
|
138
|
+
// instance label) — the chip itself stops row-click propagation.
|
|
139
|
+
onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
|
|
140
|
+
// Cross-cluster surfaces (Hub-only); OSS leaves these undefined.
|
|
141
|
+
crossClusterCount?: number
|
|
142
|
+
destinationFilter?: DestinationFilter
|
|
143
|
+
onDestinationFilterChange?: (next: DestinationFilter) => void
|
|
144
|
+
// Per-cluster RBAC denials on argocd secrets — surface as amber note.
|
|
145
|
+
forbiddenSecretsClusters?: string[]
|
|
146
|
+
|
|
147
|
+
// Customization
|
|
148
|
+
// searchHotkey: when true, '/' focuses the search input (OSS keyboard
|
|
149
|
+
// shortcut convention). Default false so hub-web doesn't fight whatever
|
|
150
|
+
// global '/' binding it might have.
|
|
151
|
+
searchHotkey?: boolean
|
|
152
|
+
// emptyStateTitle / emptyStateBody override the "No GitOps resources
|
|
153
|
+
// detected" copy. Hub passes "No GitOps resources across the fleet".
|
|
154
|
+
emptyStateTitle?: string
|
|
155
|
+
emptyStateBody?: string
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ----- Main component --------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
export function GitOpsTableView({
|
|
161
|
+
rows: allRowsInput,
|
|
162
|
+
loading,
|
|
163
|
+
error,
|
|
164
|
+
counts,
|
|
165
|
+
onRefresh,
|
|
166
|
+
onRowClick,
|
|
167
|
+
onDestinationClick,
|
|
168
|
+
crossClusterCount,
|
|
169
|
+
destinationFilter,
|
|
170
|
+
onDestinationFilterChange,
|
|
171
|
+
forbiddenSecretsClusters,
|
|
172
|
+
searchHotkey,
|
|
173
|
+
emptyStateTitle,
|
|
174
|
+
emptyStateBody,
|
|
175
|
+
}: GitOpsTableViewProps) {
|
|
176
|
+
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
177
|
+
const [mode, setMode] = useState<GitOpsMode>('applications')
|
|
178
|
+
const [viewMode, setViewMode] = useState<GitOpsViewMode>('table')
|
|
179
|
+
const [search, setSearch] = useState('')
|
|
180
|
+
const [syncFilters, setSyncFilters] = useState<Set<string>>(new Set())
|
|
181
|
+
const [healthFilters, setHealthFilters] = useState<Set<string>>(new Set())
|
|
182
|
+
const [projectFilters, setProjectFilters] = useState<Set<string>>(new Set())
|
|
183
|
+
const [namespaceFilters, setNamespaceFilters] = useState<Set<string>>(new Set())
|
|
184
|
+
const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set())
|
|
185
|
+
const [showLabelsDropdown, setShowLabelsDropdown] = useState(false)
|
|
186
|
+
const [labelSearch, setLabelSearch] = useState('')
|
|
187
|
+
const [automationFilter, setAutomationFilter] = useState<'all' | 'auto' | 'manual' | 'suspended'>('all')
|
|
188
|
+
const [lifecycleFilter, setLifecycleFilter] = useState<'all' | 'terminating' | 'active'>('all')
|
|
189
|
+
const [sortKey, setSortKey] = useState<SortKey>('health')
|
|
190
|
+
|
|
191
|
+
// Optional '/' keyboard shortcut to focus search. Avoided as a default to
|
|
192
|
+
// not collide with other surfaces' keyboard maps; OSS opts in via prop.
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!searchHotkey) return
|
|
195
|
+
const onKey = (e: KeyboardEvent) => {
|
|
196
|
+
if (e.key !== '/') return
|
|
197
|
+
const active = document.activeElement
|
|
198
|
+
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) return
|
|
199
|
+
e.preventDefault()
|
|
200
|
+
searchInputRef.current?.focus()
|
|
201
|
+
}
|
|
202
|
+
window.addEventListener('keydown', onKey)
|
|
203
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
204
|
+
}, [searchHotkey])
|
|
205
|
+
|
|
206
|
+
const allRows = allRowsInput
|
|
207
|
+
const statusSummary = summarizeGitOpsRows(allRows)
|
|
208
|
+
|
|
209
|
+
// Per-mode counts (Applications/Sources/Projects/Alerts) — fed from the
|
|
210
|
+
// caller's counts map.
|
|
211
|
+
const modeCounts: Record<GitOpsMode, number> = {
|
|
212
|
+
applications: allRows.length,
|
|
213
|
+
sources: (counts['source.toolkit.fluxcd.io/GitRepository'] ?? 0)
|
|
214
|
+
+ (counts['source.toolkit.fluxcd.io/OCIRepository'] ?? 0)
|
|
215
|
+
+ (counts['source.toolkit.fluxcd.io/HelmRepository'] ?? 0),
|
|
216
|
+
projects: counts['argoproj.io/AppProject'] ?? 0,
|
|
217
|
+
alerts: counts['notification.toolkit.fluxcd.io/Alert'] ?? 0,
|
|
218
|
+
}
|
|
219
|
+
const totalGitOps = Object.values(counts).reduce((sum, n) => sum + n, 0)
|
|
220
|
+
|
|
221
|
+
const projects = useMemo(
|
|
222
|
+
() => countValues(allRows.map((row) => row.project).filter(Boolean)),
|
|
223
|
+
[allRows],
|
|
224
|
+
)
|
|
225
|
+
const rowNamespaces = useMemo(
|
|
226
|
+
() => countValues(allRows.map((row) => row.namespace || '(cluster)').filter(Boolean)),
|
|
227
|
+
[allRows],
|
|
228
|
+
)
|
|
229
|
+
const syncCounts = useMemo(() => countMap(allRows.map((row) => row.sync)), [allRows])
|
|
230
|
+
const healthCounts = useMemo(() => countMap(allRows.map((row) => row.health)), [allRows])
|
|
231
|
+
const labels = useMemo(() => countLabels(allRows), [allRows])
|
|
232
|
+
const filteredRows = useMemo(() => {
|
|
233
|
+
const q = search.trim().toLowerCase()
|
|
234
|
+
const activeLabels = [...labelFilters]
|
|
235
|
+
.map((pair) => {
|
|
236
|
+
const [key, ...rest] = pair.split('=')
|
|
237
|
+
return { key, value: rest.join('=') }
|
|
238
|
+
})
|
|
239
|
+
.filter((label) => label.key && label.value)
|
|
240
|
+
const rows = allRows.filter((row) => {
|
|
241
|
+
if (mode !== 'applications') return false
|
|
242
|
+
if (q && ![
|
|
243
|
+
row.name,
|
|
244
|
+
row.namespace,
|
|
245
|
+
row.project,
|
|
246
|
+
row.repository,
|
|
247
|
+
row.path,
|
|
248
|
+
row.chart,
|
|
249
|
+
row.destination,
|
|
250
|
+
row.targetRevision,
|
|
251
|
+
row.kind,
|
|
252
|
+
].some((value) => value.toLowerCase().includes(q))) return false
|
|
253
|
+
if (syncFilters.size > 0 && !syncFilters.has(row.sync)) return false
|
|
254
|
+
if (healthFilters.size > 0 && !healthFilters.has(row.health)) return false
|
|
255
|
+
if (projectFilters.size > 0 && !projectFilters.has(row.project || '(none)')) return false
|
|
256
|
+
if (namespaceFilters.size > 0 && !namespaceFilters.has(row.namespace || '(cluster)')) return false
|
|
257
|
+
if (activeLabels.length > 0 && !activeLabels.every(({ key, value }) => row.labels[key] === value)) return false
|
|
258
|
+
if (automationFilter === 'auto' && !row.autoSync) return false
|
|
259
|
+
if (automationFilter === 'manual' && row.autoSync) return false
|
|
260
|
+
if (automationFilter === 'suspended' && !row.suspended) return false
|
|
261
|
+
if (lifecycleFilter === 'terminating' && !row.terminating) return false
|
|
262
|
+
if (lifecycleFilter === 'active' && row.terminating) return false
|
|
263
|
+
if (destinationFilter && destinationFilter !== 'all') {
|
|
264
|
+
const match = row._destination?.match
|
|
265
|
+
if (destinationFilter === 'this-cluster' && match !== 'in_cluster') return false
|
|
266
|
+
// Cross-cluster covers BOTH match modes that point at a different
|
|
267
|
+
// hub-connected cluster: 'exact' (URL match, high confidence) and
|
|
268
|
+
// 'inferred' (name match, medium). Forgetting 'exact' here would
|
|
269
|
+
// hide the very rows the URL-correlation work was meant to surface.
|
|
270
|
+
if (destinationFilter === 'cross-cluster' && match !== 'exact' && match !== 'inferred') return false
|
|
271
|
+
if (destinationFilter === 'unmatched' && match !== 'unmatched') return false
|
|
272
|
+
}
|
|
273
|
+
return true
|
|
274
|
+
})
|
|
275
|
+
return [...rows].sort((a, b) => compareRows(a, b, sortKey))
|
|
276
|
+
}, [allRows, automationFilter, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sortKey, syncFilters, destinationFilter])
|
|
277
|
+
|
|
278
|
+
const terminatingCount = useMemo(() => allRows.filter((row) => row.terminating).length, [allRows])
|
|
279
|
+
|
|
280
|
+
// Empty-state — when there's truly nothing to show across all kinds.
|
|
281
|
+
if (totalGitOps === 0 && !loading) {
|
|
282
|
+
return (
|
|
283
|
+
<div className="flex h-full min-h-0 flex-1 items-center justify-center bg-theme-base p-4">
|
|
284
|
+
<div className="rounded-lg border border-theme-border bg-theme-surface p-8 text-center">
|
|
285
|
+
<GitBranch className="mx-auto h-8 w-8 text-theme-text-tertiary" />
|
|
286
|
+
<h2 className="mt-3 text-base font-semibold text-theme-text-primary">
|
|
287
|
+
{emptyStateTitle ?? 'No GitOps resources detected'}
|
|
288
|
+
</h2>
|
|
289
|
+
<p className="mt-1 text-sm text-theme-text-secondary">
|
|
290
|
+
{emptyStateBody ?? 'No ArgoCD Applications or FluxCD resources were found.'}
|
|
291
|
+
</p>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const showCrossClusterTile = typeof crossClusterCount === 'number' && mode === 'applications'
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<div className="flex h-full min-w-0 flex-1 overflow-hidden bg-theme-base max-lg:flex-col">
|
|
301
|
+
<GitOpsFilterSidebar
|
|
302
|
+
mode={mode}
|
|
303
|
+
onModeChange={setMode}
|
|
304
|
+
modeCounts={modeCounts}
|
|
305
|
+
syncCounts={syncCounts}
|
|
306
|
+
syncFilters={syncFilters}
|
|
307
|
+
onToggleSync={(value) => toggleSet(syncFilters, setSyncFilters, value)}
|
|
308
|
+
healthCounts={healthCounts}
|
|
309
|
+
healthFilters={healthFilters}
|
|
310
|
+
onToggleHealth={(value) => toggleSet(healthFilters, setHealthFilters, value)}
|
|
311
|
+
automationFilter={automationFilter}
|
|
312
|
+
onAutomationFilterChange={setAutomationFilter}
|
|
313
|
+
lifecycleFilter={lifecycleFilter}
|
|
314
|
+
onLifecycleFilterChange={setLifecycleFilter}
|
|
315
|
+
terminatingCount={terminatingCount}
|
|
316
|
+
projects={projects}
|
|
317
|
+
projectFilters={projectFilters}
|
|
318
|
+
onToggleProject={(value) => toggleSet(projectFilters, setProjectFilters, value)}
|
|
319
|
+
namespaces={rowNamespaces}
|
|
320
|
+
namespaceFilters={namespaceFilters}
|
|
321
|
+
onToggleNamespace={(value) => toggleSet(namespaceFilters, setNamespaceFilters, value)}
|
|
322
|
+
onClear={() => {
|
|
323
|
+
setSearch('')
|
|
324
|
+
setSyncFilters(new Set())
|
|
325
|
+
setHealthFilters(new Set())
|
|
326
|
+
setProjectFilters(new Set())
|
|
327
|
+
setNamespaceFilters(new Set())
|
|
328
|
+
setLabelFilters(new Set())
|
|
329
|
+
setAutomationFilter('all')
|
|
330
|
+
setLifecycleFilter('all')
|
|
331
|
+
}}
|
|
332
|
+
/>
|
|
333
|
+
|
|
334
|
+
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
335
|
+
<div className="shrink-0 border-b border-theme-border bg-theme-base px-4 py-3">
|
|
336
|
+
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
|
337
|
+
<div className="min-w-0">
|
|
338
|
+
<h1 className="text-lg font-semibold text-theme-text-primary">GitOps</h1>
|
|
339
|
+
<p className="truncate text-sm text-theme-text-secondary">
|
|
340
|
+
Applications and reconciliations with source, destination, sync, and health state.
|
|
341
|
+
</p>
|
|
342
|
+
</div>
|
|
343
|
+
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
|
344
|
+
<SummaryTile label="Applications" value={allRows.length} />
|
|
345
|
+
<SummaryTile label="Out of sync" value={statusSummary.outOfSync} tone="warning" />
|
|
346
|
+
<SummaryTile label="Degraded" value={statusSummary.degraded} tone="error" />
|
|
347
|
+
<SummaryTile label="Suspended" value={statusSummary.suspended} tone="warning" />
|
|
348
|
+
<SummaryTile label="Reconciling" value={statusSummary.reconciling} tone="info" />
|
|
349
|
+
{showCrossClusterTile && (
|
|
350
|
+
<SummaryTile label="Cross-cluster" value={crossClusterCount!} tone="info" />
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
{forbiddenSecretsClusters && forbiddenSecretsClusters.length > 0 && mode === 'applications' && (
|
|
357
|
+
// Hub-only graceful-degradation note: when the user lacks `get
|
|
358
|
+
// secrets` in the argocd namespace on a controller, the hub
|
|
359
|
+
// can't resolve server-URL destinations to Argo-registered
|
|
360
|
+
// names. Direct name-match still works; surface so operators
|
|
361
|
+
// understand why some destinations show as unmatched.
|
|
362
|
+
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-xs text-amber-700 dark:text-amber-300">
|
|
363
|
+
<span className="font-medium">Cross-cluster mapping limited for {forbiddenSecretsClusters.join(', ')}.</span>{' '}
|
|
364
|
+
Owner needs <code>get secrets</code> in the <code>argocd</code> namespace there; destination
|
|
365
|
+
correlation falls back to name-only resolution.
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
<div className="shrink-0 border-b border-theme-border bg-theme-surface/70 px-4 py-3">
|
|
370
|
+
<StatusDistribution rows={filteredRows} />
|
|
371
|
+
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
372
|
+
<div className="relative w-full max-w-md">
|
|
373
|
+
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-theme-text-tertiary" />
|
|
374
|
+
<input
|
|
375
|
+
ref={searchInputRef}
|
|
376
|
+
value={search}
|
|
377
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
378
|
+
placeholder="Search applications, repos, paths..."
|
|
379
|
+
className="h-8 w-full rounded-md border border-theme-border bg-theme-base pl-8 pr-3 text-sm text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
|
|
380
|
+
/>
|
|
381
|
+
</div>
|
|
382
|
+
{filteredRows.length !== allRows.length && (
|
|
383
|
+
<span className="text-[11px] text-theme-text-tertiary">
|
|
384
|
+
Showing {filteredRows.length} of {allRows.length}
|
|
385
|
+
</span>
|
|
386
|
+
)}
|
|
387
|
+
<select
|
|
388
|
+
value={sortKey}
|
|
389
|
+
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
|
390
|
+
className="h-8 rounded-md border border-theme-border bg-theme-base px-2 text-xs text-theme-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
|
|
391
|
+
>
|
|
392
|
+
<option value="health">Sort: health</option>
|
|
393
|
+
<option value="sync">Sort: sync</option>
|
|
394
|
+
<option value="lastSync">Sort: last sync</option>
|
|
395
|
+
<option value="project">Sort: project</option>
|
|
396
|
+
<option value="name">Sort: name</option>
|
|
397
|
+
</select>
|
|
398
|
+
{labels.length > 0 && (
|
|
399
|
+
<LabelsDropdown
|
|
400
|
+
labels={labels}
|
|
401
|
+
activeLabels={labelFilters}
|
|
402
|
+
onToggle={(value) => toggleSet(labelFilters, setLabelFilters, value)}
|
|
403
|
+
onClear={() => setLabelFilters(new Set())}
|
|
404
|
+
open={showLabelsDropdown}
|
|
405
|
+
onOpenChange={setShowLabelsDropdown}
|
|
406
|
+
search={labelSearch}
|
|
407
|
+
onSearchChange={setLabelSearch}
|
|
408
|
+
/>
|
|
409
|
+
)}
|
|
410
|
+
{onDestinationFilterChange && mode === 'applications' && (
|
|
411
|
+
<div className="ml-auto flex items-center gap-1 text-[11px]">
|
|
412
|
+
<span className="text-theme-text-tertiary">Destination:</span>
|
|
413
|
+
{(['all', 'this-cluster', 'cross-cluster', 'unmatched'] as DestinationFilter[]).map((v) => (
|
|
414
|
+
<button
|
|
415
|
+
key={v}
|
|
416
|
+
type="button"
|
|
417
|
+
onClick={() => onDestinationFilterChange(v)}
|
|
418
|
+
className={`rounded-md border px-2 py-0.5 font-medium transition-colors ${
|
|
419
|
+
destinationFilter === v
|
|
420
|
+
? 'border-sky-500/40 bg-sky-500/10 text-sky-600 dark:text-sky-300'
|
|
421
|
+
: 'border-theme-border bg-theme-base text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
422
|
+
}`}
|
|
423
|
+
>
|
|
424
|
+
{v === 'all' ? 'All' : v === 'this-cluster' ? 'This cluster' : v === 'cross-cluster' ? 'Cross-cluster' : 'Unmatched'}
|
|
425
|
+
</button>
|
|
426
|
+
))}
|
|
427
|
+
</div>
|
|
428
|
+
)}
|
|
429
|
+
<div className="flex shrink-0 items-center gap-0 overflow-hidden rounded-md border border-theme-border">
|
|
430
|
+
<GitOpsIconToggle active={viewMode === 'table'} label="Table view" icon={List} onClick={() => setViewMode('table')} />
|
|
431
|
+
<GitOpsIconToggle active={viewMode === 'tiles'} label="Tiles view" icon={LayoutGrid} onClick={() => setViewMode('tiles')} />
|
|
432
|
+
</div>
|
|
433
|
+
{onRefresh && (
|
|
434
|
+
<Tooltip content="Refresh GitOps resources">
|
|
435
|
+
<button
|
|
436
|
+
type="button"
|
|
437
|
+
onClick={onRefresh}
|
|
438
|
+
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"
|
|
439
|
+
>
|
|
440
|
+
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
|
|
441
|
+
</button>
|
|
442
|
+
</Tooltip>
|
|
443
|
+
)}
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
|
|
447
|
+
<div className="min-h-0 min-w-0 flex-1 overflow-auto bg-theme-base">
|
|
448
|
+
{mode !== 'applications' ? (
|
|
449
|
+
<div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
|
|
450
|
+
{modeLabel(mode)} view is queued behind the application list.
|
|
451
|
+
</div>
|
|
452
|
+
) : loading && filteredRows.length === 0 ? (
|
|
453
|
+
<div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
|
|
454
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading GitOps applications...
|
|
455
|
+
</div>
|
|
456
|
+
) : error ? (
|
|
457
|
+
<div className="p-4 text-sm text-red-500">Failed to load GitOps applications: {error.message}</div>
|
|
458
|
+
) : filteredRows.length === 0 ? (
|
|
459
|
+
<div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
|
|
460
|
+
No applications match the current filters.
|
|
461
|
+
</div>
|
|
462
|
+
) : viewMode === 'tiles' ? (
|
|
463
|
+
<GitOpsTiles rows={filteredRows} onOpen={onRowClick} />
|
|
464
|
+
) : (
|
|
465
|
+
<GitOpsTable rows={filteredRows} onOpen={onRowClick} onDestinationClick={onDestinationClick} />
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// =============================================================================
|
|
474
|
+
// Subcomponents — kept private to the file; they're tightly coupled to
|
|
475
|
+
// GitOpsTableView's visual language and not generally useful elsewhere.
|
|
476
|
+
// =============================================================================
|
|
477
|
+
|
|
478
|
+
function GitOpsFilterSidebar({
|
|
479
|
+
mode,
|
|
480
|
+
onModeChange,
|
|
481
|
+
modeCounts,
|
|
482
|
+
syncCounts,
|
|
483
|
+
syncFilters,
|
|
484
|
+
onToggleSync,
|
|
485
|
+
healthCounts,
|
|
486
|
+
healthFilters,
|
|
487
|
+
onToggleHealth,
|
|
488
|
+
automationFilter,
|
|
489
|
+
onAutomationFilterChange,
|
|
490
|
+
lifecycleFilter,
|
|
491
|
+
onLifecycleFilterChange,
|
|
492
|
+
terminatingCount,
|
|
493
|
+
projects,
|
|
494
|
+
projectFilters,
|
|
495
|
+
onToggleProject,
|
|
496
|
+
namespaces,
|
|
497
|
+
namespaceFilters,
|
|
498
|
+
onToggleNamespace,
|
|
499
|
+
onClear,
|
|
500
|
+
}: {
|
|
501
|
+
mode: GitOpsMode
|
|
502
|
+
onModeChange: (mode: GitOpsMode) => void
|
|
503
|
+
modeCounts: Record<GitOpsMode, number>
|
|
504
|
+
syncCounts: Map<string, number>
|
|
505
|
+
syncFilters: Set<string>
|
|
506
|
+
onToggleSync: (value: string) => void
|
|
507
|
+
healthCounts: Map<string, number>
|
|
508
|
+
healthFilters: Set<string>
|
|
509
|
+
onToggleHealth: (value: string) => void
|
|
510
|
+
automationFilter: 'all' | 'auto' | 'manual' | 'suspended'
|
|
511
|
+
onAutomationFilterChange: (value: 'all' | 'auto' | 'manual' | 'suspended') => void
|
|
512
|
+
lifecycleFilter: 'all' | 'terminating' | 'active'
|
|
513
|
+
onLifecycleFilterChange: (value: 'all' | 'terminating' | 'active') => void
|
|
514
|
+
terminatingCount: number
|
|
515
|
+
projects: Array<{ name: string; count: number }>
|
|
516
|
+
projectFilters: Set<string>
|
|
517
|
+
onToggleProject: (value: string) => void
|
|
518
|
+
namespaces: Array<{ name: string; count: number }>
|
|
519
|
+
namespaceFilters: Set<string>
|
|
520
|
+
onToggleNamespace: (value: string) => void
|
|
521
|
+
onClear: () => void
|
|
522
|
+
}) {
|
|
523
|
+
return (
|
|
524
|
+
<aside className="flex w-72 shrink-0 flex-col overflow-hidden border-r border-theme-border bg-theme-surface/90 max-lg:max-h-72 max-lg:w-full max-lg:border-b max-lg:border-r-0">
|
|
525
|
+
<div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
|
|
526
|
+
<span className="text-sm font-medium text-theme-text-secondary">GitOps Filters</span>
|
|
527
|
+
<button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
|
|
528
|
+
Clear
|
|
529
|
+
</button>
|
|
530
|
+
</div>
|
|
531
|
+
<div className="flex-1 overflow-y-auto">
|
|
532
|
+
<GitOpsFilterSection icon={GitBranch} title="Scope">
|
|
533
|
+
<div className="grid grid-cols-2 gap-1">
|
|
534
|
+
{(['applications'] as GitOpsMode[]).map((item) => (
|
|
535
|
+
<button
|
|
536
|
+
key={item}
|
|
537
|
+
type="button"
|
|
538
|
+
onClick={() => onModeChange(item)}
|
|
539
|
+
className={`rounded-md px-2 py-1.5 text-left text-[11px] transition-colors ${
|
|
540
|
+
mode === item
|
|
541
|
+
? 'bg-skyhook-500 text-white'
|
|
542
|
+
: 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
543
|
+
}`}
|
|
544
|
+
>
|
|
545
|
+
<div className="font-medium">{modeLabel(item)}</div>
|
|
546
|
+
<div className={mode === item ? 'text-white/70' : 'text-theme-text-tertiary'}>{modeCounts[item]}</div>
|
|
547
|
+
</button>
|
|
548
|
+
))}
|
|
549
|
+
</div>
|
|
550
|
+
</GitOpsFilterSection>
|
|
551
|
+
|
|
552
|
+
<GitOpsFilterSection icon={CheckCircle2} title="Sync">
|
|
553
|
+
<GitOpsFacetButton label="Synced" count={syncCounts.get('Synced') ?? 0} active={syncFilters.has('Synced')} tone="success" onClick={() => onToggleSync('Synced')} />
|
|
554
|
+
<GitOpsFacetButton label="OutOfSync" count={syncCounts.get('OutOfSync') ?? 0} active={syncFilters.has('OutOfSync')} tone="warning" onClick={() => onToggleSync('OutOfSync')} />
|
|
555
|
+
<GitOpsFacetButton label="Reconciling" count={syncCounts.get('Reconciling') ?? 0} active={syncFilters.has('Reconciling')} tone="info" onClick={() => onToggleSync('Reconciling')} />
|
|
556
|
+
<GitOpsFacetButton label="Unknown" count={syncCounts.get('Unknown') ?? 0} active={syncFilters.has('Unknown')} onClick={() => onToggleSync('Unknown')} />
|
|
557
|
+
</GitOpsFilterSection>
|
|
558
|
+
|
|
559
|
+
<GitOpsFilterSection icon={HeartPulse} title="Health">
|
|
560
|
+
<GitOpsFacetButton label="Healthy" count={healthCounts.get('Healthy') ?? 0} active={healthFilters.has('Healthy')} tone="success" onClick={() => onToggleHealth('Healthy')} />
|
|
561
|
+
<GitOpsFacetButton label="Progressing" count={healthCounts.get('Progressing') ?? 0} active={healthFilters.has('Progressing')} tone="info" onClick={() => onToggleHealth('Progressing')} />
|
|
562
|
+
<GitOpsFacetButton label="Degraded" count={healthCounts.get('Degraded') ?? 0} active={healthFilters.has('Degraded')} tone="error" onClick={() => onToggleHealth('Degraded')} />
|
|
563
|
+
<GitOpsFacetButton label="Suspended" count={healthCounts.get('Suspended') ?? 0} active={healthFilters.has('Suspended')} tone="warning" onClick={() => onToggleHealth('Suspended')} />
|
|
564
|
+
<GitOpsFacetButton label="Unknown" count={healthCounts.get('Unknown') ?? 0} active={healthFilters.has('Unknown')} onClick={() => onToggleHealth('Unknown')} />
|
|
565
|
+
</GitOpsFilterSection>
|
|
566
|
+
|
|
567
|
+
<GitOpsFilterSection icon={CircleDot} title="Automation">
|
|
568
|
+
<div className="grid grid-cols-2 gap-1">
|
|
569
|
+
{([
|
|
570
|
+
['all', 'All'],
|
|
571
|
+
['auto', 'Auto-sync'],
|
|
572
|
+
['manual', 'Manual'],
|
|
573
|
+
['suspended', 'Suspended'],
|
|
574
|
+
] as const).map(([value, label]) => (
|
|
575
|
+
<button
|
|
576
|
+
key={value}
|
|
577
|
+
type="button"
|
|
578
|
+
onClick={() => onAutomationFilterChange(value)}
|
|
579
|
+
className={`rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors ${
|
|
580
|
+
automationFilter === value
|
|
581
|
+
? 'bg-skyhook-500 text-white'
|
|
582
|
+
: 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
583
|
+
}`}
|
|
584
|
+
>
|
|
585
|
+
{label}
|
|
586
|
+
</button>
|
|
587
|
+
))}
|
|
588
|
+
</div>
|
|
589
|
+
</GitOpsFilterSection>
|
|
590
|
+
|
|
591
|
+
{terminatingCount > 0 && (
|
|
592
|
+
<GitOpsFilterSection icon={Trash2} title="Lifecycle">
|
|
593
|
+
<div className="grid grid-cols-3 gap-1">
|
|
594
|
+
{([
|
|
595
|
+
['all', 'All'],
|
|
596
|
+
['active', 'Active'],
|
|
597
|
+
['terminating', `Terminating (${terminatingCount})`],
|
|
598
|
+
] as const).map(([value, label]) => (
|
|
599
|
+
<button
|
|
600
|
+
key={value}
|
|
601
|
+
type="button"
|
|
602
|
+
onClick={() => onLifecycleFilterChange(value)}
|
|
603
|
+
className={`rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors ${
|
|
604
|
+
lifecycleFilter === value
|
|
605
|
+
? value === 'terminating'
|
|
606
|
+
? 'bg-orange-500 text-white'
|
|
607
|
+
: 'bg-skyhook-500 text-white'
|
|
608
|
+
: 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
609
|
+
}`}
|
|
610
|
+
>
|
|
611
|
+
{label}
|
|
612
|
+
</button>
|
|
613
|
+
))}
|
|
614
|
+
</div>
|
|
615
|
+
</GitOpsFilterSection>
|
|
616
|
+
)}
|
|
617
|
+
|
|
618
|
+
<GitOpsFilterSection icon={CircleAlert} title="Projects">
|
|
619
|
+
{projects.slice(0, 10).map((project) => (
|
|
620
|
+
<GitOpsFacetButton
|
|
621
|
+
key={project.name}
|
|
622
|
+
label={project.name || '(none)'}
|
|
623
|
+
count={project.count}
|
|
624
|
+
active={projectFilters.has(project.name || '(none)')}
|
|
625
|
+
onClick={() => onToggleProject(project.name || '(none)')}
|
|
626
|
+
/>
|
|
627
|
+
))}
|
|
628
|
+
</GitOpsFilterSection>
|
|
629
|
+
|
|
630
|
+
<GitOpsFilterSection icon={List} title="Namespaces">
|
|
631
|
+
{namespaces.slice(0, 12).map((namespace) => (
|
|
632
|
+
<GitOpsFacetButton
|
|
633
|
+
key={namespace.name}
|
|
634
|
+
label={namespace.name}
|
|
635
|
+
count={namespace.count}
|
|
636
|
+
active={namespaceFilters.has(namespace.name)}
|
|
637
|
+
onClick={() => onToggleNamespace(namespace.name)}
|
|
638
|
+
/>
|
|
639
|
+
))}
|
|
640
|
+
</GitOpsFilterSection>
|
|
641
|
+
</div>
|
|
642
|
+
</aside>
|
|
643
|
+
)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Exported so consumers can build their own GitOps-flavored filter rails
|
|
647
|
+
// (e.g. OSS's GitOpsGraphFilterRail in the detail view's Topology tab,
|
|
648
|
+
// or hub-web's destination filter sub-bar) without re-implementing the
|
|
649
|
+
// section/facet/toggle primitives. The styles stay in lockstep with the
|
|
650
|
+
// main table's filter sidebar — change once, both surfaces follow.
|
|
651
|
+
export function GitOpsFilterSection({ icon: Icon, title, children }: { icon: ComponentType<{ className?: string }>; title: string; children: ReactNode }) {
|
|
652
|
+
return (
|
|
653
|
+
<section className="border-b border-theme-border px-3 py-2">
|
|
654
|
+
<div className="mb-1.5 flex items-center gap-2">
|
|
655
|
+
<Icon className="h-3.5 w-3.5 text-theme-text-tertiary" />
|
|
656
|
+
<span className="text-[10px] font-medium uppercase tracking-wider text-theme-text-tertiary">{title}</span>
|
|
657
|
+
</div>
|
|
658
|
+
<div className="space-y-0.5">{children}</div>
|
|
659
|
+
</section>
|
|
660
|
+
)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Exported alongside GitOpsFilterSection — same reuse motivation. OSS's
|
|
664
|
+
// GitOpsGraphFilterRail (detail view Topology tab) imports it.
|
|
665
|
+
export function GitOpsFacetButton({
|
|
666
|
+
label,
|
|
667
|
+
count,
|
|
668
|
+
active,
|
|
669
|
+
tone = 'neutral',
|
|
670
|
+
onClick,
|
|
671
|
+
}: {
|
|
672
|
+
label: string
|
|
673
|
+
count: number
|
|
674
|
+
active: boolean
|
|
675
|
+
tone?: 'neutral' | 'success' | 'warning' | 'error' | 'info'
|
|
676
|
+
onClick: () => void
|
|
677
|
+
}) {
|
|
678
|
+
const dot = {
|
|
679
|
+
neutral: 'bg-theme-text-tertiary',
|
|
680
|
+
success: 'bg-emerald-500',
|
|
681
|
+
warning: 'bg-amber-500',
|
|
682
|
+
error: 'bg-red-500',
|
|
683
|
+
info: 'bg-sky-500',
|
|
684
|
+
}[tone]
|
|
685
|
+
return (
|
|
686
|
+
<button
|
|
687
|
+
type="button"
|
|
688
|
+
onClick={onClick}
|
|
689
|
+
className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[11px] transition-colors ${
|
|
690
|
+
active ? 'bg-blue-500/15 text-blue-500' : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
691
|
+
}`}
|
|
692
|
+
>
|
|
693
|
+
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} />
|
|
694
|
+
<span className="min-w-0 flex-1 truncate font-medium">{label}</span>
|
|
695
|
+
{count > 0 && <span className="tabular-nums text-theme-text-tertiary">{count}</span>}
|
|
696
|
+
</button>
|
|
697
|
+
)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Exported alongside GitOpsFilterSection / GitOpsFacetButton for the same
|
|
701
|
+
// reuse story.
|
|
702
|
+
export function GitOpsIconToggle({ active, label, icon: Icon, onClick }: { active: boolean; label: string; icon: ComponentType<{ className?: string }>; onClick: () => void }) {
|
|
703
|
+
return (
|
|
704
|
+
<Tooltip content={label}>
|
|
705
|
+
<button
|
|
706
|
+
type="button"
|
|
707
|
+
onClick={onClick}
|
|
708
|
+
className={`inline-flex h-8 w-8 items-center justify-center transition-colors ${
|
|
709
|
+
active ? 'bg-skyhook-500 text-white' : 'bg-theme-base text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
710
|
+
}`}
|
|
711
|
+
>
|
|
712
|
+
<Icon className="h-3.5 w-3.5" />
|
|
713
|
+
</button>
|
|
714
|
+
</Tooltip>
|
|
715
|
+
)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function LabelsDropdown({
|
|
719
|
+
labels,
|
|
720
|
+
activeLabels,
|
|
721
|
+
onToggle,
|
|
722
|
+
onClear,
|
|
723
|
+
open,
|
|
724
|
+
onOpenChange,
|
|
725
|
+
search,
|
|
726
|
+
onSearchChange,
|
|
727
|
+
}: {
|
|
728
|
+
labels: Array<{ name: string; count: number }>
|
|
729
|
+
activeLabels: Set<string>
|
|
730
|
+
onToggle: (value: string) => void
|
|
731
|
+
onClear: () => void
|
|
732
|
+
open: boolean
|
|
733
|
+
onOpenChange: (open: boolean) => void
|
|
734
|
+
search: string
|
|
735
|
+
onSearchChange: (value: string) => void
|
|
736
|
+
}) {
|
|
737
|
+
const filtered = search.trim()
|
|
738
|
+
? labels.filter((label) => label.name.toLowerCase().includes(search.trim().toLowerCase()))
|
|
739
|
+
: labels
|
|
740
|
+
return (
|
|
741
|
+
<div className="relative">
|
|
742
|
+
<button
|
|
743
|
+
type="button"
|
|
744
|
+
onClick={() => onOpenChange(!open)}
|
|
745
|
+
className={`inline-flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs transition-colors ${
|
|
746
|
+
activeLabels.size > 0
|
|
747
|
+
? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-600 dark:text-emerald-300'
|
|
748
|
+
: 'border-theme-border bg-theme-base text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
749
|
+
}`}
|
|
750
|
+
>
|
|
751
|
+
<Tag className="h-3.5 w-3.5" />
|
|
752
|
+
Labels
|
|
753
|
+
{activeLabels.size > 0 && (
|
|
754
|
+
<span className="rounded bg-emerald-500/20 px-1 text-[10px] tabular-nums">{activeLabels.size}</span>
|
|
755
|
+
)}
|
|
756
|
+
</button>
|
|
757
|
+
{open && (
|
|
758
|
+
<div className="absolute right-0 top-full z-50 mt-1 w-80 overflow-hidden rounded-lg border border-theme-border bg-theme-surface shadow-xl">
|
|
759
|
+
<div className="border-b border-theme-border p-2">
|
|
760
|
+
<div className="mb-2 text-xs text-theme-text-secondary">
|
|
761
|
+
Selected labels are combined with <span className="font-semibold text-theme-text-primary">AND</span>.
|
|
762
|
+
</div>
|
|
763
|
+
<div className="flex items-center gap-2">
|
|
764
|
+
<div className="relative flex-1">
|
|
765
|
+
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-theme-text-tertiary" />
|
|
766
|
+
<input
|
|
767
|
+
type="text"
|
|
768
|
+
value={search}
|
|
769
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
770
|
+
placeholder="Search labels..."
|
|
771
|
+
autoFocus
|
|
772
|
+
className="h-7 w-full rounded border border-theme-border bg-theme-elevated pl-7 pr-2 text-xs text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
|
|
773
|
+
/>
|
|
774
|
+
</div>
|
|
775
|
+
{activeLabels.size > 0 && (
|
|
776
|
+
<button
|
|
777
|
+
type="button"
|
|
778
|
+
onClick={() => {
|
|
779
|
+
onClear()
|
|
780
|
+
onOpenChange(false)
|
|
781
|
+
}}
|
|
782
|
+
className="shrink-0 rounded px-1 py-0.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
|
|
783
|
+
>
|
|
784
|
+
Clear
|
|
785
|
+
</button>
|
|
786
|
+
)}
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
<div className="max-h-72 overflow-y-auto py-1">
|
|
790
|
+
{filtered.map((label) => {
|
|
791
|
+
const active = activeLabels.has(label.name)
|
|
792
|
+
return (
|
|
793
|
+
<button
|
|
794
|
+
key={label.name}
|
|
795
|
+
type="button"
|
|
796
|
+
onClick={() => onToggle(label.name)}
|
|
797
|
+
className={`flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-xs transition-colors ${
|
|
798
|
+
active
|
|
799
|
+
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300'
|
|
800
|
+
: 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
|
|
801
|
+
}`}
|
|
802
|
+
>
|
|
803
|
+
<Tooltip content={label.name} delay={400} wrapperClassName="min-w-0 flex-1">
|
|
804
|
+
<span className="block w-full truncate">{label.name}</span>
|
|
805
|
+
</Tooltip>
|
|
806
|
+
<span className="shrink-0 tabular-nums text-theme-text-tertiary">({label.count})</span>
|
|
807
|
+
</button>
|
|
808
|
+
)
|
|
809
|
+
})}
|
|
810
|
+
{filtered.length === 0 && (
|
|
811
|
+
<div className="px-3 py-2 text-xs text-theme-text-tertiary">No labels match.</div>
|
|
812
|
+
)}
|
|
813
|
+
</div>
|
|
814
|
+
</div>
|
|
815
|
+
)}
|
|
816
|
+
</div>
|
|
817
|
+
)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function StatusDistribution({ rows }: { rows: GitOpsRow[] }) {
|
|
821
|
+
// Single dimension: health. Earlier this mixed health (healthy /
|
|
822
|
+
// progressing / degraded) with sync (outOfSync) which double-counted
|
|
823
|
+
// rows that were both (e.g. Synced + Degraded → 2 segment increments
|
|
824
|
+
// → total > rows.length → flex distorts proportions). Use health for
|
|
825
|
+
// the bar — sync state is visible elsewhere (the OutOfSync summary
|
|
826
|
+
// tile, the Sync column, the filter rail).
|
|
827
|
+
const summary = summarizeGitOpsRows(rows)
|
|
828
|
+
const total = rows.length || 1
|
|
829
|
+
const segments = [
|
|
830
|
+
{ key: 'healthy', value: summary.healthy, className: 'bg-emerald-500' },
|
|
831
|
+
{ key: 'progressing', value: summary.progressing, className: 'bg-sky-500' },
|
|
832
|
+
{ key: 'degraded', value: summary.degraded, className: 'bg-red-500' },
|
|
833
|
+
{ key: 'unknown', value: Math.max(0, rows.length - summary.healthy - summary.progressing - summary.degraded), className: 'bg-theme-text-tertiary/40' },
|
|
834
|
+
].filter((segment) => segment.value > 0)
|
|
835
|
+
return (
|
|
836
|
+
<div className="h-2 overflow-hidden rounded-full bg-theme-elevated">
|
|
837
|
+
<div className="flex h-full w-full">
|
|
838
|
+
{segments.map((segment) => (
|
|
839
|
+
<div
|
|
840
|
+
key={segment.key}
|
|
841
|
+
className={segment.className}
|
|
842
|
+
style={{ width: `${Math.max(1, (segment.value / total) * 100)}%` }}
|
|
843
|
+
/>
|
|
844
|
+
))}
|
|
845
|
+
</div>
|
|
846
|
+
</div>
|
|
847
|
+
)
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function GitOpsTable({
|
|
851
|
+
rows,
|
|
852
|
+
onOpen,
|
|
853
|
+
onDestinationClick,
|
|
854
|
+
}: {
|
|
855
|
+
rows: GitOpsRow[]
|
|
856
|
+
onOpen: (row: GitOpsRow) => void
|
|
857
|
+
onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
|
|
858
|
+
}) {
|
|
859
|
+
return (
|
|
860
|
+
<table className="w-full min-w-[1040px] table-fixed border-separate border-spacing-0 text-sm">
|
|
861
|
+
<thead className="sticky top-0 z-10 bg-theme-surface">
|
|
862
|
+
<tr className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
|
|
863
|
+
<TableHead className="w-[22%]">Application</TableHead>
|
|
864
|
+
<TableHead className="w-[9%]">Project</TableHead>
|
|
865
|
+
<TableHead className="w-[9%]">Sync</TableHead>
|
|
866
|
+
<TableHead className="w-[9%]">Health</TableHead>
|
|
867
|
+
<TableHead className="w-[20%]">Source</TableHead>
|
|
868
|
+
<TableHead className="w-[14%]">Destination</TableHead>
|
|
869
|
+
<TableHead className="w-[10%]">Last Sync</TableHead>
|
|
870
|
+
</tr>
|
|
871
|
+
</thead>
|
|
872
|
+
<tbody>
|
|
873
|
+
{rows.map((row) => (
|
|
874
|
+
<tr
|
|
875
|
+
key={row.id}
|
|
876
|
+
onClick={() => onOpen(row)}
|
|
877
|
+
className={clsx(
|
|
878
|
+
'cursor-pointer border-b border-theme-border bg-theme-base hover:bg-theme-hover',
|
|
879
|
+
row.terminating && 'opacity-70',
|
|
880
|
+
)}
|
|
881
|
+
>
|
|
882
|
+
<TableCell>
|
|
883
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
884
|
+
<span className={`h-8 w-1 shrink-0 rounded-full ${statusStripe(row)}`} />
|
|
885
|
+
{row.terminating && (
|
|
886
|
+
<Tooltip content="Pending deletion — finalizers still running">
|
|
887
|
+
<span className="inline-flex shrink-0 items-center gap-1 rounded border border-orange-500/40 bg-orange-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-orange-400">
|
|
888
|
+
<Trash2 className="h-3 w-3" />
|
|
889
|
+
Terminating
|
|
890
|
+
</span>
|
|
891
|
+
</Tooltip>
|
|
892
|
+
)}
|
|
893
|
+
<div className="min-w-0">
|
|
894
|
+
<div className="truncate font-medium text-theme-text-primary">{row.name}</div>
|
|
895
|
+
<div className="truncate text-xs text-theme-text-tertiary">
|
|
896
|
+
{row.tool === 'argo' ? 'ArgoCD' : 'FluxCD'} {row.kind}
|
|
897
|
+
{row._cluster && (
|
|
898
|
+
<span title={row._cluster.name !== shortClusterName(row._cluster.name) ? row._cluster.name : undefined}>
|
|
899
|
+
{' · '}{shortClusterName(row._cluster.name)}
|
|
900
|
+
</span>
|
|
901
|
+
)}
|
|
902
|
+
</div>
|
|
903
|
+
</div>
|
|
904
|
+
</div>
|
|
905
|
+
</TableCell>
|
|
906
|
+
<TableCell>{row.project || '-'}</TableCell>
|
|
907
|
+
<TableCell>
|
|
908
|
+
{row.terminating
|
|
909
|
+
? <span className="text-[11px] text-theme-text-tertiary">—</span>
|
|
910
|
+
: <SyncStatusBadge sync={row.sync as any} suspended={row.suspended} />}
|
|
911
|
+
</TableCell>
|
|
912
|
+
<TableCell>
|
|
913
|
+
{row.terminating
|
|
914
|
+
? <span className="text-[11px] text-theme-text-tertiary">—</span>
|
|
915
|
+
: <HealthStatusBadge health={row.health as any} />}
|
|
916
|
+
</TableCell>
|
|
917
|
+
<TableCell>
|
|
918
|
+
<div className="truncate text-theme-text-primary">{row.repository || row.chart || '-'}</div>
|
|
919
|
+
<div className="truncate text-xs text-theme-text-tertiary">{[row.targetRevision, row.path || row.chart].filter(Boolean).join(' · ') || '-'}</div>
|
|
920
|
+
</TableCell>
|
|
921
|
+
<TableCell>
|
|
922
|
+
<DestinationCell row={row} onDestinationClick={onDestinationClick} />
|
|
923
|
+
<div className="truncate text-xs text-theme-text-tertiary">{row.destinationNamespace || row.namespace || '-'}</div>
|
|
924
|
+
</TableCell>
|
|
925
|
+
<TableCell>
|
|
926
|
+
{row.terminating
|
|
927
|
+
? <span className="text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</span>
|
|
928
|
+
: formatRelativeAge(row.lastSync || row.createdAt)}
|
|
929
|
+
</TableCell>
|
|
930
|
+
</tr>
|
|
931
|
+
))}
|
|
932
|
+
</tbody>
|
|
933
|
+
</table>
|
|
934
|
+
)
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function GitOpsTiles({
|
|
938
|
+
rows,
|
|
939
|
+
onOpen,
|
|
940
|
+
}: {
|
|
941
|
+
rows: GitOpsRow[]
|
|
942
|
+
onOpen: (row: GitOpsRow) => void
|
|
943
|
+
}) {
|
|
944
|
+
return (
|
|
945
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-3 p-4">
|
|
946
|
+
{rows.map((row) => (
|
|
947
|
+
<GitOpsTile key={row.id} row={row} onOpen={onOpen} />
|
|
948
|
+
))}
|
|
949
|
+
</div>
|
|
950
|
+
)
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function GitOpsTile({
|
|
954
|
+
row,
|
|
955
|
+
onOpen,
|
|
956
|
+
}: {
|
|
957
|
+
row: GitOpsRow
|
|
958
|
+
onOpen: (row: GitOpsRow) => void
|
|
959
|
+
}) {
|
|
960
|
+
const source = compactRepoSource(row.repository || row.chart, row.path || row.chart)
|
|
961
|
+
const revision = row.targetRevision || ''
|
|
962
|
+
const lastSyncRaw = row.lastSync || row.createdAt
|
|
963
|
+
const recencyClass = recencyTone(lastSyncRaw)
|
|
964
|
+
const dest = row.destination ? compactClusterURL(row.destination) : ''
|
|
965
|
+
const ns = row.destinationNamespace || row.namespace
|
|
966
|
+
return (
|
|
967
|
+
<button
|
|
968
|
+
type="button"
|
|
969
|
+
onClick={() => onOpen(row)}
|
|
970
|
+
className={clsx(
|
|
971
|
+
'group relative flex min-w-0 flex-col overflow-hidden rounded-md border border-theme-border bg-theme-surface text-left shadow-theme-sm transition-all hover:border-theme-text-tertiary/40 hover:shadow-theme-md',
|
|
972
|
+
row.terminating && 'opacity-80',
|
|
973
|
+
)}
|
|
974
|
+
>
|
|
975
|
+
<div className={clsx('h-1 w-full', statusStripe(row))} />
|
|
976
|
+
<div className="flex flex-1 flex-col gap-3 px-4 pb-4 pt-3">
|
|
977
|
+
<div className="line-clamp-2 break-all text-[15px] font-semibold leading-tight text-theme-text-primary">
|
|
978
|
+
{row.name}
|
|
979
|
+
</div>
|
|
980
|
+
<div className="flex flex-wrap gap-1.5">
|
|
981
|
+
{row.terminating ? (
|
|
982
|
+
<span className="badge border border-orange-500/40 bg-orange-500/15 text-orange-400" title="Pending deletion — finalizers still running">
|
|
983
|
+
<Trash2 className="h-3 w-3" />
|
|
984
|
+
Terminating
|
|
985
|
+
</span>
|
|
986
|
+
) : (
|
|
987
|
+
<>
|
|
988
|
+
<SyncStatusBadge sync={row.sync as any} suspended={row.suspended} />
|
|
989
|
+
<HealthStatusBadge health={row.health as any} />
|
|
990
|
+
</>
|
|
991
|
+
)}
|
|
992
|
+
</div>
|
|
993
|
+
<div className="flex flex-col gap-1 text-[12px]">
|
|
994
|
+
{source && (
|
|
995
|
+
<div className="truncate text-theme-text-secondary">{source}</div>
|
|
996
|
+
)}
|
|
997
|
+
{revision && (
|
|
998
|
+
<div className="truncate font-mono text-[11px] text-theme-text-tertiary">{shortRevision(revision)}</div>
|
|
999
|
+
)}
|
|
1000
|
+
{row.terminating ? (
|
|
1001
|
+
<div className="font-medium text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</div>
|
|
1002
|
+
) : (
|
|
1003
|
+
lastSyncRaw && <div className={clsx('font-medium', recencyClass)}>{formatRelativeAge(lastSyncRaw)}</div>
|
|
1004
|
+
)}
|
|
1005
|
+
</div>
|
|
1006
|
+
{(dest || ns || row.project) && (
|
|
1007
|
+
<div className="mt-auto flex flex-wrap items-center gap-x-1.5 gap-y-1 border-t border-theme-border/60 pt-3 text-[11px] text-theme-text-tertiary">
|
|
1008
|
+
{dest && <span className="truncate" title={row.destination}>{dest}</span>}
|
|
1009
|
+
{dest && ns && <span aria-hidden>·</span>}
|
|
1010
|
+
{ns && <span className="truncate">{ns}</span>}
|
|
1011
|
+
{row.project && row.project !== 'default' && (
|
|
1012
|
+
<>
|
|
1013
|
+
<span aria-hidden>·</span>
|
|
1014
|
+
<span className="truncate">{row.project}</span>
|
|
1015
|
+
</>
|
|
1016
|
+
)}
|
|
1017
|
+
</div>
|
|
1018
|
+
)}
|
|
1019
|
+
</div>
|
|
1020
|
+
</button>
|
|
1021
|
+
)
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function SummaryTile({ label, value, tone = 'neutral' }: { label: string; value: number; tone?: 'neutral' | 'warning' | 'error' | 'info' }) {
|
|
1025
|
+
const toneClass = {
|
|
1026
|
+
neutral: 'text-theme-text-primary',
|
|
1027
|
+
warning: 'text-amber-600 dark:text-amber-300',
|
|
1028
|
+
error: 'text-red-600 dark:text-red-300',
|
|
1029
|
+
info: 'text-sky-600 dark:text-sky-300',
|
|
1030
|
+
}[tone]
|
|
1031
|
+
return (
|
|
1032
|
+
<div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
|
|
1033
|
+
<div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
|
|
1034
|
+
<div className="text-xs text-theme-text-tertiary">{label}</div>
|
|
1035
|
+
</div>
|
|
1036
|
+
)
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function TableHead({ children, className = '' }: { children: ReactNode; className?: string }) {
|
|
1040
|
+
return <th className={`border-b border-theme-border px-3 py-2 font-medium ${className}`}>{children}</th>
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// DestinationCell renders line 1 of the Destination column. Three modes:
|
|
1044
|
+
// - No fleet stamp (single-cluster OSS): show the raw `row.destination`
|
|
1045
|
+
// string from the Argo/Flux spec (typically `https://kubernetes.default.svc`
|
|
1046
|
+
// for in-cluster, or a public LB URL for hub-spoke).
|
|
1047
|
+
// - Fleet stamp with `in_cluster` or no destination match (Flux rows):
|
|
1048
|
+
// show muted "same cluster" — the workload lives where the controller
|
|
1049
|
+
// lives.
|
|
1050
|
+
// - Fleet stamp with `inferred` match: clickable chip with the
|
|
1051
|
+
// destination cluster's Radar-known name. Click stops row propagation
|
|
1052
|
+
// and calls `onDestinationClick` (caller routes to the destination
|
|
1053
|
+
// cluster's workloads view).
|
|
1054
|
+
// - Fleet stamp with `unmatched`: amber chip with the raw Argo
|
|
1055
|
+
// destination name + warning icon — signals the destination isn't a
|
|
1056
|
+
// Radar-connected cluster (onboarding hook).
|
|
1057
|
+
function DestinationCell({
|
|
1058
|
+
row,
|
|
1059
|
+
onDestinationClick,
|
|
1060
|
+
}: {
|
|
1061
|
+
row: GitOpsRow
|
|
1062
|
+
onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
|
|
1063
|
+
}) {
|
|
1064
|
+
const dest = row._destination
|
|
1065
|
+
// Non-fleet (OSS) path — show the raw destination string.
|
|
1066
|
+
if (!dest) {
|
|
1067
|
+
return <div className="block truncate text-theme-text-primary">{row.destination || '-'}</div>
|
|
1068
|
+
}
|
|
1069
|
+
if (dest.match === 'in_cluster') {
|
|
1070
|
+
return <span className="block truncate text-theme-text-tertiary">same cluster</span>
|
|
1071
|
+
}
|
|
1072
|
+
if ((dest.match === 'exact' || dest.match === 'inferred') && dest.cluster_id && dest.cluster_name) {
|
|
1073
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
1074
|
+
e.stopPropagation()
|
|
1075
|
+
onDestinationClick?.(row, dest)
|
|
1076
|
+
}
|
|
1077
|
+
const short = shortClusterName(dest.cluster_name)
|
|
1078
|
+
// High-confidence (URL match): solid sky chip with a small ✓ marker.
|
|
1079
|
+
// Medium-confidence (name match): same chip styling but no marker, and
|
|
1080
|
+
// a slightly muted border tone so operators can tell at a glance
|
|
1081
|
+
// which destinations are URL-verified vs name-only. Tooltip carries
|
|
1082
|
+
// the human-readable reason from the hub.
|
|
1083
|
+
const highConfidence = dest.confidence === 'high'
|
|
1084
|
+
const tooltipReason = dest.reason ? ` (${dest.reason})` : ''
|
|
1085
|
+
return (
|
|
1086
|
+
<button
|
|
1087
|
+
type="button"
|
|
1088
|
+
onClick={handleClick}
|
|
1089
|
+
className={
|
|
1090
|
+
'block max-w-full truncate rounded px-1.5 py-0.5 text-xs font-medium hover:bg-sky-500/20 dark:text-sky-300 ' +
|
|
1091
|
+
(highConfidence
|
|
1092
|
+
? 'border border-sky-500/50 bg-sky-500/15 text-sky-700'
|
|
1093
|
+
: 'border border-sky-500/25 bg-sky-500/5 text-sky-600')
|
|
1094
|
+
}
|
|
1095
|
+
title={`Open workloads in ${dest.cluster_name}${tooltipReason}`}
|
|
1096
|
+
>
|
|
1097
|
+
{highConfidence ? '✓ ' : ''}{short}
|
|
1098
|
+
</button>
|
|
1099
|
+
)
|
|
1100
|
+
}
|
|
1101
|
+
const rawLabel = dest.raw_name || dest.raw_server || 'unknown'
|
|
1102
|
+
return (
|
|
1103
|
+
<span
|
|
1104
|
+
className="flex min-w-0 items-center gap-1 text-xs text-amber-600 dark:text-amber-300"
|
|
1105
|
+
title={`Not a Radar-connected cluster. Connect ${rawLabel} to see workloads.`}
|
|
1106
|
+
>
|
|
1107
|
+
<AlertTriangle className="h-3 w-3 shrink-0" />
|
|
1108
|
+
<span className="block truncate">{shortClusterName(rawLabel)}</span>
|
|
1109
|
+
</span>
|
|
1110
|
+
)
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// shortClusterName strips kubectl-context-style prefixes so cluster
|
|
1114
|
+
// chips show the human-recognizable suffix instead of the full provider
|
|
1115
|
+
// id. Operators recognize `management-cluster` instantly;
|
|
1116
|
+
// `gke_koalabackend_me-west1-a_management-cluster` is ~40 chars of noise.
|
|
1117
|
+
//
|
|
1118
|
+
// Delegates to parseContextName (utils/context-name.ts), which already
|
|
1119
|
+
// covers GKE / EKS ARN / AKS provider formats with a CodeQL-clean
|
|
1120
|
+
// linear-time regex. Falls back to the raw input when the parser can't
|
|
1121
|
+
// extract a cluster name (kind, k3d, user-named) — keeps the chip from
|
|
1122
|
+
// rendering blank for malformed inputs.
|
|
1123
|
+
export function shortClusterName(full: string): string {
|
|
1124
|
+
return parseContextName(full).clusterName || full
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function TableCell({ children }: { children: ReactNode }) {
|
|
1128
|
+
// overflow-hidden is the belt to inner `truncate`'s suspenders — in a
|
|
1129
|
+
// table-fixed layout, a long unbroken token (kubectl-context cluster
|
|
1130
|
+
// names, OCI repo URLs) will visually bleed into the next column unless
|
|
1131
|
+
// the cell itself clips. Callers should still use `block truncate` on
|
|
1132
|
+
// single-line content for the ellipsis; this prevents the cosmetic
|
|
1133
|
+
// disaster when they forget.
|
|
1134
|
+
return <td className="overflow-hidden border-b border-theme-border px-3 py-2 align-middle text-theme-text-secondary">{children}</td>
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// =============================================================================
|
|
1138
|
+
// Helpers — pure functions for row sorting / filtering / formatting. Exported
|
|
1139
|
+
// where the callers' normalize pipeline needs them (e.g. summarizeGitOpsRows
|
|
1140
|
+
// is used in OSS for the page-header counts).
|
|
1141
|
+
// =============================================================================
|
|
1142
|
+
|
|
1143
|
+
export function summarizeGitOpsRows(rows: GitOpsRow[]) {
|
|
1144
|
+
return rows.reduce(
|
|
1145
|
+
(summary, row) => {
|
|
1146
|
+
if (row.sync === 'OutOfSync') summary.outOfSync++
|
|
1147
|
+
if (row.health === 'Degraded') summary.degraded++
|
|
1148
|
+
if (row.health === 'Healthy') summary.healthy++
|
|
1149
|
+
if (row.health === 'Progressing') summary.progressing++
|
|
1150
|
+
if (row.suspended) summary.suspended++
|
|
1151
|
+
if (row.sync === 'Reconciling' || row.health === 'Progressing') summary.reconciling++
|
|
1152
|
+
return summary
|
|
1153
|
+
},
|
|
1154
|
+
{ outOfSync: 0, degraded: 0, healthy: 0, progressing: 0, suspended: 0, reconciling: 0 },
|
|
1155
|
+
)
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function compareRows(a: GitOpsRow, b: GitOpsRow, sortKey: SortKey) {
|
|
1159
|
+
if (sortKey === 'health') return urgencyRank(a) - urgencyRank(b) || a.name.localeCompare(b.name)
|
|
1160
|
+
if (sortKey === 'sync') return syncRank(a.sync) - syncRank(b.sync) || a.name.localeCompare(b.name)
|
|
1161
|
+
if (sortKey === 'lastSync') return (Date.parse(b.lastSync || b.createdAt) || 0) - (Date.parse(a.lastSync || a.createdAt) || 0)
|
|
1162
|
+
if (sortKey === 'project') return a.project.localeCompare(b.project) || a.name.localeCompare(b.name)
|
|
1163
|
+
return a.name.localeCompare(b.name)
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// urgencyRank groups rows by what the operator should do about them.
|
|
1167
|
+
// Tiers:
|
|
1168
|
+
// 0 — broken (Terminating, Degraded, Missing). Won't self-heal.
|
|
1169
|
+
// 1 — OutOfSync, no auto-sync. Drifted, waiting for a human.
|
|
1170
|
+
// 2 — OutOfSync with auto-sync. Healing in progress.
|
|
1171
|
+
// 3 — Progressing / Reconciling. Mid-rollout.
|
|
1172
|
+
// 4 — Unknown. Indeterminate.
|
|
1173
|
+
// 5 — Suspended. Intentional non-green.
|
|
1174
|
+
// 6 — Synced + Healthy. Calm.
|
|
1175
|
+
function urgencyRank(row: GitOpsRow): number {
|
|
1176
|
+
if (row.terminating) return 0
|
|
1177
|
+
if (row.health === 'Degraded' || row.health === 'Missing') return 0
|
|
1178
|
+
if (row.sync === 'OutOfSync' && !row.autoSync) return 1
|
|
1179
|
+
if (row.sync === 'OutOfSync') return 2
|
|
1180
|
+
if (row.health === 'Progressing' || row.sync === 'Reconciling') return 3
|
|
1181
|
+
if (row.suspended || row.health === 'Suspended') return 5
|
|
1182
|
+
if (row.health === 'Healthy' && row.sync === 'Synced') return 6
|
|
1183
|
+
return 4
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function syncRank(sync: string) {
|
|
1187
|
+
return ({ OutOfSync: 0, Reconciling: 1, Unknown: 2, Synced: 3 } as Record<string, number>)[sync] ?? 2
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function modeLabel(mode: GitOpsMode) {
|
|
1191
|
+
return ({ applications: 'Applications', sources: 'Sources', projects: 'Projects', alerts: 'Alerts' } as const)[mode]
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function statusStripe(row: GitOpsRow) {
|
|
1195
|
+
if (row.terminating) return 'bg-orange-500'
|
|
1196
|
+
if (row.health === 'Degraded') return 'bg-red-500'
|
|
1197
|
+
if (row.health === 'Progressing' || row.sync === 'Reconciling') return 'bg-sky-500'
|
|
1198
|
+
if (row.sync === 'OutOfSync') return 'bg-amber-500'
|
|
1199
|
+
if (row.health === 'Healthy' && row.sync === 'Synced') return 'bg-emerald-500'
|
|
1200
|
+
return 'bg-theme-text-tertiary'
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function countValues(values: string[]) {
|
|
1204
|
+
const counts = new Map<string, number>()
|
|
1205
|
+
for (const value of values) {
|
|
1206
|
+
const key = value || '(none)'
|
|
1207
|
+
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
1208
|
+
}
|
|
1209
|
+
return [...counts.entries()]
|
|
1210
|
+
.map(([name, count]) => ({ name, count }))
|
|
1211
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function countMap(values: string[]) {
|
|
1215
|
+
const counts = new Map<string, number>()
|
|
1216
|
+
for (const value of values) {
|
|
1217
|
+
counts.set(value || 'Unknown', (counts.get(value || 'Unknown') ?? 0) + 1)
|
|
1218
|
+
}
|
|
1219
|
+
return counts
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function countLabels(rows: GitOpsRow[]) {
|
|
1223
|
+
const counts = new Map<string, number>()
|
|
1224
|
+
for (const row of rows) {
|
|
1225
|
+
for (const [key, value] of Object.entries(row.labels)) {
|
|
1226
|
+
if (!value) continue
|
|
1227
|
+
if (key.includes('pod-template-hash') || key.includes('controller-revision-hash')) continue
|
|
1228
|
+
const pair = `${key}=${value}`
|
|
1229
|
+
counts.set(pair, (counts.get(pair) ?? 0) + 1)
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
return [...counts.entries()]
|
|
1233
|
+
.map(([name, count]) => ({ name, count }))
|
|
1234
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
|
|
1235
|
+
.slice(0, 30)
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function recencyTone(value: string): string {
|
|
1239
|
+
if (!value) return 'text-theme-text-tertiary'
|
|
1240
|
+
const time = Date.parse(value)
|
|
1241
|
+
if (!Number.isFinite(time)) return 'text-theme-text-tertiary'
|
|
1242
|
+
const diffMs = Date.now() - time
|
|
1243
|
+
if (diffMs < 10 * 60_000) return 'text-emerald-600 dark:text-emerald-400'
|
|
1244
|
+
if (diffMs > 7 * 24 * 60 * 60_000) return 'text-amber-600 dark:text-amber-400'
|
|
1245
|
+
return 'text-theme-text-secondary'
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function shortRevision(rev: string): string {
|
|
1249
|
+
if (rev.length <= 12) return rev
|
|
1250
|
+
if (/^[0-9a-f]{12,}$/i.test(rev)) return rev.slice(0, 7)
|
|
1251
|
+
return rev
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function compactRepoSource(repo: string, path: string): string {
|
|
1255
|
+
if (!repo) return ''
|
|
1256
|
+
let head = repo.replace(/^https?:\/\//, '').replace(/\.git$/, '')
|
|
1257
|
+
head = head.replace(/^(github\.com|gitlab\.com|bitbucket\.org)\//, '')
|
|
1258
|
+
return path ? `${head} · ${path}` : head
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function compactClusterURL(dest: string): string {
|
|
1262
|
+
return dest
|
|
1263
|
+
.replace(/^https?:\/\//, '')
|
|
1264
|
+
.replace(/^kubernetes\.default\.svc(:\d+)?\/?$/, 'in-cluster')
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// formatRelativeAge — inline relative-time formatter. Returns "" for unparseable
|
|
1268
|
+
// inputs so callers can treat empty as "no timestamp" and skip the time
|
|
1269
|
+
// cell gracefully.
|
|
1270
|
+
function formatRelativeAge(rfc3339: string): string {
|
|
1271
|
+
if (!rfc3339) return ''
|
|
1272
|
+
const time = Date.parse(rfc3339)
|
|
1273
|
+
if (!Number.isFinite(time)) return ''
|
|
1274
|
+
const diffMs = Date.now() - time
|
|
1275
|
+
const s = Math.max(0, Math.floor(diffMs / 1000))
|
|
1276
|
+
if (s < 60) return `${s}s ago`
|
|
1277
|
+
const m = Math.floor(s / 60)
|
|
1278
|
+
if (m < 60) return `${m}m ago`
|
|
1279
|
+
const h = Math.floor(m / 60)
|
|
1280
|
+
if (h < 24) return `${h}h ago`
|
|
1281
|
+
const d = Math.floor(h / 24)
|
|
1282
|
+
if (d < 30) return `${d}d ago`
|
|
1283
|
+
const mo = Math.floor(d / 30)
|
|
1284
|
+
if (mo < 12) return `${mo}mo ago`
|
|
1285
|
+
const y = Math.floor(d / 365)
|
|
1286
|
+
return `${y}y ago`
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// =============================================================================
|
|
1290
|
+
// Normalize helpers — convert raw Argo / Flux unstructured CRDs into the
|
|
1291
|
+
// GitOpsRow shape the table consumes. Exported so callers do their own
|
|
1292
|
+
// fetch+normalize pipeline (OSS fetches per-kind from the radar API;
|
|
1293
|
+
// hub-web fetches via the fleet endpoint then maps the response).
|
|
1294
|
+
// =============================================================================
|
|
1295
|
+
|
|
1296
|
+
export function normalizeArgoApplication(resource: any): GitOpsRow {
|
|
1297
|
+
const status = getGitOpsResourceStatus('applications', resource)
|
|
1298
|
+
const dest = resource.spec?.destination?.server ?? resource.spec?.destination?.name ?? ''
|
|
1299
|
+
// history?.length on the SAME optional-chain so a missing history
|
|
1300
|
+
// doesn't crash on `.length`. Argo populates status before history
|
|
1301
|
+
// on freshly-created Applications (between create and first sync),
|
|
1302
|
+
// so this isn't theoretical.
|
|
1303
|
+
const argoHistory = resource.status?.history
|
|
1304
|
+
const argoLastSync = resource.status?.operationState?.finishedAt ?? (argoHistory && argoHistory.length > 0 ? argoHistory[argoHistory.length - 1]?.deployedAt : undefined)
|
|
1305
|
+
return {
|
|
1306
|
+
id: `argo/applications/${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`,
|
|
1307
|
+
mode: 'applications',
|
|
1308
|
+
tool: 'argo',
|
|
1309
|
+
kindName: 'applications',
|
|
1310
|
+
kind: 'Application',
|
|
1311
|
+
group: 'argoproj.io',
|
|
1312
|
+
name: resource.metadata?.name ?? '',
|
|
1313
|
+
namespace: resource.metadata?.namespace ?? '',
|
|
1314
|
+
project: resource.spec?.project ?? '',
|
|
1315
|
+
labels: (resource.metadata?.labels ?? {}) as Record<string, string>,
|
|
1316
|
+
sync: status?.sync ?? 'Unknown',
|
|
1317
|
+
health: status?.health ?? 'Unknown',
|
|
1318
|
+
suspended: status?.suspended ?? false,
|
|
1319
|
+
repository: resource.spec?.source?.repoURL ?? '',
|
|
1320
|
+
targetRevision: resource.spec?.source?.targetRevision ?? '',
|
|
1321
|
+
path: resource.spec?.source?.path ?? '',
|
|
1322
|
+
chart: resource.spec?.source?.chart ?? '',
|
|
1323
|
+
destination: dest,
|
|
1324
|
+
destinationNamespace: resource.spec?.destination?.namespace ?? '',
|
|
1325
|
+
createdAt: resource.metadata?.creationTimestamp ?? '',
|
|
1326
|
+
lastSync: argoLastSync ?? '',
|
|
1327
|
+
autoSync: Boolean(resource.spec?.syncPolicy?.automated),
|
|
1328
|
+
terminating: isTerminating(resource),
|
|
1329
|
+
terminationStartedAt: terminationStartedAt(resource),
|
|
1330
|
+
raw: resource,
|
|
1331
|
+
_cluster: resource._cluster,
|
|
1332
|
+
_destination: resource._destination,
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
export function normalizeFluxKustomization(resource: any, fluxSourceUrls?: Map<string, string>): GitOpsRow {
|
|
1337
|
+
const status = getGitOpsResourceStatus('kustomizations', resource)
|
|
1338
|
+
const sourceName = resource.spec?.sourceRef?.name ?? ''
|
|
1339
|
+
const sourceKind = resource.spec?.sourceRef?.kind ?? ''
|
|
1340
|
+
const repository = fluxSourceUrls?.get(`${sourceKind}/${resource.metadata?.namespace ?? ''}/${sourceName}`) ?? sourceName
|
|
1341
|
+
return {
|
|
1342
|
+
id: `flux/kustomizations/${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`,
|
|
1343
|
+
mode: 'applications',
|
|
1344
|
+
tool: 'flux',
|
|
1345
|
+
kindName: 'kustomizations',
|
|
1346
|
+
kind: 'Kustomization',
|
|
1347
|
+
group: 'kustomize.toolkit.fluxcd.io',
|
|
1348
|
+
name: resource.metadata?.name ?? '',
|
|
1349
|
+
namespace: resource.metadata?.namespace ?? '',
|
|
1350
|
+
project: resource.metadata?.namespace ?? '',
|
|
1351
|
+
labels: (resource.metadata?.labels ?? {}) as Record<string, string>,
|
|
1352
|
+
sync: status?.sync ?? 'Unknown',
|
|
1353
|
+
health: status?.health ?? 'Unknown',
|
|
1354
|
+
suspended: status?.suspended ?? resource.spec?.suspend === true,
|
|
1355
|
+
repository,
|
|
1356
|
+
targetRevision: resource.status?.lastAppliedRevision ?? resource.status?.lastAttemptedRevision ?? '',
|
|
1357
|
+
path: resource.spec?.path ?? '',
|
|
1358
|
+
chart: '',
|
|
1359
|
+
destination: 'in-cluster',
|
|
1360
|
+
destinationNamespace: resource.spec?.targetNamespace ?? resource.metadata?.namespace ?? '',
|
|
1361
|
+
createdAt: resource.metadata?.creationTimestamp ?? '',
|
|
1362
|
+
lastSync: newestConditionTime(resource),
|
|
1363
|
+
autoSync: !resource.spec?.suspend,
|
|
1364
|
+
terminating: isTerminating(resource),
|
|
1365
|
+
terminationStartedAt: terminationStartedAt(resource),
|
|
1366
|
+
raw: resource,
|
|
1367
|
+
_cluster: resource._cluster,
|
|
1368
|
+
_destination: resource._destination,
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
export function normalizeFluxHelmRelease(resource: any, fluxSourceUrls?: Map<string, string>): GitOpsRow {
|
|
1373
|
+
const status = getGitOpsResourceStatus('helmreleases', resource)
|
|
1374
|
+
const chartSpec = resource.spec?.chart?.spec ?? {}
|
|
1375
|
+
const sourceName = chartSpec.sourceRef?.name ?? ''
|
|
1376
|
+
const sourceKind = chartSpec.sourceRef?.kind ?? ''
|
|
1377
|
+
const repository = fluxSourceUrls?.get(`${sourceKind}/${resource.metadata?.namespace ?? ''}/${sourceName}`) ?? sourceName
|
|
1378
|
+
return {
|
|
1379
|
+
id: `flux/helmreleases/${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`,
|
|
1380
|
+
mode: 'applications',
|
|
1381
|
+
tool: 'flux',
|
|
1382
|
+
kindName: 'helmreleases',
|
|
1383
|
+
kind: 'HelmRelease',
|
|
1384
|
+
group: 'helm.toolkit.fluxcd.io',
|
|
1385
|
+
name: resource.metadata?.name ?? '',
|
|
1386
|
+
namespace: resource.metadata?.namespace ?? '',
|
|
1387
|
+
project: resource.metadata?.namespace ?? '',
|
|
1388
|
+
labels: (resource.metadata?.labels ?? {}) as Record<string, string>,
|
|
1389
|
+
sync: status?.sync ?? 'Unknown',
|
|
1390
|
+
health: status?.health ?? 'Unknown',
|
|
1391
|
+
suspended: status?.suspended ?? resource.spec?.suspend === true,
|
|
1392
|
+
repository,
|
|
1393
|
+
targetRevision: chartSpec.version ?? resource.status?.lastAttemptedRevision ?? '',
|
|
1394
|
+
path: '',
|
|
1395
|
+
chart: chartSpec.chart ?? '',
|
|
1396
|
+
destination: 'in-cluster',
|
|
1397
|
+
destinationNamespace: resource.spec?.targetNamespace ?? resource.metadata?.namespace ?? '',
|
|
1398
|
+
createdAt: resource.metadata?.creationTimestamp ?? '',
|
|
1399
|
+
lastSync: newestConditionTime(resource),
|
|
1400
|
+
autoSync: !resource.spec?.suspend,
|
|
1401
|
+
terminating: isTerminating(resource),
|
|
1402
|
+
terminationStartedAt: terminationStartedAt(resource),
|
|
1403
|
+
raw: resource,
|
|
1404
|
+
_cluster: resource._cluster,
|
|
1405
|
+
_destination: resource._destination,
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// buildFluxSourceUrlMap — index Flux source CRs (GitRepository, HelmRepository,
|
|
1410
|
+
// OCIRepository, Bucket) by `<kind>/<namespace>/<name>` so the
|
|
1411
|
+
// normalize* helpers can resolve `spec.sourceRef.name` → an actual URL.
|
|
1412
|
+
// Without this, the Source column shows the opaque CR name only.
|
|
1413
|
+
export function buildFluxSourceUrlMap(sources: any[]): Map<string, string> {
|
|
1414
|
+
const out = new Map<string, string>()
|
|
1415
|
+
for (const src of sources) {
|
|
1416
|
+
const kind = src.kind ?? ''
|
|
1417
|
+
const ns = src.metadata?.namespace ?? ''
|
|
1418
|
+
const name = src.metadata?.name ?? ''
|
|
1419
|
+
const url = src.spec?.url ?? src.spec?.endpoint ?? ''
|
|
1420
|
+
if (!kind || !name || !url) continue
|
|
1421
|
+
out.set(`${kind}/${ns}/${name}`, url)
|
|
1422
|
+
}
|
|
1423
|
+
return out
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function isTerminating(resource: any): boolean {
|
|
1427
|
+
return Boolean(resource?.metadata?.deletionTimestamp)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function terminationStartedAt(resource: any): string | undefined {
|
|
1431
|
+
return resource?.metadata?.deletionTimestamp || undefined
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function newestConditionTime(resource: any): string {
|
|
1435
|
+
const times = (resource.status?.conditions ?? [])
|
|
1436
|
+
.map((condition: any) => condition.lastTransitionTime)
|
|
1437
|
+
.filter(Boolean)
|
|
1438
|
+
.sort()
|
|
1439
|
+
return times[times.length - 1] ?? ''
|
|
1440
|
+
}
|
|
1441
|
+
|