@skyhook-io/radar-app 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2441 @@
1
+ import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
2
+ import { useLocation, useNavigate } from 'react-router-dom'
3
+ import { useQuery } from '@tanstack/react-query'
4
+ import { clsx } from 'clsx'
5
+ import { CheckCircle2, ChevronDown, ChevronRight, CircleAlert, CircleDot, Clock3, GitBranch, GitCommit, HeartPulse, LayoutGrid, List, Loader2, Pause, Play, RefreshCw, RotateCw, Search, Settings, Tag, Trash2, XCircle } from 'lucide-react'
6
+ import yaml from 'yaml'
7
+ import {
8
+ GitOpsActivityInsightView,
9
+ GitOpsChangesView,
10
+ GitOpsIssuesBand,
11
+ GitOpsTreeGraph,
12
+ GitOpsStatusStrip,
13
+ HealthStatusBadge,
14
+ SyncStatusBadge,
15
+ formatCompactAge,
16
+ formatRelativeAgeTime,
17
+ initNavigationMap,
18
+ kindToPlural,
19
+ type APIResource,
20
+ type GitOpsResourceTree,
21
+ type GitOpsInsightRef,
22
+ type GitOpsTreeFilters,
23
+ type GitOpsTreeRef,
24
+ type GitOpsTreePreset,
25
+ type SelectedResource,
26
+ } from '@skyhook-io/k8s-ui'
27
+ import {
28
+ argoStatusToGitOpsStatus,
29
+ fluxConditionsToGitOpsStatus,
30
+ type FluxCondition,
31
+ type GitOpsStatus,
32
+ } from '@skyhook-io/k8s-ui/types/gitops'
33
+ import { useToast } from '../ui/Toast'
34
+
35
+ import {
36
+ fetchJSON,
37
+ useApplyResource,
38
+ useArgoRefresh,
39
+ useArgoResume,
40
+ useArgoRollback,
41
+ useArgoSuspend,
42
+ useArgoSync,
43
+ useArgoTerminate,
44
+ useFluxReconcile,
45
+ useFluxResume,
46
+ useFluxSuspend,
47
+ useFluxSyncWithSource,
48
+ useGitOpsInsights,
49
+ useGitOpsTree,
50
+ useResource,
51
+ } from '../../api/client'
52
+ import { useAPIResources } from '../../api/apiResources'
53
+ import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
54
+ import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
55
+ import { Tooltip } from '../ui/Tooltip'
56
+ import { CodeViewer } from '../ui/CodeViewer'
57
+ import { SyncOptionsDialog } from './SyncOptionsDialog'
58
+ import { RollbackDialog } from './RollbackDialog'
59
+ import type { GitOpsHistoryItem } from '@skyhook-io/k8s-ui'
60
+
61
+ const GITOPS_KINDS: APIResource[] = [
62
+ { name: 'applications', kind: 'Application', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
63
+ { name: 'applicationsets', kind: 'ApplicationSet', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
64
+ { name: 'appprojects', kind: 'AppProject', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
65
+ { name: 'kustomizations', kind: 'Kustomization', group: 'kustomize.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
66
+ { name: 'helmreleases', kind: 'HelmRelease', group: 'helm.toolkit.fluxcd.io', version: 'v2', namespaced: true, verbs: ['list', 'get'], isCrd: true },
67
+ { name: 'gitrepositories', kind: 'GitRepository', group: 'source.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
68
+ { name: 'ocirepositories', kind: 'OCIRepository', group: 'source.toolkit.fluxcd.io', version: 'v1beta2', namespaced: true, verbs: ['list', 'get'], isCrd: true },
69
+ { name: 'helmrepositories', kind: 'HelmRepository', group: 'source.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
70
+ { name: 'alerts', kind: 'Alert', group: 'notification.toolkit.fluxcd.io', version: 'v1beta3', namespaced: true, verbs: ['list', 'get'], isCrd: true },
71
+ ]
72
+
73
+ const KIND_BY_NAME = new Map(GITOPS_KINDS.map((k) => [k.name, k]))
74
+
75
+ interface ResourceCountsResponse {
76
+ counts: Record<string, number>
77
+ forbidden?: string[]
78
+ }
79
+
80
+ type GitOpsMode = 'applications' | 'sources' | 'projects' | 'alerts'
81
+ type GitOpsViewMode = 'table' | 'tiles'
82
+ type SortKey = 'name' | 'health' | 'sync' | 'lastSync' | 'project'
83
+
84
+ interface GitOpsRow {
85
+ id: string
86
+ mode: GitOpsMode
87
+ tool: 'argo' | 'flux'
88
+ kindName: string
89
+ kind: string
90
+ group: string
91
+ name: string
92
+ namespace: string
93
+ project: string
94
+ labels: Record<string, string>
95
+ sync: string
96
+ health: string
97
+ suspended: boolean
98
+ repository: string
99
+ targetRevision: string
100
+ path: string
101
+ chart: string
102
+ destination: string
103
+ destinationNamespace: string
104
+ createdAt: string
105
+ lastSync: string
106
+ autoSync: boolean
107
+ // True when metadata.deletionTimestamp is set. Drives the small
108
+ // [Terminating] indicator on the row + on the detail page header,
109
+ // so users can spot zombie resources without having to drill in.
110
+ terminating: boolean
111
+ // RFC3339 timestamp from metadata.deletionTimestamp. Used in the
112
+ // fleet's Last Sync column to render "Pending {N}{unit} ago" instead of the
113
+ // stale last-reconcile time when the row is Terminating.
114
+ terminationStartedAt?: string
115
+ raw: any
116
+ }
117
+
118
+ interface GitOpsViewProps {
119
+ namespaces: string[]
120
+ onOpenResource: (resource: SelectedResource) => void
121
+ }
122
+
123
+ export function GitOpsView({ namespaces, onOpenResource }: GitOpsViewProps) {
124
+ const location = useLocation()
125
+ if (location.pathname.startsWith('/gitops/detail/')) {
126
+ return <GitOpsDetailView namespaces={namespaces} onOpenResource={onOpenResource} />
127
+ }
128
+ return <GitOpsTableView namespaces={namespaces} />
129
+ }
130
+
131
+ function GitOpsTableView({ namespaces }: { namespaces: string[] }) {
132
+ const navigate = useNavigate()
133
+ const searchInputRef = useRef<HTMLInputElement>(null)
134
+ const namespacesParam = namespaces.join(',')
135
+ const { data: apiResources, isLoading: apiResourcesLoading } = useAPIResources()
136
+
137
+ useEffect(() => {
138
+ initNavigationMap([...(apiResources ?? []), ...GITOPS_KINDS])
139
+ }, [apiResources])
140
+
141
+ const [mode, setMode] = useState<GitOpsMode>('applications')
142
+ const [viewMode, setViewMode] = useState<GitOpsViewMode>('table')
143
+ const [search, setSearch] = useState('')
144
+ const [syncFilters, setSyncFilters] = useState<Set<string>>(new Set())
145
+ const [healthFilters, setHealthFilters] = useState<Set<string>>(new Set())
146
+ const [projectFilters, setProjectFilters] = useState<Set<string>>(new Set())
147
+ const [namespaceFilters, setNamespaceFilters] = useState<Set<string>>(new Set())
148
+ const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set())
149
+ const [showLabelsDropdown, setShowLabelsDropdown] = useState(false)
150
+ const [labelSearch, setLabelSearch] = useState('')
151
+ const [automationFilter, setAutomationFilter] = useState<'all' | 'auto' | 'manual' | 'suspended'>('all')
152
+ // Lifecycle filter: surface zombies (terminating but stuck) and let the
153
+ // user filter them in/out. Default is 'all' so the fleet doesn't hide
154
+ // problem resources by accident; 'terminating' focuses to investigate
155
+ // stuck cleanups; 'active' hides them when the user wants to ignore
156
+ // resources that are on their way out.
157
+ const [lifecycleFilter, setLifecycleFilter] = useState<'all' | 'terminating' | 'active'>('all')
158
+ const [sortKey, setSortKey] = useState<SortKey>('health')
159
+
160
+ useRegisterShortcut({
161
+ id: 'gitops-focus-search',
162
+ keys: '/',
163
+ category: 'GitOps',
164
+ description: 'Focus GitOps search',
165
+ scope: 'gitops',
166
+ handler: (event) => {
167
+ event.preventDefault()
168
+ searchInputRef.current?.focus()
169
+ },
170
+ allowInInputs: false,
171
+ })
172
+
173
+ const countsQuery = useQuery({
174
+ queryKey: ['gitops-resource-counts', namespacesParam],
175
+ queryFn: async () => {
176
+ const params = new URLSearchParams()
177
+ if (namespaces.length > 0) params.set('namespaces', namespacesParam)
178
+ return fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
179
+ },
180
+ staleTime: 10_000,
181
+ refetchInterval: 60_000,
182
+ })
183
+
184
+ const applicationQuery = useQuery({
185
+ queryKey: ['gitops-applications-main', namespaces, apiResources?.length ?? 0],
186
+ queryFn: async () => {
187
+ const hasApplications = hasAPIResource(apiResources, 'applications', 'argoproj.io')
188
+ const hasKustomizations = hasAPIResource(apiResources, 'kustomizations', 'kustomize.toolkit.fluxcd.io')
189
+ const hasHelmReleases = hasAPIResource(apiResources, 'helmreleases', 'helm.toolkit.fluxcd.io')
190
+ // Flux source CRs carry the actual URL. Reconcilers (Kustomization,
191
+ // HelmRelease) only reference the source by name. We list sources
192
+ // alongside the reconcilers and build one lookup map so the fleet's
193
+ // Source column can render the URL (e.g. github.com/owner/repo)
194
+ // instead of the opaque CR name (e.g. "GitRepository podinfo").
195
+ // Listing the sources cluster-wide is cheap — they're cached by the
196
+ // dynamic informer and there are few per cluster — but skip the
197
+ // request entirely when no Flux CRDs are installed.
198
+ const hasFluxSources = hasKustomizations || hasHelmReleases
199
+ const hasGitRepos = hasFluxSources && hasAPIResource(apiResources, 'gitrepositories', 'source.toolkit.fluxcd.io')
200
+ const hasHelmRepos = hasFluxSources && hasAPIResource(apiResources, 'helmrepositories', 'source.toolkit.fluxcd.io')
201
+ const hasOCIRepos = hasFluxSources && hasAPIResource(apiResources, 'ocirepositories', 'source.toolkit.fluxcd.io')
202
+ const hasBuckets = hasFluxSources && hasAPIResource(apiResources, 'buckets', 'source.toolkit.fluxcd.io')
203
+ const [applications, kustomizations, helmReleases, gitRepos, helmRepos, ociRepos, buckets] = await Promise.all([
204
+ hasApplications ? fetchResourceList('applications', 'argoproj.io', namespacesParam) : Promise.resolve([]),
205
+ hasKustomizations ? fetchResourceList('kustomizations', 'kustomize.toolkit.fluxcd.io', namespacesParam) : Promise.resolve([]),
206
+ hasHelmReleases ? fetchResourceList('helmreleases', 'helm.toolkit.fluxcd.io', namespacesParam) : Promise.resolve([]),
207
+ hasGitRepos ? fetchResourceList('gitrepositories', 'source.toolkit.fluxcd.io', '') : Promise.resolve([]),
208
+ hasHelmRepos ? fetchResourceList('helmrepositories', 'source.toolkit.fluxcd.io', '') : Promise.resolve([]),
209
+ hasOCIRepos ? fetchResourceList('ocirepositories', 'source.toolkit.fluxcd.io', '') : Promise.resolve([]),
210
+ hasBuckets ? fetchResourceList('buckets', 'source.toolkit.fluxcd.io', '') : Promise.resolve([]),
211
+ ])
212
+ const fluxSourceUrls = buildFluxSourceUrlMap([...gitRepos, ...helmRepos, ...ociRepos, ...buckets])
213
+ return [
214
+ ...applications.map((r) => normalizeArgoApplication(r)),
215
+ ...kustomizations.map((r) => normalizeFluxKustomization(r, fluxSourceUrls)),
216
+ ...helmReleases.map((r) => normalizeFluxHelmRelease(r, fluxSourceUrls)),
217
+ ]
218
+ },
219
+ enabled: !apiResourcesLoading,
220
+ staleTime: 30_000,
221
+ refetchInterval: 120_000,
222
+ })
223
+
224
+ const gitopsCounts = useMemo(() => {
225
+ const counts = countsQuery.data?.counts ?? {}
226
+ const out: Record<string, number> = {}
227
+ for (const k of GITOPS_KINDS) {
228
+ out[k.group ? `${k.group}/${k.kind}` : k.name] = counts[`${k.group}/${k.kind}`] ?? counts[k.name] ?? 0
229
+ }
230
+ return out
231
+ }, [countsQuery.data])
232
+
233
+ const totalGitOps = Object.values(gitopsCounts).reduce((sum, n) => sum + n, 0)
234
+ const allRows = applicationQuery.data ?? []
235
+ const statusSummary = summarizeGitOpsRows(allRows)
236
+
237
+ const modeCounts = {
238
+ applications: allRows.length,
239
+ sources: (gitopsCounts['source.toolkit.fluxcd.io/GitRepository'] ?? 0) + (gitopsCounts['source.toolkit.fluxcd.io/OCIRepository'] ?? 0) + (gitopsCounts['source.toolkit.fluxcd.io/HelmRepository'] ?? 0),
240
+ projects: gitopsCounts['argoproj.io/AppProject'] ?? 0,
241
+ alerts: gitopsCounts['notification.toolkit.fluxcd.io/Alert'] ?? 0,
242
+ }
243
+
244
+ const projects = useMemo(() => countValues(allRows.map((row) => row.project).filter(Boolean)), [allRows])
245
+ const rowNamespaces = useMemo(() => countValues(allRows.map((row) => row.namespace || '(cluster)').filter(Boolean)), [allRows])
246
+ const syncCounts = useMemo(() => countMap(allRows.map((row) => row.sync)), [allRows])
247
+ const healthCounts = useMemo(() => countMap(allRows.map((row) => row.health)), [allRows])
248
+ const labels = useMemo(() => countLabels(allRows), [allRows])
249
+ const filteredRows = useMemo(() => {
250
+ const q = search.trim().toLowerCase()
251
+ const activeLabels = [...labelFilters].map((pair) => {
252
+ const [key, ...rest] = pair.split('=')
253
+ return { key, value: rest.join('=') }
254
+ }).filter((label) => label.key && label.value)
255
+ const rows = allRows.filter((row) => {
256
+ if (mode !== 'applications') return false
257
+ if (q && ![
258
+ row.name,
259
+ row.namespace,
260
+ row.project,
261
+ row.repository,
262
+ row.path,
263
+ row.chart,
264
+ row.destination,
265
+ row.targetRevision,
266
+ row.kind,
267
+ ].some((value) => value.toLowerCase().includes(q))) return false
268
+ if (syncFilters.size > 0 && !syncFilters.has(row.sync)) return false
269
+ if (healthFilters.size > 0 && !healthFilters.has(row.health)) return false
270
+ if (projectFilters.size > 0 && !projectFilters.has(row.project || '(none)')) return false
271
+ if (namespaceFilters.size > 0 && !namespaceFilters.has(row.namespace || '(cluster)')) return false
272
+ if (activeLabels.length > 0 && !activeLabels.every(({ key, value }) => row.labels[key] === value)) return false
273
+ if (automationFilter === 'auto' && !row.autoSync) return false
274
+ if (automationFilter === 'manual' && row.autoSync) return false
275
+ if (automationFilter === 'suspended' && !row.suspended) return false
276
+ if (lifecycleFilter === 'terminating' && !row.terminating) return false
277
+ if (lifecycleFilter === 'active' && row.terminating) return false
278
+ return true
279
+ })
280
+ return [...rows].sort((a, b) => compareRows(a, b, sortKey))
281
+ }, [allRows, automationFilter, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sortKey, syncFilters])
282
+
283
+ const terminatingCount = useMemo(() => allRows.filter((row) => row.terminating).length, [allRows])
284
+
285
+ function openRow(row: GitOpsRow) {
286
+ const ns = row.namespace || '_'
287
+ const params = new URLSearchParams()
288
+ params.set('apiGroup', row.group)
289
+ navigate({ pathname: gitOpsDetailPath(row.kindName, ns, row.name), search: params.toString() })
290
+ }
291
+
292
+ function refetch() {
293
+ applicationQuery.refetch()
294
+ }
295
+
296
+ const isInitialLoading = apiResourcesLoading || countsQuery.isLoading || applicationQuery.isLoading
297
+
298
+ if (totalGitOps === 0 && applicationQuery.isFetched && countsQuery.isFetched && !isInitialLoading) {
299
+ return (
300
+ <div className="flex h-full min-h-0 flex-1 items-center justify-center bg-theme-base p-4">
301
+ <div className="rounded-lg border border-theme-border bg-theme-surface p-8 text-center">
302
+ <GitBranch className="mx-auto h-8 w-8 text-theme-text-tertiary" />
303
+ <h2 className="mt-3 text-base font-semibold text-theme-text-primary">No GitOps resources detected</h2>
304
+ <p className="mt-1 text-sm text-theme-text-secondary">
305
+ Radar did not find ArgoCD Applications or FluxCD resources in this cluster.
306
+ </p>
307
+ </div>
308
+ </div>
309
+ )
310
+ }
311
+
312
+ return (
313
+ <div className="flex h-full min-w-0 flex-1 overflow-hidden bg-theme-base max-lg:flex-col">
314
+ <GitOpsFilterSidebar
315
+ mode={mode}
316
+ onModeChange={setMode}
317
+ modeCounts={modeCounts}
318
+ syncCounts={syncCounts}
319
+ syncFilters={syncFilters}
320
+ onToggleSync={(value) => toggleSet(syncFilters, setSyncFilters, value)}
321
+ healthCounts={healthCounts}
322
+ healthFilters={healthFilters}
323
+ onToggleHealth={(value) => toggleSet(healthFilters, setHealthFilters, value)}
324
+ automationFilter={automationFilter}
325
+ onAutomationFilterChange={setAutomationFilter}
326
+ lifecycleFilter={lifecycleFilter}
327
+ onLifecycleFilterChange={setLifecycleFilter}
328
+ terminatingCount={terminatingCount}
329
+ projects={projects}
330
+ projectFilters={projectFilters}
331
+ onToggleProject={(value) => toggleSet(projectFilters, setProjectFilters, value)}
332
+ namespaces={rowNamespaces}
333
+ namespaceFilters={namespaceFilters}
334
+ onToggleNamespace={(value) => toggleSet(namespaceFilters, setNamespaceFilters, value)}
335
+ onClear={() => {
336
+ setSearch('')
337
+ setSyncFilters(new Set())
338
+ setHealthFilters(new Set())
339
+ setProjectFilters(new Set())
340
+ setNamespaceFilters(new Set())
341
+ setLabelFilters(new Set())
342
+ setAutomationFilter('all')
343
+ setLifecycleFilter('all')
344
+ }}
345
+ />
346
+
347
+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
348
+ <div className="shrink-0 border-b border-theme-border bg-theme-base px-4 py-3">
349
+ <div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
350
+ <div className="min-w-0">
351
+ <h1 className="text-lg font-semibold text-theme-text-primary">GitOps</h1>
352
+ <p className="truncate text-sm text-theme-text-secondary">
353
+ Applications and reconciliations with source, destination, sync, and health state.
354
+ </p>
355
+ </div>
356
+ <div className="flex shrink-0 flex-wrap justify-end gap-2">
357
+ <SummaryTile label="Applications" value={allRows.length} />
358
+ <SummaryTile label="Out of sync" value={statusSummary.outOfSync} tone="warning" />
359
+ <SummaryTile label="Degraded" value={statusSummary.degraded} tone="error" />
360
+ <SummaryTile label="Suspended" value={statusSummary.suspended} tone="warning" />
361
+ <SummaryTile label="Reconciling" value={statusSummary.reconciling} tone="info" />
362
+ </div>
363
+ </div>
364
+ </div>
365
+
366
+ <div className="shrink-0 border-b border-theme-border bg-theme-surface/70 px-4 py-3">
367
+ <StatusDistribution rows={filteredRows} />
368
+ <div className="mt-3 flex flex-wrap items-center gap-2">
369
+ <div className="relative w-full max-w-md">
370
+ <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" />
371
+ <input
372
+ ref={searchInputRef}
373
+ value={search}
374
+ onChange={(e) => setSearch(e.target.value)}
375
+ placeholder="Search applications, repos, paths..."
376
+ 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"
377
+ />
378
+ </div>
379
+ {/* Surface the filter denominator so users know whether they're
380
+ seeing all rows, a search-narrowed slice, or a sidebar-filtered
381
+ slice. The KPI tiles count the unfiltered universe; this caption
382
+ counts the visible result set. */}
383
+ {filteredRows.length !== allRows.length && (
384
+ <span className="text-[11px] text-theme-text-tertiary">
385
+ Showing {filteredRows.length} of {allRows.length}
386
+ </span>
387
+ )}
388
+ <select
389
+ value={sortKey}
390
+ onChange={(e) => setSortKey(e.target.value as SortKey)}
391
+ 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"
392
+ >
393
+ <option value="health">Sort: health</option>
394
+ <option value="sync">Sort: sync</option>
395
+ <option value="lastSync">Sort: last sync</option>
396
+ <option value="project">Sort: project</option>
397
+ <option value="name">Sort: name</option>
398
+ </select>
399
+ {labels.length > 0 && (
400
+ <LabelsDropdown
401
+ labels={labels}
402
+ activeLabels={labelFilters}
403
+ onToggle={(value) => toggleSet(labelFilters, setLabelFilters, value)}
404
+ onClear={() => setLabelFilters(new Set())}
405
+ open={showLabelsDropdown}
406
+ onOpenChange={(open) => {
407
+ setShowLabelsDropdown(open)
408
+ if (open) setLabelSearch('')
409
+ }}
410
+ search={labelSearch}
411
+ onSearchChange={setLabelSearch}
412
+ />
413
+ )}
414
+ <div className="flex overflow-hidden rounded-md border border-theme-border">
415
+ <IconToggle active={viewMode === 'table'} label="Table" icon={List} onClick={() => setViewMode('table')} />
416
+ <IconToggle active={viewMode === 'tiles'} label="Tiles" icon={LayoutGrid} onClick={() => setViewMode('tiles')} />
417
+ </div>
418
+ <Tooltip content="Refresh GitOps resources">
419
+ <button
420
+ type="button"
421
+ onClick={refetch}
422
+ 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"
423
+ >
424
+ <RefreshCw className={`h-3.5 w-3.5 ${applicationQuery.isFetching ? 'animate-spin' : ''}`} />
425
+ </button>
426
+ </Tooltip>
427
+ </div>
428
+ </div>
429
+
430
+ <div className="min-h-0 min-w-0 flex-1 overflow-auto bg-theme-base">
431
+ {mode !== 'applications' ? (
432
+ <div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
433
+ {modeLabel(mode)} view is queued behind the application list.
434
+ </div>
435
+ ) : applicationQuery.isLoading ? (
436
+ <div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
437
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading GitOps applications...
438
+ </div>
439
+ ) : applicationQuery.error ? (
440
+ <div className="p-4 text-sm text-red-500">Failed to load GitOps applications: {(applicationQuery.error as Error).message}</div>
441
+ ) : filteredRows.length === 0 ? (
442
+ <div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
443
+ No applications match the current filters.
444
+ </div>
445
+ ) : viewMode === 'tiles' ? (
446
+ <GitOpsTiles rows={filteredRows} onOpen={openRow} />
447
+ ) : (
448
+ <GitOpsTable rows={filteredRows} onOpen={openRow} />
449
+ )}
450
+ </div>
451
+ </div>
452
+ </div>
453
+ )
454
+ }
455
+
456
+ function GitOpsFilterSidebar({
457
+ mode,
458
+ onModeChange,
459
+ modeCounts,
460
+ syncCounts,
461
+ syncFilters,
462
+ onToggleSync,
463
+ healthCounts,
464
+ healthFilters,
465
+ onToggleHealth,
466
+ automationFilter,
467
+ onAutomationFilterChange,
468
+ lifecycleFilter,
469
+ onLifecycleFilterChange,
470
+ terminatingCount,
471
+ projects,
472
+ projectFilters,
473
+ onToggleProject,
474
+ namespaces,
475
+ namespaceFilters,
476
+ onToggleNamespace,
477
+ onClear,
478
+ }: {
479
+ mode: GitOpsMode
480
+ onModeChange: (mode: GitOpsMode) => void
481
+ modeCounts: Record<GitOpsMode, number>
482
+ syncCounts: Map<string, number>
483
+ syncFilters: Set<string>
484
+ onToggleSync: (value: string) => void
485
+ healthCounts: Map<string, number>
486
+ healthFilters: Set<string>
487
+ onToggleHealth: (value: string) => void
488
+ automationFilter: 'all' | 'auto' | 'manual' | 'suspended'
489
+ onAutomationFilterChange: (value: 'all' | 'auto' | 'manual' | 'suspended') => void
490
+ lifecycleFilter: 'all' | 'terminating' | 'active'
491
+ onLifecycleFilterChange: (value: 'all' | 'terminating' | 'active') => void
492
+ terminatingCount: number
493
+ projects: Array<{ name: string; count: number }>
494
+ projectFilters: Set<string>
495
+ onToggleProject: (value: string) => void
496
+ namespaces: Array<{ name: string; count: number }>
497
+ namespaceFilters: Set<string>
498
+ onToggleNamespace: (value: string) => void
499
+ onClear: () => void
500
+ }) {
501
+ return (
502
+ <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">
503
+ <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
504
+ <span className="text-sm font-medium text-theme-text-secondary">GitOps Filters</span>
505
+ <button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
506
+ Clear
507
+ </button>
508
+ </div>
509
+ <div className="flex-1 overflow-y-auto">
510
+ {/* Sources/Projects/Alerts modes are placeholder surfaces that route to
511
+ a "queued behind the application list" pane — confusing to expose
512
+ in the primary nav while they're not built. Restore here once the
513
+ corresponding views ship. */}
514
+ <FilterSection icon={GitBranch} title="Scope">
515
+ <div className="grid grid-cols-2 gap-1">
516
+ {(['applications'] as GitOpsMode[]).map((item) => (
517
+ <button
518
+ key={item}
519
+ type="button"
520
+ onClick={() => onModeChange(item)}
521
+ className={`rounded-md px-2 py-1.5 text-left text-[11px] transition-colors ${
522
+ mode === item
523
+ ? 'bg-skyhook-500 text-white'
524
+ : 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
525
+ }`}
526
+ >
527
+ <div className="font-medium">{modeLabel(item)}</div>
528
+ <div className={mode === item ? 'text-white/70' : 'text-theme-text-tertiary'}>{modeCounts[item]}</div>
529
+ </button>
530
+ ))}
531
+ </div>
532
+ </FilterSection>
533
+
534
+ <FilterSection icon={CheckCircle2} title="Sync">
535
+ <FacetButton label="Synced" count={syncCounts.get('Synced') ?? 0} active={syncFilters.has('Synced')} tone="success" onClick={() => onToggleSync('Synced')} />
536
+ <FacetButton label="OutOfSync" count={syncCounts.get('OutOfSync') ?? 0} active={syncFilters.has('OutOfSync')} tone="warning" onClick={() => onToggleSync('OutOfSync')} />
537
+ <FacetButton label="Reconciling" count={syncCounts.get('Reconciling') ?? 0} active={syncFilters.has('Reconciling')} tone="info" onClick={() => onToggleSync('Reconciling')} />
538
+ <FacetButton label="Unknown" count={syncCounts.get('Unknown') ?? 0} active={syncFilters.has('Unknown')} onClick={() => onToggleSync('Unknown')} />
539
+ </FilterSection>
540
+
541
+ <FilterSection icon={HeartPulse} title="Health">
542
+ <FacetButton label="Healthy" count={healthCounts.get('Healthy') ?? 0} active={healthFilters.has('Healthy')} tone="success" onClick={() => onToggleHealth('Healthy')} />
543
+ <FacetButton label="Progressing" count={healthCounts.get('Progressing') ?? 0} active={healthFilters.has('Progressing')} tone="info" onClick={() => onToggleHealth('Progressing')} />
544
+ <FacetButton label="Degraded" count={healthCounts.get('Degraded') ?? 0} active={healthFilters.has('Degraded')} tone="error" onClick={() => onToggleHealth('Degraded')} />
545
+ <FacetButton label="Suspended" count={healthCounts.get('Suspended') ?? 0} active={healthFilters.has('Suspended')} tone="warning" onClick={() => onToggleHealth('Suspended')} />
546
+ <FacetButton label="Unknown" count={healthCounts.get('Unknown') ?? 0} active={healthFilters.has('Unknown')} onClick={() => onToggleHealth('Unknown')} />
547
+ </FilterSection>
548
+
549
+ <FilterSection icon={CircleDot} title="Automation">
550
+ <div className="grid grid-cols-2 gap-1">
551
+ {([
552
+ ['all', 'All'],
553
+ ['auto', 'Auto-sync'],
554
+ ['manual', 'Manual'],
555
+ ['suspended', 'Suspended'],
556
+ ] as const).map(([value, label]) => (
557
+ <button
558
+ key={value}
559
+ type="button"
560
+ onClick={() => onAutomationFilterChange(value)}
561
+ className={`rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors ${
562
+ automationFilter === value
563
+ ? 'bg-skyhook-500 text-white'
564
+ : 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
565
+ }`}
566
+ >
567
+ {label}
568
+ </button>
569
+ ))}
570
+ </div>
571
+ </FilterSection>
572
+
573
+ {terminatingCount > 0 && (
574
+ <FilterSection icon={Trash2} title="Lifecycle">
575
+ <div className="grid grid-cols-3 gap-1">
576
+ {([
577
+ ['all', 'All'],
578
+ ['active', 'Active'],
579
+ ['terminating', `Terminating (${terminatingCount})`],
580
+ ] as const).map(([value, label]) => (
581
+ <button
582
+ key={value}
583
+ type="button"
584
+ onClick={() => onLifecycleFilterChange(value)}
585
+ className={`rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors ${
586
+ lifecycleFilter === value
587
+ ? value === 'terminating'
588
+ // Distinct tone for the Terminating mode — orange
589
+ // mirrors the [Term] chip + insight Issue color, so
590
+ // the active state visually links to its consequence.
591
+ ? 'bg-orange-500 text-white'
592
+ : 'bg-skyhook-500 text-white'
593
+ : 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
594
+ }`}
595
+ >
596
+ {label}
597
+ </button>
598
+ ))}
599
+ </div>
600
+ </FilterSection>
601
+ )}
602
+
603
+ <FilterSection icon={CircleAlert} title="Projects">
604
+ {projects.slice(0, 10).map((project) => (
605
+ <FacetButton
606
+ key={project.name}
607
+ label={project.name || '(none)'}
608
+ count={project.count}
609
+ active={projectFilters.has(project.name || '(none)')}
610
+ onClick={() => onToggleProject(project.name || '(none)')}
611
+ />
612
+ ))}
613
+ </FilterSection>
614
+
615
+ <FilterSection icon={List} title="Namespaces">
616
+ {namespaces.slice(0, 12).map((namespace) => (
617
+ <FacetButton
618
+ key={namespace.name}
619
+ label={namespace.name}
620
+ count={namespace.count}
621
+ active={namespaceFilters.has(namespace.name)}
622
+ onClick={() => onToggleNamespace(namespace.name)}
623
+ />
624
+ ))}
625
+ </FilterSection>
626
+ </div>
627
+ </aside>
628
+ )
629
+ }
630
+
631
+ function FilterSection({ icon: Icon, title, children }: { icon: ComponentType<{ className?: string }>; title: string; children: ReactNode }) {
632
+ return (
633
+ <section className="border-b border-theme-border px-3 py-2">
634
+ <div className="mb-1.5 flex items-center gap-2">
635
+ <Icon className="h-3.5 w-3.5 text-theme-text-tertiary" />
636
+ <span className="text-[10px] font-medium uppercase tracking-wider text-theme-text-tertiary">{title}</span>
637
+ </div>
638
+ <div className="space-y-0.5">{children}</div>
639
+ </section>
640
+ )
641
+ }
642
+
643
+ function FacetButton({
644
+ label,
645
+ count,
646
+ active,
647
+ tone = 'neutral',
648
+ onClick,
649
+ }: {
650
+ label: string
651
+ count: number
652
+ active: boolean
653
+ tone?: 'neutral' | 'success' | 'warning' | 'error' | 'info'
654
+ onClick: () => void
655
+ }) {
656
+ const dot = {
657
+ neutral: 'bg-theme-text-tertiary',
658
+ success: 'bg-emerald-500',
659
+ warning: 'bg-amber-500',
660
+ error: 'bg-red-500',
661
+ info: 'bg-sky-500',
662
+ }[tone]
663
+ return (
664
+ <button
665
+ type="button"
666
+ onClick={onClick}
667
+ className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[11px] transition-colors ${
668
+ active ? 'bg-blue-500/15 text-blue-500' : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
669
+ }`}
670
+ >
671
+ <span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} />
672
+ <span className="min-w-0 flex-1 truncate font-medium">{label}</span>
673
+ {count > 0 && <span className="tabular-nums text-theme-text-tertiary">{count}</span>}
674
+ </button>
675
+ )
676
+ }
677
+
678
+ function IconToggle({ active, label, icon: Icon, onClick }: { active: boolean; label: string; icon: ComponentType<{ className?: string }>; onClick: () => void }) {
679
+ return (
680
+ <Tooltip content={label}>
681
+ <button
682
+ type="button"
683
+ onClick={onClick}
684
+ className={`inline-flex h-8 w-8 items-center justify-center transition-colors ${
685
+ active ? 'bg-skyhook-500 text-white' : 'bg-theme-base text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
686
+ }`}
687
+ >
688
+ <Icon className="h-3.5 w-3.5" />
689
+ </button>
690
+ </Tooltip>
691
+ )
692
+ }
693
+
694
+ function LabelsDropdown({
695
+ labels,
696
+ activeLabels,
697
+ onToggle,
698
+ onClear,
699
+ open,
700
+ onOpenChange,
701
+ search,
702
+ onSearchChange,
703
+ }: {
704
+ labels: Array<{ name: string; count: number }>
705
+ activeLabels: Set<string>
706
+ onToggle: (value: string) => void
707
+ onClear: () => void
708
+ open: boolean
709
+ onOpenChange: (open: boolean) => void
710
+ search: string
711
+ onSearchChange: (value: string) => void
712
+ }) {
713
+ const filtered = search.trim()
714
+ ? labels.filter((label) => label.name.toLowerCase().includes(search.trim().toLowerCase()))
715
+ : labels
716
+ return (
717
+ <div className="relative">
718
+ <button
719
+ type="button"
720
+ onClick={() => onOpenChange(!open)}
721
+ className={`inline-flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs transition-colors ${
722
+ activeLabels.size > 0
723
+ ? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-600 dark:text-emerald-300'
724
+ : 'border-theme-border bg-theme-base text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
725
+ }`}
726
+ >
727
+ <Tag className="h-3.5 w-3.5" />
728
+ Labels
729
+ {activeLabels.size > 0 && (
730
+ <span className="rounded bg-emerald-500/20 px-1 text-[10px] tabular-nums">{activeLabels.size}</span>
731
+ )}
732
+ </button>
733
+ {open && (
734
+ <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">
735
+ <div className="border-b border-theme-border p-2">
736
+ <div className="mb-2 text-xs text-theme-text-secondary">
737
+ Selected labels are combined with <span className="font-semibold text-theme-text-primary">AND</span>.
738
+ </div>
739
+ <div className="flex items-center gap-2">
740
+ <div className="relative flex-1">
741
+ <Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-theme-text-tertiary" />
742
+ <input
743
+ type="text"
744
+ value={search}
745
+ onChange={(e) => onSearchChange(e.target.value)}
746
+ placeholder="Search labels..."
747
+ autoFocus
748
+ 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"
749
+ />
750
+ </div>
751
+ {activeLabels.size > 0 && (
752
+ <button
753
+ type="button"
754
+ onClick={() => {
755
+ onClear()
756
+ onOpenChange(false)
757
+ }}
758
+ className="shrink-0 rounded px-1 py-0.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
759
+ >
760
+ Clear
761
+ </button>
762
+ )}
763
+ </div>
764
+ </div>
765
+ <div className="max-h-72 overflow-y-auto py-1">
766
+ {filtered.map((label) => {
767
+ const active = activeLabels.has(label.name)
768
+ return (
769
+ <button
770
+ key={label.name}
771
+ type="button"
772
+ onClick={() => onToggle(label.name)}
773
+ className={`flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-xs transition-colors ${
774
+ active
775
+ ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300'
776
+ : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
777
+ }`}
778
+ >
779
+ <Tooltip content={label.name} delay={400} wrapperClassName="min-w-0 flex-1">
780
+ <span className="block w-full truncate">{label.name}</span>
781
+ </Tooltip>
782
+ <span className="shrink-0 tabular-nums text-theme-text-tertiary">({label.count})</span>
783
+ </button>
784
+ )
785
+ })}
786
+ {filtered.length === 0 && (
787
+ <div className="px-3 py-2 text-xs text-theme-text-tertiary">No labels match.</div>
788
+ )}
789
+ </div>
790
+ </div>
791
+ )}
792
+ </div>
793
+ )
794
+ }
795
+
796
+ function StatusDistribution({ rows }: { rows: GitOpsRow[] }) {
797
+ const summary = summarizeGitOpsRows(rows)
798
+ const total = rows.length || 1
799
+ const segments = [
800
+ { key: 'healthy', value: summary.healthy, className: 'bg-emerald-500' },
801
+ { key: 'progressing', value: summary.progressing, className: 'bg-sky-500' },
802
+ { key: 'degraded', value: summary.degraded, className: 'bg-red-500' },
803
+ { key: 'outOfSync', value: summary.outOfSync, className: 'bg-amber-500' },
804
+ { key: 'unknown', value: Math.max(0, rows.length - summary.healthy - summary.progressing - summary.degraded), className: 'bg-theme-text-tertiary/40' },
805
+ ].filter((segment) => segment.value > 0)
806
+ return (
807
+ <div className="h-2 overflow-hidden rounded-full bg-theme-elevated">
808
+ <div className="flex h-full w-full">
809
+ {segments.map((segment) => (
810
+ <div
811
+ key={segment.key}
812
+ className={segment.className}
813
+ style={{ width: `${Math.max(1, (segment.value / total) * 100)}%` }}
814
+ />
815
+ ))}
816
+ </div>
817
+ </div>
818
+ )
819
+ }
820
+
821
+ function GitOpsTable({ rows, onOpen }: { rows: GitOpsRow[]; onOpen: (row: GitOpsRow) => void }) {
822
+ return (
823
+ <table className="w-full min-w-[1040px] table-fixed border-separate border-spacing-0 text-sm">
824
+ <thead className="sticky top-0 z-10 bg-theme-surface">
825
+ <tr className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
826
+ <TableHead className="w-[24%]">Application</TableHead>
827
+ <TableHead className="w-[9%]">Project</TableHead>
828
+ <TableHead className="w-[9%]">Sync</TableHead>
829
+ <TableHead className="w-[9%]">Health</TableHead>
830
+ <TableHead className="w-[22%]">Source</TableHead>
831
+ <TableHead className="w-[15%]">Destination</TableHead>
832
+ <TableHead className="w-[12%]">Last Sync</TableHead>
833
+ </tr>
834
+ </thead>
835
+ <tbody>
836
+ {rows.map((row) => (
837
+ <tr
838
+ key={row.id}
839
+ onClick={() => onOpen(row)}
840
+ // Subtle row-level fade for Terminating to reinforce the
841
+ // "this is on its way out" reading; the orange status stripe
842
+ // + chip are the primary lifecycle indicators, this is just
843
+ // weight tuning so a row of 5 zombies doesn't shout the same
844
+ // visual weight as 5 active applications.
845
+ className={clsx(
846
+ 'cursor-pointer border-b border-theme-border bg-theme-base hover:bg-theme-hover',
847
+ row.terminating && 'opacity-70',
848
+ )}
849
+ >
850
+ <TableCell>
851
+ <div className="flex min-w-0 items-center gap-2">
852
+ <span className={`h-8 w-1 shrink-0 rounded-full ${statusStripe(row)}`} />
853
+ {/* Terminating chip moves to the leftmost slot (before the
854
+ name) so it's the first thing the eye lands on when
855
+ scanning. Previously it sat after the name where it
856
+ competed with status badges for attention. */}
857
+ {row.terminating && (
858
+ <Tooltip content="Pending deletion — finalizers still running">
859
+ <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">
860
+ <Trash2 className="h-3 w-3" />
861
+ Terminating
862
+ </span>
863
+ </Tooltip>
864
+ )}
865
+ <div className="min-w-0">
866
+ <div className="truncate font-medium text-theme-text-primary">{row.name}</div>
867
+ <div className="truncate text-xs text-theme-text-tertiary">{row.tool === 'argo' ? 'ArgoCD' : 'FluxCD'} {row.kind}</div>
868
+ </div>
869
+ </div>
870
+ </TableCell>
871
+ <TableCell>{row.project || '-'}</TableCell>
872
+ {/* Sync / Health cells: when row is Terminating, the controller
873
+ isn't reconciling and the badges reflect frozen pre-deletion
874
+ state. Replace with a muted dash so the row reads as "no
875
+ live status — see Terminating chip for the actual state". */}
876
+ <TableCell>
877
+ {row.terminating
878
+ ? <span className="text-[11px] text-theme-text-tertiary">—</span>
879
+ : <SyncStatusBadge sync={row.sync as any} suspended={row.suspended} />}
880
+ </TableCell>
881
+ <TableCell>
882
+ {row.terminating
883
+ ? <span className="text-[11px] text-theme-text-tertiary">—</span>
884
+ : <HealthStatusBadge health={row.health as any} />}
885
+ </TableCell>
886
+ <TableCell>
887
+ <div className="truncate text-theme-text-primary">{row.repository || row.chart || '-'}</div>
888
+ <div className="truncate text-xs text-theme-text-tertiary">{[row.targetRevision, row.path || row.chart].filter(Boolean).join(' · ') || '-'}</div>
889
+ </TableCell>
890
+ <TableCell>
891
+ <div className="truncate text-theme-text-primary">{row.destination || '-'}</div>
892
+ <div className="truncate text-xs text-theme-text-tertiary">{row.destinationNamespace || row.namespace || '-'}</div>
893
+ </TableCell>
894
+ {/* Last Sync column: for Terminating rows, "33d ago" is stale.
895
+ Show the deletion-pending duration instead, so the time
896
+ column answers the *current* operational question. */}
897
+ <TableCell>
898
+ {row.terminating
899
+ ? <span className="text-orange-400/80">Pending {formatRelative(row.terminationStartedAt ?? '') || 'now'}</span>
900
+ : formatRelative(row.lastSync || row.createdAt)}
901
+ </TableCell>
902
+ </tr>
903
+ ))}
904
+ </tbody>
905
+ </table>
906
+ )
907
+ }
908
+
909
+ function GitOpsTiles({ rows, onOpen }: { rows: GitOpsRow[]; onOpen: (row: GitOpsRow) => void }) {
910
+ return (
911
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-3 p-4">
912
+ {rows.map((row) => (
913
+ <GitOpsTile key={row.id} row={row} onOpen={onOpen} />
914
+ ))}
915
+ </div>
916
+ )
917
+ }
918
+
919
+ // Tier hierarchy: name (primary scan target) > sync/health badges > source +
920
+ // revision + recency (operational answers) > cluster + namespace + project
921
+ // (footer metadata). Critically: never truncate the name. Spacing rhythm 4/8/12
922
+ // to make hierarchy felt, not just sized.
923
+ function GitOpsTile({ row, onOpen }: { row: GitOpsRow; onOpen: (row: GitOpsRow) => void }) {
924
+ const source = compactRepoSource(row.repository || row.chart, row.path || row.chart)
925
+ const revision = row.targetRevision || ''
926
+ const lastSyncRaw = row.lastSync || row.createdAt
927
+ const recencyClass = recencyTone(lastSyncRaw)
928
+ const dest = row.destination ? compactClusterURL(row.destination) : ''
929
+ const ns = row.destinationNamespace || row.namespace
930
+ return (
931
+ <button
932
+ type="button"
933
+ onClick={() => onOpen(row)}
934
+ className={clsx(
935
+ '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',
936
+ row.terminating && 'opacity-80',
937
+ )}
938
+ >
939
+ {/* Top accent strip — sync-state color, sole color above the badge row */}
940
+ <div className={clsx('h-1 w-full', statusStripe(row))} />
941
+ <div className="flex flex-1 flex-col gap-3 px-4 pb-4 pt-3">
942
+ {/* Tier 1 — name. Wrap up to 2 lines, then break-words to avoid clipping. */}
943
+ <div className="line-clamp-2 break-all text-[15px] font-semibold leading-tight text-theme-text-primary">
944
+ {row.name}
945
+ </div>
946
+ {/* Tier 2 — lifecycle dominates when Terminating; otherwise sync + health.
947
+ Suppressing sync/health for Terminating tiles avoids the same
948
+ stale-state contradiction we removed from the detail title row. */}
949
+ <div className="flex flex-wrap gap-1.5">
950
+ {row.terminating ? (
951
+ <span className="badge border border-orange-500/40 bg-orange-500/15 text-orange-400" title="Pending deletion — finalizers still running">
952
+ <Trash2 className="h-3 w-3" />
953
+ Terminating
954
+ </span>
955
+ ) : (
956
+ <>
957
+ <SyncStatusBadge sync={row.sync as any} suspended={row.suspended} />
958
+ <HealthStatusBadge health={row.health as any} />
959
+ </>
960
+ )}
961
+ </div>
962
+ {/* Tier 3 — source / revision / recency. The operational answers. */}
963
+ <div className="flex flex-col gap-1 text-[12px]">
964
+ {source && (
965
+ <div className="truncate text-theme-text-secondary">{source}</div>
966
+ )}
967
+ {revision && (
968
+ <div className="truncate font-mono text-[11px] text-theme-text-tertiary">{shortRevision(revision)}</div>
969
+ )}
970
+ {row.terminating ? (
971
+ <div className="font-medium text-orange-400/80">Pending {formatRelative(row.terminationStartedAt ?? '') || 'now'}</div>
972
+ ) : (
973
+ lastSyncRaw && <div className={clsx('font-medium', recencyClass)}>{formatRelative(lastSyncRaw)}</div>
974
+ )}
975
+ </div>
976
+ {/* Tier 4 — footer chips. Quiet, but reachable. */}
977
+ {(dest || ns || row.project) && (
978
+ <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">
979
+ {dest && <span className="truncate" title={row.destination}>{dest}</span>}
980
+ {dest && ns && <span aria-hidden>·</span>}
981
+ {ns && <span className="truncate">{ns}</span>}
982
+ {row.project && row.project !== 'default' && (
983
+ <>
984
+ <span aria-hidden>·</span>
985
+ <span className="truncate">{row.project}</span>
986
+ </>
987
+ )}
988
+ </div>
989
+ )}
990
+ </div>
991
+ </button>
992
+ )
993
+ }
994
+
995
+ // Render the source as `org/repo · path` instead of full URL. Keep `.git`
996
+ // off, drop scheme + host. Falls back to whatever's there if it doesn't
997
+ // parse as a github-style URL — Helm chart repos and bare hostnames just
998
+ // pass through.
999
+ function compactRepoSource(repo: string, path: string): string {
1000
+ if (!repo) return ''
1001
+ let head = repo.replace(/^https?:\/\//, '').replace(/\.git$/, '')
1002
+ // Strip well-known SaaS hosts so the org/repo part dominates
1003
+ head = head.replace(/^(github\.com|gitlab\.com|bitbucket\.org)\//, '')
1004
+ return path ? `${head} · ${path}` : head
1005
+ }
1006
+
1007
+ // Drop common Kubernetes service URL prefixes so cluster destinations show
1008
+ // as a recognizable label, not a verbose service URL the user has to parse.
1009
+ function compactClusterURL(dest: string): string {
1010
+ return dest
1011
+ .replace(/^https?:\/\//, '')
1012
+ .replace(/^kubernetes\.default\.svc(:\d+)?\/?$/, 'in-cluster')
1013
+ }
1014
+
1015
+ function shortRevision(rev: string): string {
1016
+ // Already short? Pass through (tags, branch names like "HEAD", short SHAs)
1017
+ if (rev.length <= 12) return rev
1018
+ // Long SHA → 7 chars (git default short)
1019
+ if (/^[0-9a-f]{12,}$/i.test(rev)) return rev.slice(0, 7)
1020
+ return rev
1021
+ }
1022
+
1023
+ // Color the relative time so a quick glance answers "fresh / stale / old".
1024
+ // Thresholds intentionally generous: <10m green, <1d default, >7d amber.
1025
+ // Most production apps reconcile within minutes; >7d signals drift or a
1026
+ // disabled sync controller.
1027
+ function recencyTone(value: string): string {
1028
+ if (!value) return 'text-theme-text-tertiary'
1029
+ const time = Date.parse(value)
1030
+ if (!Number.isFinite(time)) return 'text-theme-text-tertiary'
1031
+ const diffMs = Date.now() - time
1032
+ if (diffMs < 10 * 60_000) return 'text-emerald-600 dark:text-emerald-400'
1033
+ if (diffMs > 7 * 24 * 60 * 60_000) return 'text-amber-600 dark:text-amber-400'
1034
+ return 'text-theme-text-secondary'
1035
+ }
1036
+
1037
+ function TableHead({ children, className = '' }: { children: ReactNode; className?: string }) {
1038
+ return <th className={`border-b border-theme-border px-3 py-2 font-medium ${className}`}>{children}</th>
1039
+ }
1040
+
1041
+ function TableCell({ children }: { children: ReactNode }) {
1042
+ return <td className="border-b border-theme-border px-3 py-2 align-middle text-theme-text-secondary">{children}</td>
1043
+ }
1044
+
1045
+ // Three top-level views per detail page:
1046
+ // topology — the resource tree, with an internal graph/table toggle since
1047
+ // both views share the same filter rail and dataset
1048
+ // changes — drift between desired and live state
1049
+ // activity — current operation, history, diagnosis
1050
+ type GitOpsAppView = 'topology' | 'changes' | 'activity'
1051
+
1052
+ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
1053
+ const location = useLocation()
1054
+ const navigate = useNavigate()
1055
+ const { showError, showSuccess } = useToast()
1056
+ const parts = location.pathname.split('/').filter(Boolean)
1057
+ const kind = parts[2] || 'applications'
1058
+ const namespace = parts[3] === '_' ? '' : decodePathPart(parts[3] || '')
1059
+ const name = decodePathPart(parts[4] || '')
1060
+ const group = new URLSearchParams(location.search).get('apiGroup') || (KIND_BY_NAME.get(kind)?.group ?? '')
1061
+ const apiKind = KIND_BY_NAME.get(kind)
1062
+ // Parent lineage from the ?from=kind|namespace|name query param. Set by
1063
+ // openResourceFromTree when the user clicks a child GitOps node from a
1064
+ // parent's graph. Renders an extra breadcrumb segment + "↑ Open parent"
1065
+ // button so the user always knows where they came from. Falls back to
1066
+ // null (no breadcrumb) for direct/deep links.
1067
+ const parent = useMemo<{ kind: string; namespace: string; name: string; group: string } | null>(() => {
1068
+ const raw = new URLSearchParams(location.search).get('from')
1069
+ if (!raw) return null
1070
+ const [pKind = '', pNs = '', pName = ''] = raw.split('|')
1071
+ if (!pKind || !pName) return null
1072
+ return {
1073
+ kind: pKind,
1074
+ namespace: pNs,
1075
+ name: pName,
1076
+ group: KIND_BY_NAME.get(pKind)?.group ?? '',
1077
+ }
1078
+ }, [location.search])
1079
+
1080
+ const resourceQ = useResource<any>(kind, namespace, name, group)
1081
+ const treeQ = useGitOpsTree(kind, namespace, name, group, namespaces)
1082
+ const insightsQ = useGitOpsInsights(kind, namespace, name, group, namespaces)
1083
+ const status = resourceQ.data ? getGitOpsStatus(kind, resourceQ.data) : null
1084
+ const tool = getTool(kind, group)
1085
+ // Argo "auto-sync ON" is determined by spec.syncPolicy.automated being set,
1086
+ // not by health.status === Suspended (which is Argo's CronJob-style suspend).
1087
+ // The toggle button reads from this so the label flips correctly when an
1088
+ // app is in Manual mode or suspended via Radar's annotations.
1089
+ const argoAutoSyncEnabled = kind === 'applications' && Boolean(resourceQ.data?.spec?.syncPolicy?.automated)
1090
+ // Radar-driven Argo suspension is signaled by annotations that record the
1091
+ // pre-suspend prune/selfHeal state for restoration on resume. When present,
1092
+ // the app is in a deliberately-paused state (vs. Manual mode, which is a
1093
+ // normal operational choice) and should surface a Suspended chip alongside
1094
+ // the other status indicators.
1095
+ const argoSuspendedByRadar =
1096
+ kind === 'applications' &&
1097
+ Boolean(
1098
+ resourceQ.data?.metadata?.annotations?.['radarhq.io/suspended-prune'] ||
1099
+ resourceQ.data?.metadata?.annotations?.['radarhq.io/suspended-selfheal'] ||
1100
+ resourceQ.data?.metadata?.annotations?.['skyhook.io/suspended-prune'] ||
1101
+ resourceQ.data?.metadata?.annotations?.['skyhook.io/suspended-selfheal'],
1102
+ )
1103
+ const effectiveSuspended = (status?.suspended ?? false) || argoSuspendedByRadar
1104
+ // Lifecycle gate: when the resource is pending deletion, mutating
1105
+ // actions are futile (the controller is processing finalizers and
1106
+ // ignores reconcile/sync triggers). Surface it visually + disable
1107
+ // the affected buttons. Read-style verbs (Refresh, Hard refresh,
1108
+ // Terminate) intentionally remain enabled — see the corresponding
1109
+ // carve-out in pkg/gitops/operations.go.
1110
+ const terminating = !!insightsQ.data?.summary?.terminating
1111
+ const terminatingDescriptions = describeTerminating(insightsQ.data?.summary)
1112
+ const terminatingChipTooltip = terminatingDescriptions.chipTooltip
1113
+ const terminatingActionTooltip = terminatingDescriptions.actionDisabledTooltip
1114
+ const [appView, setAppView] = useState<GitOpsAppView>('topology')
1115
+ // When the user clicks an actionable issue alert ("OutOfSync — NodePool
1116
+ // default is out of sync · View →"), we navigate to Changes and focus
1117
+ // that resource. The ref is stringified to a stable key so GitOpsChangesView
1118
+ // can find and scroll it; cleared after a few seconds so the highlight
1119
+ // doesn't persist past its purpose.
1120
+ const [changesFocusKey, setChangesFocusKey] = useState<string | null>(null)
1121
+ const [graphPreset, setGraphPreset] = useState<GitOpsTreePreset>('compact')
1122
+ const [graphSearch, setGraphSearch] = useState('')
1123
+ const [graphKinds, setGraphKinds] = useState<Set<string>>(new Set())
1124
+ const [graphSync, setGraphSync] = useState<Set<string>>(new Set())
1125
+ const [graphHealth, setGraphHealth] = useState<Set<string>>(new Set())
1126
+ const [graphNamespaces, setGraphNamespaces] = useState<Set<string>>(new Set())
1127
+ const [graphRoles, setGraphRoles] = useState<Set<string>>(new Set())
1128
+ const [graphFullscreen, setGraphFullscreen] = useState(false)
1129
+ const [helmValuesOpen, setHelmValuesOpen] = useState(false)
1130
+
1131
+ const argoSync = useArgoSync()
1132
+ const argoRefresh = useArgoRefresh()
1133
+ const argoTerminate = useArgoTerminate()
1134
+ const argoSuspend = useArgoSuspend()
1135
+ const argoResume = useArgoResume()
1136
+ const argoRollback = useArgoRollback()
1137
+ const applyResource = useApplyResource()
1138
+ const fluxReconcile = useFluxReconcile()
1139
+ const fluxSyncWithSource = useFluxSyncWithSource()
1140
+ const fluxSuspend = useFluxSuspend()
1141
+ const fluxResume = useFluxResume()
1142
+
1143
+ const [syncDialogOpen, setSyncDialogOpen] = useState(false)
1144
+ // Doubles as the "open" flag (truthy = dialog open) and the data carrier
1145
+ // for which history entry to roll back to.
1146
+ const [rollbackTarget, setRollbackTarget] = useState<GitOpsHistoryItem | null>(null)
1147
+ // Disambiguates which refresh button is in flight (both share argoRefresh).
1148
+ const [refreshKind, setRefreshKind] = useState<'normal' | 'hard'>('normal')
1149
+
1150
+ const detailRow = resourceQ.data ? normalizeDetailResource(kind, group, resourceQ.data) : null
1151
+ const tree = treeQ.data ?? null
1152
+ const helmValues = useMemo(() => extractHelmValues(kind, resourceQ.data), [kind, resourceQ.data])
1153
+ const graphFilters = useMemo<GitOpsTreeFilters>(() => ({
1154
+ kinds: graphKinds,
1155
+ sync: graphSync,
1156
+ health: graphHealth,
1157
+ namespaces: graphNamespaces,
1158
+ roles: graphRoles,
1159
+ }), [graphHealth, graphKinds, graphNamespaces, graphRoles, graphSync])
1160
+ const graphFacets = useMemo(() => buildTreeFacets(tree), [tree])
1161
+
1162
+ function openResourceFromTree(ref: GitOpsTreeRef | GitOpsInsightRef) {
1163
+ if (isGitOpsDetailRef(ref) && isValidKubernetesName(ref.name)) {
1164
+ const detailKind = kindToPlural(ref.kind)
1165
+ const params = new URLSearchParams()
1166
+ if (ref.group) params.set('apiGroup', ref.group)
1167
+ // Lineage breadcrumb support: when the user opens a child GitOps CR
1168
+ // from inside a parent's tree, encode the parent into the URL so
1169
+ // the child page can render "GitOps / parent / child" + "↑ Open
1170
+ // parent" affordance. Encoded as kind|namespace|name (a single
1171
+ // "from" param keeps the URL short; multi-level lineage isn't
1172
+ // supported here yet — the deepest valid breadcrumb is parent →
1173
+ // child. Going further would need either a chain encoding or
1174
+ // history-state walking, both deferred until the use case shows up).
1175
+ const fromKind = apiKind?.name ?? kind
1176
+ if (fromKind && name) {
1177
+ params.set('from', `${fromKind}|${namespace || ''}|${name}`)
1178
+ }
1179
+ navigate({ pathname: gitOpsDetailPath(detailKind, ref.namespace || '_', ref.name), search: params.toString() })
1180
+ return
1181
+ }
1182
+ onOpenResource({ kind: kindToPlural(ref.kind), namespace: ref.namespace || '', name: ref.name, group: ref.group })
1183
+ }
1184
+
1185
+ const isRunning = resourceQ.data?.status?.operationState?.phase === 'Running'
1186
+ const isFluxWorkload = kind === 'kustomizations' || kind === 'helmreleases'
1187
+ const isFlux = tool === 'flux'
1188
+ const isArgoApp = kind === 'applications'
1189
+ const graphShellClass = graphFullscreen
1190
+ ? 'fixed inset-0 z-[80] flex min-h-0 min-w-0 flex-col bg-theme-base'
1191
+ : 'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden'
1192
+
1193
+ // Set the browser tab title so users with multiple resource tabs open can
1194
+ // tell which is which without focusing each tab. Restore on unmount so a
1195
+ // stray "Radar — argocd/foo" doesn't outlive its page.
1196
+ useEffect(() => {
1197
+ const previous = document.title
1198
+ document.title = `${name} — Radar`
1199
+ return () => { document.title = previous }
1200
+ }, [name])
1201
+
1202
+ // Detail-page shortcuts. Skip when a modal is already open so a stray "s"
1203
+ // in an input field doesn't pop another sync dialog.
1204
+ const shortcutsEnabled = !syncDialogOpen && !rollbackTarget
1205
+ useRegisterShortcut({
1206
+ id: 'gitops-detail-sync',
1207
+ keys: 's',
1208
+ description: isArgoApp ? 'Open sync options' : 'Reconcile',
1209
+ category: 'GitOps',
1210
+ scope: 'gitops',
1211
+ handler: () => {
1212
+ if (effectiveSuspended || terminating) return
1213
+ if (isArgoApp) setSyncDialogOpen(true)
1214
+ else if (isFlux) fluxReconcile.mutate({ kind, namespace, name })
1215
+ },
1216
+ enabled: shortcutsEnabled && (isArgoApp || isFlux) && !effectiveSuspended && !terminating,
1217
+ })
1218
+ useRegisterShortcut({
1219
+ id: 'gitops-detail-refresh',
1220
+ keys: 'r',
1221
+ description: 'Refresh application',
1222
+ category: 'GitOps',
1223
+ scope: 'gitops',
1224
+ handler: () => {
1225
+ if (!isArgoApp) return
1226
+ setRefreshKind('normal')
1227
+ argoRefresh.mutate({ namespace, name, hard: false })
1228
+ },
1229
+ enabled: shortcutsEnabled && isArgoApp,
1230
+ })
1231
+ useRegisterShortcut({
1232
+ id: 'gitops-detail-hard-refresh',
1233
+ keys: 'Shift+R',
1234
+ description: 'Hard refresh (re-resolve source from Git)',
1235
+ category: 'GitOps',
1236
+ scope: 'gitops',
1237
+ handler: () => {
1238
+ if (!isArgoApp) return
1239
+ setRefreshKind('hard')
1240
+ argoRefresh.mutate({ namespace, name, hard: true })
1241
+ },
1242
+ enabled: shortcutsEnabled && isArgoApp,
1243
+ })
1244
+ useRegisterShortcut({
1245
+ id: 'gitops-detail-terminate',
1246
+ keys: 't',
1247
+ description: 'Terminate running sync',
1248
+ category: 'GitOps',
1249
+ scope: 'gitops',
1250
+ handler: () => {
1251
+ if (isArgoApp && isRunning) argoTerminate.mutate({ namespace, name })
1252
+ },
1253
+ enabled: shortcutsEnabled && isArgoApp && isRunning,
1254
+ })
1255
+
1256
+ return (
1257
+ <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-theme-base">
1258
+ {!graphFullscreen && <div className="shrink-0 border-b border-theme-border bg-theme-base px-4 py-3">
1259
+ <div className="flex flex-wrap items-start justify-between gap-3">
1260
+ <div className="min-w-0">
1261
+ {/* Breadcrumb collapses into the title row: parent ("GitOps") +
1262
+ resource name on one line. Tool + Kind are *properties* of
1263
+ this resource, not navigation, so they live as a neutral
1264
+ chip alongside the status badges instead of as breadcrumb
1265
+ segments. Future nested cases (app-of-apps) can extend the
1266
+ breadcrumb with intermediate parent links here. */}
1267
+ <div className="flex flex-wrap items-center gap-2">
1268
+ <button
1269
+ type="button"
1270
+ onClick={() => navigate('/gitops')}
1271
+ className="shrink-0 text-xs font-medium text-sky-500 transition-colors hover:text-sky-400"
1272
+ >
1273
+ GitOps
1274
+ </button>
1275
+ <span className="shrink-0 text-xs text-theme-text-tertiary">/</span>
1276
+ {/* Parent breadcrumb segment when the user opened this CR
1277
+ from inside another CR's tree (app-of-apps, Flux
1278
+ Kustomization-applies-Kustomization, etc). Without this,
1279
+ navigation between nested GitOps surfaces feels like
1280
+ unrelated jumps; with it, the lineage is always visible
1281
+ and clickable. The segment renders smaller than the
1282
+ current title to avoid competing for visual weight. */}
1283
+ {parent && (
1284
+ <>
1285
+ <button
1286
+ type="button"
1287
+ onClick={() => {
1288
+ const params = new URLSearchParams()
1289
+ if (parent.group) params.set('apiGroup', parent.group)
1290
+ navigate({
1291
+ pathname: gitOpsDetailPath(parent.kind, parent.namespace || '_', parent.name),
1292
+ search: params.toString(),
1293
+ })
1294
+ }}
1295
+ className="shrink-0 truncate max-w-[200px] text-xs font-medium text-sky-500 transition-colors hover:text-sky-400"
1296
+ title={`Open parent: ${parent.namespace ? `${parent.namespace}/` : ''}${parent.name}`}
1297
+ >
1298
+ {parent.namespace ? `${parent.namespace}/` : ''}{parent.name}
1299
+ </button>
1300
+ <span className="shrink-0 text-xs text-theme-text-tertiary">/</span>
1301
+ </>
1302
+ )}
1303
+ <h1 className="min-w-0 truncate text-lg font-semibold text-theme-text-primary">
1304
+ {namespace ? `${namespace}/` : ''}{name}
1305
+ </h1>
1306
+ {/* When Terminating, suppress Sync/Health badges entirely.
1307
+ They reflect the last observed reconcile state and become
1308
+ factually stale the moment deletion is initiated — Flux
1309
+ is processing finalizers, not syncing, and the resource
1310
+ isn't "Progressing" toward a healthy state, it's being
1311
+ torn down. Showing "Syncing · Progressing · Terminating"
1312
+ side-by-side is contradictory and actively misleading;
1313
+ Argo CD's own UI suppresses these badges for the same
1314
+ reason. The Terminating chip becomes the sole status
1315
+ indicator. */}
1316
+ {status && !terminating && (
1317
+ <>
1318
+ <SyncStatusBadge sync={status.sync} suspended={effectiveSuspended} />
1319
+ {!effectiveSuspended && <HealthStatusBadge health={status.health} />}
1320
+ </>
1321
+ )}
1322
+ {terminating && (
1323
+ <Tooltip content={terminatingChipTooltip}>
1324
+ <span className="badge border border-orange-500/40 bg-orange-500/10 text-orange-400">
1325
+ <Trash2 className="h-3 w-3" />
1326
+ Terminating
1327
+ </span>
1328
+ </Tooltip>
1329
+ )}
1330
+ <span className="inline-flex shrink-0 items-center rounded border border-theme-border bg-theme-hover/50 px-1.5 py-0.5 text-[11px] font-medium text-theme-text-secondary">
1331
+ {tool === 'argo' ? 'ArgoCD' : 'FluxCD'} · {apiKind?.kind ?? kind}
1332
+ </span>
1333
+ </div>
1334
+ {/* Header carries the *spec/config* facts — where this app lives
1335
+ and how it syncs. The deployment row (status strip below)
1336
+ carries dynamic per-deploy facts (latest revision, age,
1337
+ resource health). Splitting static config from live state
1338
+ avoids the "I changed nothing but everything looks different"
1339
+ effect of mixing them. */}
1340
+ <div className="mt-2 flex flex-wrap gap-x-5 gap-y-0.5 text-[11px] text-theme-text-tertiary">
1341
+ <AppFact label="Project" value={detailRow?.project || '-'} />
1342
+ {detailRow?.repository && <AppFact label="Source" value={formatSourceRepo(detailRow.repository)} />}
1343
+ {detailRow?.path && <AppFact label="Path" value={detailRow.path} />}
1344
+ {detailRow?.chart && <AppFact label="Chart" value={detailRow.chart} />}
1345
+ <AppFact label="Destination" value={formatDestination(detailRow?.destination, detailRow?.destinationNamespace)} />
1346
+ {insightsQ.data?.summary?.autoSyncMode && (
1347
+ <AppFact label="Sync mode" value={insightsQ.data.summary.autoSyncMode} />
1348
+ )}
1349
+ </div>
1350
+ </div>
1351
+ <div className="flex flex-wrap items-center gap-2">
1352
+ {isArgoApp && (
1353
+ <>
1354
+ <ActionButton label="Sync…" description="Apply manifests from Git to the cluster. Opens an options dialog (prune, dry-run, revision)." icon={RefreshCw} loading={argoSync.isPending} onClick={() => setSyncDialogOpen(true)} disabled={effectiveSuspended || terminating} disabledReason={terminating ? terminatingActionTooltip : undefined} primary />
1355
+ <ActionButton
1356
+ label="Refresh"
1357
+ description="Re-check Git for new commits and recompute sync status. Doesn't apply anything."
1358
+ icon={RotateCw}
1359
+ loading={argoRefresh.isPending && refreshKind === 'normal'}
1360
+ onClick={() => {
1361
+ setRefreshKind('normal')
1362
+ argoRefresh.mutate({ namespace, name, hard: false })
1363
+ }}
1364
+ />
1365
+ <ActionButton
1366
+ label="Hard refresh"
1367
+ description="Like Refresh, but also bypasses Argo's manifest cache (re-renders Helm/Kustomize)."
1368
+ icon={RotateCw}
1369
+ loading={argoRefresh.isPending && refreshKind === 'hard'}
1370
+ onClick={() => {
1371
+ setRefreshKind('hard')
1372
+ argoRefresh.mutate({ namespace, name, hard: true })
1373
+ }}
1374
+ />
1375
+ {isRunning && <ActionButton label="Terminate" description="Cancel the in-progress sync operation." icon={XCircle} loading={argoTerminate.isPending} onClick={() => argoTerminate.mutate({ namespace, name })} danger />}
1376
+ {argoAutoSyncEnabled
1377
+ ? <ActionButton label="Disable auto-sync" description="Stop Argo from automatically syncing Git changes. Manual Sync still works." icon={Pause} loading={argoSuspend.isPending} onClick={() => argoSuspend.mutate({ namespace, name })} disabled={terminating} disabledReason={terminating ? terminatingActionTooltip : undefined} />
1378
+ : <ActionButton label="Enable auto-sync" description="Re-enable automatic syncing of Git changes to the cluster." icon={Play} loading={argoResume.isPending} onClick={() => argoResume.mutate({ namespace, name })} disabled={terminating} disabledReason={terminating ? terminatingActionTooltip : undefined} />}
1379
+ </>
1380
+ )}
1381
+ {isFlux && (
1382
+ <>
1383
+ <ActionButton label="Reconcile" description="Tell Flux to reconcile this resource now: re-read its source, re-apply the manifests, update status. Skips waiting for the regular reconciliation interval." icon={RefreshCw} loading={fluxReconcile.isPending} onClick={() => fluxReconcile.mutate({ kind, namespace, name })} disabled={effectiveSuspended || terminating} disabledReason={terminating ? terminatingActionTooltip : undefined} primary />
1384
+ {isFluxWorkload && (
1385
+ <ActionButton
1386
+ label="Sync with source"
1387
+ description="Reconcile the upstream source CR (GitRepository/HelmRepository) first — re-fetching from Git or Helm — then reconcile this resource against the refreshed source. Useful right after pushing a commit when you don't want to wait for the source's poll interval."
1388
+ icon={GitCommit}
1389
+ loading={fluxSyncWithSource.isPending}
1390
+ onClick={() => fluxSyncWithSource.mutate({ kind, namespace, name })}
1391
+ disabled={terminating}
1392
+ disabledReason={terminating ? terminatingActionTooltip : undefined}
1393
+ />
1394
+ )}
1395
+ {status?.suspended
1396
+ ? <ActionButton label="Resume" description="Resume Flux reconciliation. Flux will start applying changes from the source again on its normal interval." icon={Play} loading={fluxResume.isPending} onClick={() => fluxResume.mutate({ kind, namespace, name })} disabled={terminating} disabledReason={terminating ? terminatingActionTooltip : undefined} />
1397
+ : <ActionButton label="Suspend" description="Pause Flux reconciliation. The resource stays exactly as-is — new commits in the source won't be applied until you resume." icon={Pause} loading={fluxSuspend.isPending} onClick={() => fluxSuspend.mutate({ kind, namespace, name })} disabled={terminating} disabledReason={terminating ? terminatingActionTooltip : undefined} />}
1398
+ </>
1399
+ )}
1400
+ </div>
1401
+ </div>
1402
+ </div>}
1403
+ {!graphFullscreen && (
1404
+ <>
1405
+ <GitOpsStatusStrip insight={insightsQ.data} loading={insightsQ.isLoading} />
1406
+ <GitOpsIssuesBand
1407
+ issues={insightsQ.data?.issues}
1408
+ terminating={terminating}
1409
+ onSelectIssue={(issue) => {
1410
+ const ref = issue.refs?.[0]
1411
+ if (!ref) return
1412
+ setAppView('changes')
1413
+ setChangesFocusKey(insightChangeKey(ref))
1414
+ // Window the highlight: 4s is long enough to find the row
1415
+ // visually but short enough that it doesn't linger if the user
1416
+ // navigates away and comes back.
1417
+ window.setTimeout(() => setChangesFocusKey(null), 4000)
1418
+ }}
1419
+ remediationPending={applyResource.isPending || argoSync.isPending}
1420
+ onRemediate={(remediation) => {
1421
+ if (remediation.kind === 'create-namespace' && remediation.target) {
1422
+ const nsName = remediation.target
1423
+ const yaml = `apiVersion: v1\nkind: Namespace\nmetadata:\n name: ${nsName}\n`
1424
+ applyResource.mutate(
1425
+ { yaml, mode: 'apply' },
1426
+ {
1427
+ onSuccess: () => {
1428
+ // Defer the success toast until we know whether the
1429
+ // follow-on sync was triggered, so a successful
1430
+ // create-namespace followed by a sync failure doesn't
1431
+ // produce a "Triggering a sync" toast that argoSync's
1432
+ // own error toast immediately contradicts.
1433
+ if (kind === 'applications') {
1434
+ argoSync.mutate(
1435
+ { namespace, name },
1436
+ {
1437
+ onSuccess: () => {
1438
+ showSuccess(
1439
+ `Created namespace ${nsName}`,
1440
+ 'Sync triggered to retry the apply.',
1441
+ )
1442
+ },
1443
+ onError: () => {
1444
+ // argoSync's mutation meta already raises its
1445
+ // own "Failed to trigger sync" toast; here we
1446
+ // tell the user explicitly that the *namespace*
1447
+ // landed even though the sync didn't, so they
1448
+ // know to click Sync manually.
1449
+ showSuccess(
1450
+ `Created namespace ${nsName}`,
1451
+ "Couldn't trigger sync automatically — click Sync to retry.",
1452
+ )
1453
+ },
1454
+ },
1455
+ )
1456
+ } else {
1457
+ // Non-Argo callers don't get the auto-sync chain; the
1458
+ // create-namespace landing is the only thing to report.
1459
+ showSuccess(`Created namespace ${nsName}`)
1460
+ }
1461
+ },
1462
+ onError: (err: unknown) => {
1463
+ const msg = err instanceof Error ? err.message : 'Unknown error'
1464
+ showError(
1465
+ `Couldn't create namespace ${nsName}`,
1466
+ msg.includes('forbidden')
1467
+ ? 'Radar lacks RBAC to create namespaces in this cluster. Create it manually or have a cluster-admin do it.'
1468
+ : msg,
1469
+ )
1470
+ },
1471
+ },
1472
+ )
1473
+ }
1474
+ }}
1475
+ />
1476
+ {helmValues && (
1477
+ <div className="shrink-0 border-b border-theme-border bg-theme-base">
1478
+ <button
1479
+ type="button"
1480
+ onClick={() => setHelmValuesOpen((v) => !v)}
1481
+ className="flex w-full items-center gap-2 px-4 py-2 text-left text-xs text-theme-text-secondary hover:bg-theme-hover"
1482
+ aria-expanded={helmValuesOpen}
1483
+ >
1484
+ {helmValuesOpen ? (
1485
+ <ChevronDown className="h-3.5 w-3.5 shrink-0" />
1486
+ ) : (
1487
+ <ChevronRight className="h-3.5 w-3.5 shrink-0" />
1488
+ )}
1489
+ <Settings className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
1490
+ <span className="font-medium text-theme-text-primary">Helm values</span>
1491
+ <span className="tabular-nums text-theme-text-tertiary">
1492
+ {helmValues.keyCount} {helmValues.keyCount === 1 ? 'key' : 'keys'}
1493
+ </span>
1494
+ {helmValues.source === 'argo-parameters' && (
1495
+ <span className="text-[10px] uppercase tracking-wider text-theme-text-tertiary">parameters</span>
1496
+ )}
1497
+ </button>
1498
+ {helmValuesOpen && (
1499
+ <div className="border-t border-theme-border bg-theme-surface px-4 py-3">
1500
+ <CodeViewer code={helmValues.yaml} language="yaml" showLineNumbers maxHeight="320px" />
1501
+ </div>
1502
+ )}
1503
+ </div>
1504
+ )}
1505
+ </>
1506
+ )}
1507
+
1508
+ {resourceQ.isLoading ? (
1509
+ <div className="flex flex-1 items-center justify-center text-theme-text-secondary">
1510
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading GitOps resource…
1511
+ </div>
1512
+ ) : resourceQ.error ? (
1513
+ <div className="p-4 text-sm text-red-500">Failed to load resource: {(resourceQ.error as Error).message}</div>
1514
+ ) : (
1515
+ <div className={graphShellClass}>
1516
+ <div className="flex shrink-0 items-center justify-between gap-3 border-b border-theme-border bg-theme-base px-4 py-2">
1517
+ <div className="flex items-center gap-1 rounded-md border border-theme-border bg-theme-surface p-1">
1518
+ <ViewButton active={appView === 'topology'} icon={GitBranch} label="Topology" onClick={() => setAppView('topology')} />
1519
+ <ViewButton active={appView === 'changes'} icon={GitCommit} label="Resources" onClick={() => setAppView('changes')} />
1520
+ <ViewButton active={appView === 'activity'} icon={Clock3} label="Activity" onClick={() => setAppView('activity')} />
1521
+ </div>
1522
+ {graphFullscreen ? (
1523
+ <div className="min-w-0 flex-1 truncate text-sm font-medium text-theme-text-primary">
1524
+ {namespace ? `${namespace}/` : ''}{name}
1525
+ </div>
1526
+ ) : (
1527
+ appView === 'topology' && tree && <TopologyCounts tree={tree} />
1528
+ )}
1529
+ <div className="flex items-center gap-2">
1530
+ {appView === 'topology' && (
1531
+ <>
1532
+ <button
1533
+ type="button"
1534
+ onClick={() => {
1535
+ setGraphSearch('')
1536
+ setGraphKinds(new Set())
1537
+ setGraphSync(new Set())
1538
+ setGraphHealth(new Set())
1539
+ setGraphNamespaces(new Set())
1540
+ setGraphRoles(new Set())
1541
+ }}
1542
+ className="rounded px-2 py-1 text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
1543
+ >
1544
+ Clear filters
1545
+ </button>
1546
+ <button
1547
+ type="button"
1548
+ onClick={() => setGraphFullscreen(!graphFullscreen)}
1549
+ className="rounded-md border border-theme-border bg-theme-surface px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
1550
+ >
1551
+ {graphFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
1552
+ </button>
1553
+ </>
1554
+ )}
1555
+ </div>
1556
+ </div>
1557
+
1558
+ {appView === 'activity' ? (
1559
+ <GitOpsActivityInsightView
1560
+ insight={insightsQ.data}
1561
+ error={insightsQ.error as Error | null}
1562
+ // Only Argo apps support rollback. Skip the callback entirely
1563
+ // for entries with non-numeric IDs (Flux conditions reuse the
1564
+ // ID slot for condition.type) so the button doesn't render and
1565
+ // then silently fail when clicked.
1566
+ onRollback={isArgoApp ? (item) => {
1567
+ if (parseRollbackID(item.id) == null) return
1568
+ setRollbackTarget(item)
1569
+ } : undefined}
1570
+ />
1571
+ ) : appView === 'changes' ? (
1572
+ <GitOpsChangesView
1573
+ insight={insightsQ.data}
1574
+ error={insightsQ.error as Error | null}
1575
+ onOpenResource={openResourceFromTree}
1576
+ focusKey={changesFocusKey}
1577
+ tree={tree}
1578
+ />
1579
+ ) : (
1580
+ <div className="grid min-h-0 min-w-0 flex-1 grid-cols-[280px_minmax(0,1fr)] max-lg:grid-cols-1">
1581
+ <GitOpsGraphFilterRail
1582
+ facets={graphFacets}
1583
+ preset={graphPreset}
1584
+ onPresetChange={setGraphPreset}
1585
+ search={graphSearch}
1586
+ onSearchChange={setGraphSearch}
1587
+ kinds={graphKinds}
1588
+ onToggleKind={(value) => toggleSet(graphKinds, setGraphKinds, value)}
1589
+ sync={graphSync}
1590
+ onToggleSync={(value) => toggleSet(graphSync, setGraphSync, value)}
1591
+ health={graphHealth}
1592
+ onToggleHealth={(value) => toggleSet(graphHealth, setGraphHealth, value)}
1593
+ namespaces={graphNamespaces}
1594
+ onToggleNamespace={(value) => toggleSet(graphNamespaces, setGraphNamespaces, value)}
1595
+ roles={graphRoles}
1596
+ onToggleRole={(value) => toggleSet(graphRoles, setGraphRoles, value)}
1597
+ />
1598
+ <div className="min-h-0 min-w-0 border-l border-theme-border max-lg:border-l-0 max-lg:border-t">
1599
+ <GitOpsTreeGraph
1600
+ tree={tree}
1601
+ loading={treeQ.isLoading}
1602
+ error={treeQ.error as Error | null}
1603
+ onNodeClick={openResourceFromTree}
1604
+ preset={graphPreset}
1605
+ onPresetChange={setGraphPreset}
1606
+ query={graphSearch}
1607
+ onQueryChange={setGraphSearch}
1608
+ filters={graphFilters}
1609
+ showToolbar={false}
1610
+ />
1611
+ </div>
1612
+ </div>
1613
+ )}
1614
+ </div>
1615
+ )}
1616
+ {/* Modals — portaled to body, only render the ones for the current tool. */}
1617
+ {isArgoApp && (
1618
+ <>
1619
+ <SyncOptionsDialog
1620
+ open={syncDialogOpen}
1621
+ appLabel={`${namespace}/${name}`}
1622
+ pending={argoSync.isPending}
1623
+ onCancel={() => setSyncDialogOpen(false)}
1624
+ onConfirm={(opts) => {
1625
+ // Close on success only — on failure keep the dialog open so
1626
+ // the user doesn't lose their form context (revision, advanced
1627
+ // toggles). The error toast fires globally via mutation meta.
1628
+ argoSync.mutate({ namespace, name, ...opts }, {
1629
+ onSuccess: () => setSyncDialogOpen(false),
1630
+ })
1631
+ }}
1632
+ />
1633
+ <RollbackDialog
1634
+ open={!!rollbackTarget}
1635
+ appLabel={`${namespace}/${name}`}
1636
+ revision={rollbackTarget?.revision || ''}
1637
+ historyId={rollbackTarget?.id}
1638
+ pending={argoRollback.isPending}
1639
+ onCancel={() => setRollbackTarget(null)}
1640
+ onConfirm={(opts) => {
1641
+ // Defensive: history may have refreshed between modal-open and
1642
+ // confirm, leaving the captured id no longer parseable.
1643
+ const id = parseRollbackID(rollbackTarget?.id)
1644
+ if (id == null) {
1645
+ showError('Rollback target became invalid', 'The history entry changed while the dialog was open. Reselect a target and try again.')
1646
+ setRollbackTarget(null)
1647
+ return
1648
+ }
1649
+ argoRollback.mutate({ namespace, name, id, ...opts }, {
1650
+ onSuccess: () => setRollbackTarget(null),
1651
+ })
1652
+ }}
1653
+ />
1654
+ </>
1655
+ )}
1656
+ </div>
1657
+ )
1658
+ }
1659
+
1660
+ // formatSourceRepo drops the protocol prefix from a Git source URL so the
1661
+ // row reads as "github.com/org/repo" instead of the redundant
1662
+ // "https://github.com/org/repo". Leaves non-https URLs alone so SSH-style
1663
+ // origins (`git@github.com:org/repo`) and HTTP-only on-prem mirrors still
1664
+ // render as the user wrote them.
1665
+ function formatSourceRepo(repo: string): string {
1666
+ return repo.replace(/^https?:\/\//, '')
1667
+ }
1668
+
1669
+ type HelmValuesSource = 'flux' | 'argo-object' | 'argo-string' | 'argo-parameters'
1670
+ interface HelmValuesData {
1671
+ yaml: string
1672
+ keyCount: number
1673
+ source: HelmValuesSource
1674
+ }
1675
+
1676
+ // Both Flux HelmRelease and Argo CD Application-with-Helm-source carry user
1677
+ // overrides for chart values, but spell them differently. We surface them via
1678
+ // a single disclosure on the GitOps detail page; this helper normalizes the
1679
+ // four flavors we may encounter into one renderable shape.
1680
+ function extractHelmValues(kind: string, resource: any): HelmValuesData | null {
1681
+ if (!resource) return null
1682
+ if (kind === 'helmreleases') {
1683
+ const values = resource?.spec?.values
1684
+ if (values && typeof values === 'object' && Object.keys(values).length > 0) {
1685
+ return { yaml: safeStringifyYaml(values), keyCount: Object.keys(values).length, source: 'flux' }
1686
+ }
1687
+ return null
1688
+ }
1689
+ if (kind === 'applications') {
1690
+ const helm = resource?.spec?.source?.helm
1691
+ if (helm?.valuesObject && typeof helm.valuesObject === 'object' && Object.keys(helm.valuesObject).length > 0) {
1692
+ return {
1693
+ yaml: safeStringifyYaml(helm.valuesObject),
1694
+ keyCount: Object.keys(helm.valuesObject).length,
1695
+ source: 'argo-object',
1696
+ }
1697
+ }
1698
+ if (typeof helm?.values === 'string' && helm.values.trim() !== '') {
1699
+ const parsed = tryParseYaml(helm.values)
1700
+ const keyCount = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? Object.keys(parsed).length : 0
1701
+ return { yaml: helm.values, keyCount, source: 'argo-string' }
1702
+ }
1703
+ if (Array.isArray(helm?.parameters) && helm.parameters.length > 0) {
1704
+ const obj: Record<string, unknown> = {}
1705
+ for (const param of helm.parameters) {
1706
+ if (param?.name) obj[param.name] = param.value
1707
+ }
1708
+ if (Object.keys(obj).length === 0) return null
1709
+ return {
1710
+ yaml: safeStringifyYaml(obj),
1711
+ keyCount: Object.keys(obj).length,
1712
+ source: 'argo-parameters',
1713
+ }
1714
+ }
1715
+ }
1716
+ return null
1717
+ }
1718
+
1719
+ function safeStringifyYaml(value: unknown): string {
1720
+ try {
1721
+ return yaml.stringify(value)
1722
+ } catch {
1723
+ return JSON.stringify(value, null, 2)
1724
+ }
1725
+ }
1726
+
1727
+ function tryParseYaml(value: string): unknown {
1728
+ try {
1729
+ return yaml.parse(value)
1730
+ } catch {
1731
+ return null
1732
+ }
1733
+ }
1734
+
1735
+ // formatDestination collapses the canonical in-cluster API server URL
1736
+ // ("https://kubernetes.default.svc" or the variant Argo writes when the
1737
+ // destination is the same cluster the controller runs in) to the friendlier
1738
+ // "in-cluster". Other server values pass through unchanged. The namespace
1739
+ // is appended with an explicit "Namespace:" qualifier so the relationship
1740
+ // reads unambiguously — bare `/ ns` could be mistaken for a sub-path.
1741
+ function formatDestination(server: string | undefined, namespace: string | undefined): string {
1742
+ let host = (server || '').trim()
1743
+ if (host === '' || host === 'https://kubernetes.default.svc' || host === 'in-cluster') {
1744
+ host = 'in-cluster'
1745
+ } else {
1746
+ host = host.replace(/^https?:\/\//, '')
1747
+ }
1748
+ return namespace ? `${host}, Namespace: ${namespace}` : host
1749
+ }
1750
+
1751
+ function AppFact({ label, value }: { label: string; value: string }) {
1752
+ // inline-flex (not flex) so each fact sizes to its content; the parent's
1753
+ // flex-wrap handles row breaks at narrow viewports. max-w-full is the
1754
+ // safety net for the rare case of a single value wider than the screen
1755
+ // (truncate + tooltip kicks in then). Without it, very long destinations
1756
+ // would force the page to scroll horizontally.
1757
+ return (
1758
+ <span className="inline-flex min-w-0 max-w-full items-baseline gap-1">
1759
+ <span className="shrink-0 text-theme-text-tertiary">{label}:</span>
1760
+ <Tooltip content={value} delay={400} wrapperClassName="min-w-0">
1761
+ <span className="block truncate text-theme-text-primary">{value}</span>
1762
+ </Tooltip>
1763
+ </span>
1764
+ )
1765
+ }
1766
+
1767
+ function ViewButton({
1768
+ active,
1769
+ icon: Icon,
1770
+ label,
1771
+ onClick,
1772
+ }: {
1773
+ active: boolean
1774
+ icon: ComponentType<{ className?: string }>
1775
+ label: string
1776
+ onClick: () => void
1777
+ }) {
1778
+ return (
1779
+ <button
1780
+ type="button"
1781
+ onClick={onClick}
1782
+ className={`inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors ${
1783
+ active
1784
+ ? 'bg-skyhook-500 text-white'
1785
+ : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
1786
+ }`}
1787
+ >
1788
+ <Icon className="h-3.5 w-3.5" />
1789
+ {label}
1790
+ </button>
1791
+ )
1792
+ }
1793
+
1794
+ function GitOpsGraphFilterRail({
1795
+ facets,
1796
+ preset,
1797
+ onPresetChange,
1798
+ search,
1799
+ onSearchChange,
1800
+ kinds,
1801
+ onToggleKind,
1802
+ sync,
1803
+ onToggleSync,
1804
+ health,
1805
+ onToggleHealth,
1806
+ namespaces,
1807
+ onToggleNamespace,
1808
+ roles,
1809
+ onToggleRole,
1810
+ }: {
1811
+ facets: ReturnType<typeof buildTreeFacets>
1812
+ preset: GitOpsTreePreset
1813
+ onPresetChange: (preset: GitOpsTreePreset) => void
1814
+ search: string
1815
+ onSearchChange: (value: string) => void
1816
+ kinds: Set<string>
1817
+ onToggleKind: (value: string) => void
1818
+ sync: Set<string>
1819
+ onToggleSync: (value: string) => void
1820
+ health: Set<string>
1821
+ onToggleHealth: (value: string) => void
1822
+ namespaces: Set<string>
1823
+ onToggleNamespace: (value: string) => void
1824
+ roles: Set<string>
1825
+ onToggleRole: (value: string) => void
1826
+ }) {
1827
+ return (
1828
+ <aside className="min-h-0 overflow-y-auto bg-theme-surface/90 max-lg:h-48 max-lg:max-h-48">
1829
+ <div className="border-b border-theme-border px-3 py-3">
1830
+ <div className="relative">
1831
+ <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" />
1832
+ <input
1833
+ value={search}
1834
+ onChange={(event) => onSearchChange(event.target.value)}
1835
+ placeholder="Filter resources..."
1836
+ 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"
1837
+ />
1838
+ </div>
1839
+ </div>
1840
+ <FilterSection icon={GitBranch} title="Graph">
1841
+ <div className="grid grid-cols-2 gap-1">
1842
+ {(['compact', 'workloads', 'app', 'full'] as GitOpsTreePreset[]).map((value) => (
1843
+ <button
1844
+ key={value}
1845
+ type="button"
1846
+ onClick={() => onPresetChange(value)}
1847
+ className={`rounded-md px-2 py-1.5 text-left text-[11px] font-medium transition-colors ${
1848
+ preset === value
1849
+ ? 'bg-skyhook-500 text-white'
1850
+ : 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
1851
+ }`}
1852
+ >
1853
+ {value === 'app' ? 'Declared' : value[0].toUpperCase() + value.slice(1)}
1854
+ </button>
1855
+ ))}
1856
+ </div>
1857
+ </FilterSection>
1858
+ <FilterSection icon={List} title="Kinds">
1859
+ {facets.kinds.slice(0, 14).map((item) => (
1860
+ <FacetButton key={item.name} label={item.name} count={item.count} active={kinds.has(item.name)} onClick={() => onToggleKind(item.name)} />
1861
+ ))}
1862
+ </FilterSection>
1863
+ <FilterSection icon={CheckCircle2} title="Sync">
1864
+ {facets.sync.map((item) => (
1865
+ <FacetButton key={item.name} label={item.name} count={item.count} active={sync.has(item.name)} tone={syncTone(item.name)} onClick={() => onToggleSync(item.name)} />
1866
+ ))}
1867
+ </FilterSection>
1868
+ <FilterSection icon={HeartPulse} title="Health">
1869
+ {facets.health.map((item) => (
1870
+ <FacetButton key={item.name} label={item.name} count={item.count} active={health.has(item.name)} tone={healthTone(item.name)} onClick={() => onToggleHealth(item.name)} />
1871
+ ))}
1872
+ </FilterSection>
1873
+ <FilterSection icon={CircleDot} title="Role">
1874
+ {facets.roles.map((item) => (
1875
+ <FacetButton key={item.name} label={roleLabel(item.name)} count={item.count} active={roles.has(item.name)} onClick={() => onToggleRole(item.name)} />
1876
+ ))}
1877
+ </FilterSection>
1878
+ <FilterSection icon={LayoutGrid} title="Namespaces">
1879
+ {facets.namespaces.slice(0, 12).map((item) => (
1880
+ <FacetButton key={item.name} label={item.name} count={item.count} active={namespaces.has(item.name)} onClick={() => onToggleNamespace(item.name)} />
1881
+ ))}
1882
+ </FilterSection>
1883
+ </aside>
1884
+ )
1885
+ }
1886
+
1887
+ function buildTreeFacets(tree: GitOpsResourceTree | null) {
1888
+ const nodes = tree?.nodes ?? []
1889
+ return {
1890
+ kinds: countValues(nodes.filter((node) => node.role !== 'group').map((node) => node.ref.kind).filter(Boolean)),
1891
+ sync: countValues(nodes.map((node) => node.sync || 'Unknown')),
1892
+ health: countValues(nodes.map((node) => node.health || 'Unknown')),
1893
+ namespaces: countValues(nodes.map((node) => node.ref.namespace || '(cluster)')),
1894
+ roles: countValues(nodes.map((node) => node.role)),
1895
+ }
1896
+ }
1897
+
1898
+ function normalizeDetailResource(kind: string, group: string, resource: any): GitOpsRow | null {
1899
+ if (kind === 'applications') return normalizeArgoApplication(resource)
1900
+ if (kind === 'kustomizations') return normalizeFluxKustomization(resource)
1901
+ if (kind === 'helmreleases') return normalizeFluxHelmRelease(resource)
1902
+ const status = getGitOpsStatus(kind, resource)
1903
+ return {
1904
+ id: `${group}/${kind}/${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`,
1905
+ mode: 'applications',
1906
+ tool: getTool(kind, group),
1907
+ kindName: kind,
1908
+ kind: resource.kind ?? kind,
1909
+ group,
1910
+ name: resource.metadata?.name ?? '',
1911
+ namespace: resource.metadata?.namespace ?? '',
1912
+ project: resource.metadata?.namespace ?? '',
1913
+ labels: resource.metadata?.labels ?? {},
1914
+ sync: status?.sync ?? 'Unknown',
1915
+ health: status?.health ?? 'Unknown',
1916
+ suspended: status?.suspended ?? resource.spec?.suspend === true,
1917
+ repository: resource.spec?.url ?? resource.spec?.sourceRef?.name ?? '',
1918
+ targetRevision: resource.status?.artifact?.revision ?? resource.status?.lastAppliedRevision ?? resource.status?.lastAttemptedRevision ?? '',
1919
+ path: resource.spec?.path ?? '',
1920
+ chart: resource.spec?.chart?.spec?.chart ?? '',
1921
+ destination: 'in-cluster',
1922
+ destinationNamespace: resource.spec?.targetNamespace ?? resource.metadata?.namespace ?? '',
1923
+ createdAt: resource.metadata?.creationTimestamp ?? '',
1924
+ lastSync: newestConditionTime(resource),
1925
+ autoSync: !resource.spec?.suspend,
1926
+ terminating: isTerminating(resource),
1927
+ terminationStartedAt: terminationStartedAt(resource),
1928
+ raw: resource,
1929
+ }
1930
+ }
1931
+
1932
+ function syncTone(value: string): 'neutral' | 'success' | 'warning' | 'error' | 'info' {
1933
+ if (value === 'Synced') return 'success'
1934
+ if (value === 'OutOfSync') return 'warning'
1935
+ if (value === 'Reconciling') return 'info'
1936
+ return 'neutral'
1937
+ }
1938
+
1939
+ function healthTone(value: string): 'neutral' | 'success' | 'warning' | 'error' | 'info' {
1940
+ if (value === 'Healthy') return 'success'
1941
+ if (value === 'Degraded' || value === 'Missing') return 'error'
1942
+ if (value === 'Progressing') return 'info'
1943
+ if (value === 'Suspended') return 'warning'
1944
+ return 'neutral'
1945
+ }
1946
+
1947
+ function roleLabel(value: string) {
1948
+ return {
1949
+ root: 'Root',
1950
+ declared: 'Declared',
1951
+ generated: 'Generated',
1952
+ group: 'Groups',
1953
+ }[value] ?? value
1954
+ }
1955
+
1956
+ function gitOpsDetailPath(kind: string, namespace: string, name: string): string {
1957
+ return `/gitops/detail/${encodeURIComponent(kind)}/${encodeURIComponent(namespace || '_')}/${encodeURIComponent(name)}`
1958
+ }
1959
+
1960
+ function decodePathPart(value: string): string {
1961
+ try {
1962
+ return decodeURIComponent(value)
1963
+ } catch {
1964
+ return value
1965
+ }
1966
+ }
1967
+
1968
+ function isGitOpsDetailRef(ref: GitOpsTreeRef | GitOpsInsightRef): boolean {
1969
+ const kind = ref.kind.toLowerCase()
1970
+ if (ref.group === 'argoproj.io') {
1971
+ return kind === 'application' || kind === 'applicationset' || kind === 'appproject'
1972
+ }
1973
+ if (ref.group === 'kustomize.toolkit.fluxcd.io') return kind === 'kustomization'
1974
+ if (ref.group === 'helm.toolkit.fluxcd.io') return kind === 'helmrelease'
1975
+ // Flux source CRs (GitRepository/HelmRepository/OCIRepository/Bucket/HelmChart)
1976
+ // are NOT GitOps detail-page CRs — they're config objects with spec/status
1977
+ // but no managed-resource tree. The standard resource drawer renders them
1978
+ // cleanly. Keep this in sync with pkg/gitops/tree/graph.go classifyGitOpsKind.
1979
+ return false
1980
+ }
1981
+
1982
+ function isValidKubernetesName(name: string): boolean {
1983
+ return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(name)
1984
+ }
1985
+
1986
+ function hasAPIResource(resources: APIResource[] | undefined, name: string, group: string): boolean {
1987
+ return (resources ?? []).some((resource) => resource.name === name && resource.group === group)
1988
+ }
1989
+
1990
+ async function fetchResourceList(kind: string, group: string, namespacesParam: string): Promise<any[]> {
1991
+ const params = new URLSearchParams()
1992
+ if (namespacesParam) params.set('namespaces', namespacesParam)
1993
+ if (group) params.set('group', group)
1994
+ const res = await fetch(apiUrl(`/resources/${kind}?${params}`), {
1995
+ credentials: getCredentialsMode(),
1996
+ headers: getAuthHeaders(),
1997
+ })
1998
+ if (res.status === 400 || res.status === 403 || res.status === 404) return []
1999
+ if (!res.ok) throw new Error(`Failed to fetch ${kind}: HTTP ${res.status}`)
2000
+ return res.json()
2001
+ }
2002
+
2003
+ function normalizeArgoApplication(resource: any): GitOpsRow {
2004
+ const status = getGitOpsStatus('applications', resource)
2005
+ const source = resource.spec?.source ?? resource.spec?.sources?.[0] ?? {}
2006
+ const destination = resource.spec?.destination ?? {}
2007
+ return {
2008
+ id: `argoproj.io/applications/${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`,
2009
+ mode: 'applications',
2010
+ tool: 'argo',
2011
+ kindName: 'applications',
2012
+ kind: 'Application',
2013
+ group: 'argoproj.io',
2014
+ name: resource.metadata?.name ?? '',
2015
+ namespace: resource.metadata?.namespace ?? '',
2016
+ project: resource.spec?.project ?? 'default',
2017
+ labels: resource.metadata?.labels ?? {},
2018
+ sync: status?.sync ?? resource.status?.sync?.status ?? 'Unknown',
2019
+ health: status?.health ?? resource.status?.health?.status ?? 'Unknown',
2020
+ suspended: status?.suspended ?? false,
2021
+ repository: source.repoURL ?? '',
2022
+ targetRevision: source.targetRevision ?? resource.status?.sync?.revision ?? '',
2023
+ path: source.path ?? '',
2024
+ chart: source.chart ?? '',
2025
+ destination: destination.name ?? destination.server ?? '',
2026
+ destinationNamespace: destination.namespace ?? '',
2027
+ createdAt: resource.metadata?.creationTimestamp ?? '',
2028
+ lastSync: resource.status?.operationState?.finishedAt ?? resource.status?.reconciledAt ?? '',
2029
+ autoSync: Boolean(resource.spec?.syncPolicy?.automated),
2030
+ terminating: isTerminating(resource),
2031
+ terminationStartedAt: terminationStartedAt(resource),
2032
+ raw: resource,
2033
+ }
2034
+ }
2035
+
2036
+ function normalizeFluxKustomization(resource: any, fluxSourceUrls?: Map<string, string>): GitOpsRow {
2037
+ const status = getGitOpsStatus('kustomizations', resource)
2038
+ const sourceRef = resource.spec?.sourceRef ?? {}
2039
+ const resolvedRepo = resolveFluxSourceRepo(sourceRef, resource.metadata?.namespace, fluxSourceUrls)
2040
+ return {
2041
+ id: `kustomize.toolkit.fluxcd.io/kustomizations/${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`,
2042
+ mode: 'applications',
2043
+ tool: 'flux',
2044
+ kindName: 'kustomizations',
2045
+ kind: 'Kustomization',
2046
+ group: 'kustomize.toolkit.fluxcd.io',
2047
+ name: resource.metadata?.name ?? '',
2048
+ namespace: resource.metadata?.namespace ?? '',
2049
+ project: resource.metadata?.labels?.['kustomize.toolkit.fluxcd.io/name'] ?? resource.metadata?.namespace ?? '',
2050
+ labels: resource.metadata?.labels ?? {},
2051
+ sync: status?.sync ?? 'Unknown',
2052
+ health: resource.spec?.suspend ? 'Suspended' : (status?.health ?? 'Unknown'),
2053
+ suspended: resource.spec?.suspend === true,
2054
+ repository: resolvedRepo,
2055
+ targetRevision: resource.status?.lastAppliedRevision ?? resource.status?.lastAttemptedRevision ?? '',
2056
+ path: resource.spec?.path ?? '',
2057
+ chart: '',
2058
+ destination: resource.spec?.kubeConfig?.secretRef?.name ? `kubeconfig/${resource.spec.kubeConfig.secretRef.name}` : 'in-cluster',
2059
+ destinationNamespace: resource.spec?.targetNamespace ?? resource.metadata?.namespace ?? '',
2060
+ createdAt: resource.metadata?.creationTimestamp ?? '',
2061
+ lastSync: newestConditionTime(resource),
2062
+ autoSync: !resource.spec?.suspend,
2063
+ terminating: isTerminating(resource),
2064
+ terminationStartedAt: terminationStartedAt(resource),
2065
+ raw: resource,
2066
+ }
2067
+ }
2068
+
2069
+ function normalizeFluxHelmRelease(resource: any, fluxSourceUrls?: Map<string, string>): GitOpsRow {
2070
+ const status = getGitOpsStatus('helmreleases', resource)
2071
+ const chartSpec = resource.spec?.chart?.spec ?? {}
2072
+ const sourceRef = chartSpec.sourceRef ?? {}
2073
+ const resolvedRepo = resolveFluxSourceRepo(sourceRef, resource.metadata?.namespace, fluxSourceUrls)
2074
+ return {
2075
+ id: `helm.toolkit.fluxcd.io/helmreleases/${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`,
2076
+ mode: 'applications',
2077
+ tool: 'flux',
2078
+ kindName: 'helmreleases',
2079
+ kind: 'HelmRelease',
2080
+ group: 'helm.toolkit.fluxcd.io',
2081
+ name: resource.metadata?.name ?? '',
2082
+ namespace: resource.metadata?.namespace ?? '',
2083
+ project: resource.metadata?.labels?.['helm.toolkit.fluxcd.io/name'] ?? resource.metadata?.namespace ?? '',
2084
+ labels: resource.metadata?.labels ?? {},
2085
+ sync: status?.sync ?? 'Unknown',
2086
+ health: resource.spec?.suspend ? 'Suspended' : (status?.health ?? 'Unknown'),
2087
+ suspended: resource.spec?.suspend === true,
2088
+ repository: resolvedRepo,
2089
+ targetRevision: chartSpec.version ?? resource.status?.lastAttemptedRevision ?? '',
2090
+ path: '',
2091
+ chart: chartSpec.chart ?? '',
2092
+ destination: resource.spec?.kubeConfig?.secretRef?.name ? `kubeconfig/${resource.spec.kubeConfig.secretRef.name}` : 'in-cluster',
2093
+ destinationNamespace: resource.spec?.targetNamespace ?? resource.metadata?.namespace ?? '',
2094
+ createdAt: resource.metadata?.creationTimestamp ?? '',
2095
+ lastSync: newestConditionTime(resource),
2096
+ autoSync: !resource.spec?.suspend,
2097
+ terminating: isTerminating(resource),
2098
+ terminationStartedAt: terminationStartedAt(resource),
2099
+ raw: resource,
2100
+ }
2101
+ }
2102
+
2103
+ // buildFluxSourceUrlMap indexes Flux source CRs (GitRepository, HelmRepository,
2104
+ // OCIRepository, Bucket) by "<kind>/<namespace>/<name>" so reconciler row
2105
+ // normalization can resolve `spec.sourceRef` to the source's actual URL.
2106
+ // Without this, the fleet "Source" column reads "GitRepository podinfo"
2107
+ // (the CR's name) — same name as could appear elsewhere; useless for the
2108
+ // "where does this app live?" scan. Argo Application rows already show the
2109
+ // URL directly because Argo bakes it into the Application spec.
2110
+ function buildFluxSourceUrlMap(sources: any[]): Map<string, string> {
2111
+ const out = new Map<string, string>()
2112
+ for (const s of sources) {
2113
+ const kind = s?.kind
2114
+ const name = s?.metadata?.name
2115
+ const namespace = s?.metadata?.namespace
2116
+ const url = s?.spec?.url
2117
+ if (!kind || !name || !namespace || !url) continue
2118
+ out.set(`${kind}/${namespace}/${name}`, url)
2119
+ }
2120
+ return out
2121
+ }
2122
+
2123
+ // resolveFluxSourceRepo returns the source's URL when the source is in cache
2124
+ // and we can resolve it. Falls back to the legacy "Kind name" string so we
2125
+ // never show worse information than before. defaultNamespace handles the
2126
+ // common case where sourceRef.namespace is omitted (defaults to the
2127
+ // reconciler's own namespace per Flux convention).
2128
+ function resolveFluxSourceRepo(sourceRef: any, defaultNamespace: string | undefined, urlMap: Map<string, string> | undefined): string {
2129
+ const legacy = [sourceRef?.kind, sourceRef?.namespace ? `${sourceRef.namespace}/` : '', sourceRef?.name].filter(Boolean).join(' ')
2130
+ if (!urlMap || !sourceRef?.kind || !sourceRef?.name) return legacy
2131
+ const ns = sourceRef.namespace || defaultNamespace || ''
2132
+ if (!ns) return legacy
2133
+ const url = urlMap.get(`${sourceRef.kind}/${ns}/${sourceRef.name}`)
2134
+ return url || legacy
2135
+ }
2136
+
2137
+ // isTerminating reads metadata.deletionTimestamp from a raw K8s object.
2138
+ // Truthy when the resource has been marked for deletion (the controller
2139
+ // is processing finalizers, or finalizers are stuck and the resource is
2140
+ // a zombie). The fleet view paints a small Terminating indicator on
2141
+ // these rows; the detail view drives the [Terminating] chip + action
2142
+ // disabling off the same signal via the insights summary.
2143
+ function isTerminating(resource: any): boolean {
2144
+ return Boolean(resource?.metadata?.deletionTimestamp)
2145
+ }
2146
+
2147
+ // terminationStartedAt extracts the RFC3339 deletion timestamp, or
2148
+ // undefined when the resource isn't being deleted. Centralized so all
2149
+ // three normalizers (Argo, Flux Kustomization, Flux HelmRelease) agree
2150
+ // on the field path.
2151
+ function terminationStartedAt(resource: any): string | undefined {
2152
+ return resource?.metadata?.deletionTimestamp || undefined
2153
+ }
2154
+
2155
+ function newestConditionTime(resource: any): string {
2156
+ const times = (resource.status?.conditions ?? [])
2157
+ .map((condition: any) => condition.lastTransitionTime)
2158
+ .filter(Boolean)
2159
+ .sort()
2160
+ return times[times.length - 1] ?? ''
2161
+ }
2162
+
2163
+ function toggleSet(set: Set<string>, setter: (next: Set<string>) => void, value: string) {
2164
+ const next = new Set(set)
2165
+ if (next.has(value)) next.delete(value)
2166
+ else next.add(value)
2167
+ setter(next)
2168
+ }
2169
+
2170
+ function countValues(values: string[]) {
2171
+ const counts = new Map<string, number>()
2172
+ for (const value of values) {
2173
+ const key = value || '(none)'
2174
+ counts.set(key, (counts.get(key) ?? 0) + 1)
2175
+ }
2176
+ return [...counts.entries()]
2177
+ .map(([name, count]) => ({ name, count }))
2178
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
2179
+ }
2180
+
2181
+ function countMap(values: string[]) {
2182
+ const counts = new Map<string, number>()
2183
+ for (const value of values) {
2184
+ counts.set(value || 'Unknown', (counts.get(value || 'Unknown') ?? 0) + 1)
2185
+ }
2186
+ return counts
2187
+ }
2188
+
2189
+ function countLabels(rows: GitOpsRow[]) {
2190
+ const counts = new Map<string, number>()
2191
+ for (const row of rows) {
2192
+ for (const [key, value] of Object.entries(row.labels)) {
2193
+ if (!value) continue
2194
+ if (key.includes('pod-template-hash') || key.includes('controller-revision-hash')) continue
2195
+ const pair = `${key}=${value}`
2196
+ counts.set(pair, (counts.get(pair) ?? 0) + 1)
2197
+ }
2198
+ }
2199
+ return [...counts.entries()]
2200
+ .map(([name, count]) => ({ name, count }))
2201
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
2202
+ .slice(0, 30)
2203
+ }
2204
+
2205
+ function compareRows(a: GitOpsRow, b: GitOpsRow, sortKey: SortKey) {
2206
+ if (sortKey === 'health') return urgencyRank(a) - urgencyRank(b) || a.name.localeCompare(b.name)
2207
+ if (sortKey === 'sync') return syncRank(a.sync) - syncRank(b.sync) || a.name.localeCompare(b.name)
2208
+ if (sortKey === 'lastSync') return (Date.parse(b.lastSync || b.createdAt) || 0) - (Date.parse(a.lastSync || a.createdAt) || 0)
2209
+ if (sortKey === 'project') return a.project.localeCompare(b.project) || a.name.localeCompare(b.name)
2210
+ return a.name.localeCompare(b.name)
2211
+ }
2212
+
2213
+ // urgencyRank groups rows by what the operator should do about them, not by
2214
+ // the raw sync/health labels. The key insight: an OutOfSync app with
2215
+ // auto-sync ON is healing itself — it sorts after an OutOfSync app with no
2216
+ // auto-sync (which won't heal). Suspended sorts near the bottom because it's
2217
+ // intentionally non-green, not a problem to fix.
2218
+ //
2219
+ // Tiers:
2220
+ // 0. Truly broken — Terminating, Degraded, Missing. Won't self-heal.
2221
+ // 1. OutOfSync with no auto-sync. Drifted and stuck waiting for a human.
2222
+ // 2. OutOfSync with auto-sync on. Healing in progress.
2223
+ // 3. Progressing / Reconciling. Mid-rollout.
2224
+ // 4. Unknown / other. Indeterminate state.
2225
+ // 5. Suspended. Intentional non-green; bottom of the urgent half.
2226
+ // 6. Synced + Healthy. Calm steady state.
2227
+ function urgencyRank(row: GitOpsRow): number {
2228
+ if (row.terminating) return 0
2229
+ if (row.health === 'Degraded' || row.health === 'Missing') return 0
2230
+ if (row.sync === 'OutOfSync' && !row.autoSync) return 1
2231
+ if (row.sync === 'OutOfSync') return 2
2232
+ if (row.health === 'Progressing' || row.sync === 'Reconciling') return 3
2233
+ if (row.suspended || row.health === 'Suspended') return 5
2234
+ if (row.health === 'Healthy' && row.sync === 'Synced') return 6
2235
+ return 4
2236
+ }
2237
+
2238
+ function syncRank(sync: string) {
2239
+ return { OutOfSync: 0, Reconciling: 1, Unknown: 2, Synced: 3 }[sync] ?? 2
2240
+ }
2241
+
2242
+ function modeLabel(mode: GitOpsMode) {
2243
+ return {
2244
+ applications: 'Applications',
2245
+ sources: 'Sources',
2246
+ projects: 'Projects',
2247
+ alerts: 'Alerts',
2248
+ }[mode]
2249
+ }
2250
+
2251
+ function statusStripe(row: GitOpsRow) {
2252
+ // Lifecycle dominates: a Terminating resource paints orange regardless of
2253
+ // its (now stale) sync/health values. Without this guard, a row with
2254
+ // sync=OutOfSync + terminating=true paints amber and reads as "needs
2255
+ // sync attention" — but the resource is being deleted, so sync is moot.
2256
+ if (row.terminating) return 'bg-orange-500'
2257
+ if (row.health === 'Degraded') return 'bg-red-500'
2258
+ if (row.health === 'Progressing' || row.sync === 'Reconciling') return 'bg-sky-500'
2259
+ if (row.sync === 'OutOfSync') return 'bg-amber-500'
2260
+ if (row.health === 'Healthy' && row.sync === 'Synced') return 'bg-emerald-500'
2261
+ return 'bg-theme-text-tertiary'
2262
+ }
2263
+
2264
+ // insightChangeKey produces the same key shape that GitOpsChangesView uses
2265
+ // for its row keys, so we can pinpoint which row to scroll/highlight when
2266
+ // the user clicks an alert. Keep in sync with the row key in
2267
+ // GitOpsChangesView (kind/namespace/name; group is intentionally omitted
2268
+ // because issue refs may not carry it).
2269
+ function insightChangeKey(ref: { kind: string; namespace?: string; name: string }): string {
2270
+ return `${ref.kind}/${ref.namespace || ''}/${ref.name}`
2271
+ }
2272
+
2273
+ function formatRelative(value: string) {
2274
+ return formatRelativeAgeTime(value)
2275
+ }
2276
+
2277
+ function SummaryTile({ label, value, tone = 'neutral' }: { label: string; value: number; tone?: 'neutral' | 'warning' | 'error' | 'info' }) {
2278
+ const toneClass = {
2279
+ neutral: 'text-theme-text-primary',
2280
+ warning: 'text-amber-600 dark:text-amber-300',
2281
+ error: 'text-red-600 dark:text-red-300',
2282
+ info: 'text-sky-600 dark:text-sky-300',
2283
+ }[tone]
2284
+ return (
2285
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
2286
+ <div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
2287
+ <div className="text-xs text-theme-text-tertiary">{label}</div>
2288
+ </div>
2289
+ )
2290
+ }
2291
+
2292
+ function ActionButton({
2293
+ label,
2294
+ description,
2295
+ icon: Icon,
2296
+ loading,
2297
+ disabled,
2298
+ disabledReason,
2299
+ danger,
2300
+ primary,
2301
+ onClick,
2302
+ }: {
2303
+ label: string
2304
+ description?: string
2305
+ icon: ComponentType<{ className?: string }>
2306
+ loading?: boolean
2307
+ disabled?: boolean
2308
+ // Replaces the tooltip when the button is disabled. The user otherwise
2309
+ // hits a greyed-out button with no explanation — "Suspend (action label)"
2310
+ // tells them nothing about *why* it's disabled. With this set the tooltip
2311
+ // becomes "Cannot reconcile a resource pending deletion" or similar.
2312
+ disabledReason?: string
2313
+ danger?: boolean
2314
+ primary?: boolean
2315
+ onClick: () => void
2316
+ }) {
2317
+ // primary → brand fill (one per page); danger → red (destructive);
2318
+ // default → bordered ghost on theme surface (secondary actions).
2319
+ const variantClass = primary
2320
+ ? 'btn-brand'
2321
+ : danger
2322
+ ? 'border border-red-500/40 bg-red-500/10 text-red-500 hover:bg-red-500/20'
2323
+ : 'border border-theme-border bg-theme-surface text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
2324
+ const tooltip = disabled && disabledReason ? disabledReason : (description || label)
2325
+ return (
2326
+ <Tooltip content={tooltip}>
2327
+ <button
2328
+ type="button"
2329
+ onClick={onClick}
2330
+ disabled={loading || disabled}
2331
+ className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${variantClass}`}
2332
+ >
2333
+ {loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Icon className="h-3.5 w-3.5" />}
2334
+ {label}
2335
+ </button>
2336
+ </Tooltip>
2337
+ )
2338
+ }
2339
+
2340
+
2341
+ // describeTerminating produces two complementary strings:
2342
+ // - chipTooltip: full context for the Terminating chip's hover (finalizers
2343
+ // + age + why disabled). Wraps gracefully in the tooltip's max-w-xs
2344
+ // layout so multiple sentences are fine.
2345
+ // - actionDisabledTooltip: one tight sentence for disabled action buttons
2346
+ // so the tooltip stays small and anchored near the button. The full
2347
+ // explanation already lives in the lifecycle banner above; the
2348
+ // button tooltip just needs to say "you can't do this right now,
2349
+ // here's why in one line".
2350
+ //
2351
+ // Examples:
2352
+ // chipTooltip: "Pending deletion 21d ago. Finalizers: finalizers.fluxcd.io.
2353
+ // Mutating actions are disabled until cleanup completes."
2354
+ // actionDisabledTooltip: "Disabled — resource is pending deletion (21d)"
2355
+ function describeTerminating(summary?: { terminationStartedAt?: string; finalizers?: string[] }): {
2356
+ chipTooltip: string
2357
+ actionDisabledTooltip: string
2358
+ } {
2359
+ const ageText = formatRelativeAge(summary?.terminationStartedAt)
2360
+ const ageSuffix = ageText ? ` ${ageText} ago` : ''
2361
+ const finalizers = summary?.finalizers ?? []
2362
+ const finSuffix = finalizers.length > 0 ? ` Finalizers: ${finalizers.join(', ')}.` : ''
2363
+ const ageInline = ageText ? ` (${ageText})` : ''
2364
+ return {
2365
+ chipTooltip: `Pending deletion${ageSuffix}.${finSuffix} Mutating actions are disabled until cleanup completes.`,
2366
+ actionDisabledTooltip: `Disabled — resource is pending deletion${ageInline}`,
2367
+ }
2368
+ }
2369
+
2370
+ // formatRelativeAge — small inline relative-time formatter. Tier
2371
+ // breakpoints kept in sync with pkg/gitops/insights/insights.go::formatAgeShort
2372
+ // and pkg/audit/checks.go::formatDurationShort so UI, lifecycle Issue
2373
+ // messages, and audit findings agree on units. Adding a new tier (e.g.
2374
+ // "weeks") in one and not the others would let the same duration render
2375
+ // differently across surfaces. Returns "" when the input can't be parsed;
2376
+ // callers should treat empty as "no timestamp" and skip the age suffix
2377
+ // gracefully.
2378
+ function formatRelativeAge(rfc3339?: string): string {
2379
+ return formatCompactAge(rfc3339)
2380
+ }
2381
+
2382
+ // Parse an Argo HistoryItem.id into the int64 the rollback API needs.
2383
+ // Returns null when the id is missing, non-numeric (Flux condition rows
2384
+ // reuse the slot for condition.type), or non-positive. Number("") is 0
2385
+ // which passes Number.isFinite — guard with > 0 explicitly.
2386
+ function parseRollbackID(id: string | undefined): number | null {
2387
+ if (!id) return null
2388
+ const n = Number(id)
2389
+ if (!Number.isFinite(n) || n <= 0) return null
2390
+ return n
2391
+ }
2392
+
2393
+ // Inline counts for the topology toolbar — answers "how many resources, how
2394
+ // many of them are healthy / drifted" at a glance, without making the user
2395
+ // count facets in the filter rail.
2396
+ function TopologyCounts({ tree }: { tree: GitOpsResourceTree }) {
2397
+ const nodes = (tree.nodes ?? []).filter((n) => n.role !== 'group' && n.role !== 'root')
2398
+ const total = nodes.length
2399
+ if (total === 0) return null
2400
+ const healthy = nodes.filter((n) => (n.health || '').toLowerCase() === 'healthy').length
2401
+ const degraded = nodes.filter((n) => {
2402
+ const h = (n.health || '').toLowerCase()
2403
+ return h === 'degraded' || h === 'missing' || h === 'unhealthy'
2404
+ }).length
2405
+ const outOfSync = nodes.filter((n) => (n.sync || '').toLowerCase() === 'outofsync').length
2406
+ return (
2407
+ <div className="hidden min-w-0 flex-1 items-center gap-3 truncate text-[11px] text-theme-text-tertiary sm:flex">
2408
+ <span><span className="text-theme-text-primary">{total}</span> resources</span>
2409
+ {healthy > 0 && <span className="flex items-center gap-1"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> {healthy} healthy</span>}
2410
+ {/* Bad-news counts use status colors on the number itself so the worst
2411
+ fact in the row visually pops, not just the dot next to it. */}
2412
+ {degraded > 0 && <span className="flex items-center gap-1 font-medium text-red-600 dark:text-red-400"><span className="h-1.5 w-1.5 rounded-full bg-red-500" /> {degraded} degraded</span>}
2413
+ {outOfSync > 0 && <span className="flex items-center gap-1 font-medium text-amber-700 dark:text-amber-400"><span className="h-1.5 w-1.5 rounded-full bg-amber-500" /> {outOfSync} out of sync</span>}
2414
+ </div>
2415
+ )
2416
+ }
2417
+
2418
+ function summarizeGitOpsRows(rows: GitOpsRow[]) {
2419
+ return rows.reduce((summary, row) => {
2420
+ if (row.sync === 'OutOfSync') summary.outOfSync++
2421
+ if (row.health === 'Degraded') summary.degraded++
2422
+ if (row.health === 'Healthy') summary.healthy++
2423
+ if (row.health === 'Progressing') summary.progressing++
2424
+ if (row.suspended) summary.suspended++
2425
+ if (row.sync === 'Reconciling' || row.health === 'Progressing') summary.reconciling++
2426
+ return summary
2427
+ }, { outOfSync: 0, degraded: 0, healthy: 0, progressing: 0, suspended: 0, reconciling: 0 })
2428
+ }
2429
+
2430
+ function getGitOpsStatus(kind: string, resource: any): GitOpsStatus | null {
2431
+ if (kind === 'applications') {
2432
+ return argoStatusToGitOpsStatus(resource.status ?? {})
2433
+ }
2434
+ const conditions = (resource.status?.conditions ?? []) as FluxCondition[]
2435
+ return fluxConditionsToGitOpsStatus(conditions, resource.spec?.suspend === true)
2436
+ }
2437
+
2438
+ function getTool(kind: string, group?: string): 'argo' | 'flux' {
2439
+ if (group === 'argoproj.io' || kind === 'applications' || kind === 'applicationsets' || kind === 'appprojects') return 'argo'
2440
+ return 'flux'
2441
+ }