@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.
- package/package.json +6 -6
- package/src/App.tsx +214 -39
- package/src/api/client.ts +235 -3
- package/src/components/NamespaceSwitcher.tsx +298 -0
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/index.ts +1 -1
- package/src/components/gitops/GitOpsView.tsx +2441 -0
- package/src/components/gitops/RollbackDialog.tsx +107 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +144 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +20 -3
- package/src/components/helm/HelmView.tsx +9 -1
- package/src/components/helm/OwnedResources.tsx +2 -2
- package/src/components/home/GitOpsControllersCard.tsx +108 -0
- package/src/components/home/HomeView.tsx +9 -1
- package/src/components/portforward/PortForwardManager.tsx +135 -109
- package/src/components/resources/ResourcesView.tsx +27 -2
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +20 -6
- package/src/components/ui/CommandPalette.tsx +6 -4
- package/src/components/ui/ShortcutHelpOverlay.tsx +2 -1
- package/src/components/ui/UpdateNotification.tsx +36 -19
- package/src/components/workload/WorkloadView.tsx +126 -10
- package/src/types.ts +2 -0
- package/src/utils/navigation.ts +14 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -446
|
@@ -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
|
+
}
|