@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.
Files changed (57) hide show
  1. package/package.json +4 -4
  2. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -3
  3. package/src/components/dock/BottomDock.tsx +24 -17
  4. package/src/components/dock/DockContext.tsx +39 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
  6. package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
  7. package/src/components/gitops/GitOpsTableView.tsx +1441 -0
  8. package/src/components/gitops/RollbackDialog.tsx +117 -0
  9. package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
  10. package/src/components/gitops/detail-helpers.test.ts +97 -0
  11. package/src/components/gitops/detail-helpers.ts +112 -0
  12. package/src/components/gitops/index.ts +55 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1456 -0
  14. package/src/components/gitops/insights/index.ts +6 -0
  15. package/src/components/gitops/insights/insights-helpers.test.ts +98 -0
  16. package/src/components/gitops/insights/insights-helpers.ts +99 -0
  17. package/src/components/gitops/short-cluster-name.test.ts +36 -0
  18. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +799 -0
  19. package/src/components/gitops/tree/index.ts +6 -0
  20. package/src/components/gitops/tree/merge.test.ts +240 -0
  21. package/src/components/gitops/tree/merge.ts +160 -0
  22. package/src/components/gitops/tree/tree-helpers.ts +42 -0
  23. package/src/components/resources/ResourcesSidebar.tsx +42 -15
  24. package/src/components/resources/ResourcesView.tsx +136 -30
  25. package/src/components/resources/index.ts +1 -1
  26. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -1
  27. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
  28. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -1
  29. package/src/components/resources/renderers/PodRenderer.tsx +4 -3
  30. package/src/components/resources/renderers/SecretRenderer.tsx +4 -10
  31. package/src/components/shared/EditableYamlView.tsx +28 -17
  32. package/src/components/shared/ManagedByChip.tsx +45 -0
  33. package/src/components/shared/index.ts +1 -0
  34. package/src/components/timeline/TimelineList.tsx +3 -3
  35. package/src/components/topology/TopologyGraph.tsx +3 -2
  36. package/src/components/ui/Tooltip.tsx +10 -1
  37. package/src/components/ui/drawer-components.tsx +9 -21
  38. package/src/components/workload/ResourceDetailDrawer.tsx +5 -3
  39. package/src/components/workload/WorkloadView.tsx +66 -0
  40. package/src/hooks/useKeyboardShortcuts.tsx +3 -2
  41. package/src/index.ts +3 -0
  42. package/src/types/core.ts +48 -6
  43. package/src/types/gitops-insights.ts +193 -0
  44. package/src/types/gitops-tree.ts +57 -0
  45. package/src/types/index.ts +2 -0
  46. package/src/utils/badge-colors.ts +31 -1
  47. package/src/utils/format.ts +28 -0
  48. package/src/utils/gitops-owner.test.ts +95 -0
  49. package/src/utils/gitops-owner.ts +55 -0
  50. package/src/utils/gitops-route.test.ts +78 -0
  51. package/src/utils/gitops-route.ts +104 -0
  52. package/src/utils/helm-status.test.ts +50 -0
  53. package/src/utils/index.ts +2 -0
  54. package/src/utils/navigation.ts +14 -0
  55. package/src/utils/resource-hierarchy.ts +47 -3
  56. package/src/utils/yaml.test.ts +101 -0
  57. 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
+