@skyhook-io/radar-app 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/App.tsx +81 -18
- package/src/api/client.ts +200 -26
- package/src/api/rbac.ts +57 -0
- package/src/components/compare/CompareViewRoute.tsx +116 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +76 -0
- package/src/components/cost/CostView.tsx +1 -1
- package/src/components/gitops/GitOpsView.tsx +258 -1862
- package/src/components/helm/ChartBrowser.tsx +61 -10
- package/src/components/helm/HelmView.tsx +28 -11
- package/src/components/helm/InstallWizard.tsx +5 -5
- package/src/components/helm/ManifestDiffViewer.tsx +1 -1
- package/src/components/helm/ValuesViewer.tsx +3 -39
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/HomeView.tsx +18 -2
- package/src/components/resource/HPACharts.tsx +232 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +151 -434
- package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
- package/src/components/resource/RestartChart.tsx +124 -0
- package/src/components/resource/RightsizingStrip.tsx +167 -0
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/renderers/HPARenderer.tsx +17 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/settings/MyPermissionsDialog.tsx +231 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +107 -3
- package/src/context/NavCustomization.tsx +13 -0
- package/src/contexts/CapabilitiesContext.tsx +8 -3
- package/src/components/gitops/RollbackDialog.tsx +0 -107
- package/src/components/gitops/SyncOptionsDialog.tsx +0 -144
|
@@ -1,35 +1,44 @@
|
|
|
1
|
-
import { useEffect, useMemo,
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
2
2
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
3
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
4
|
import yaml from 'yaml'
|
|
7
5
|
import {
|
|
8
6
|
GitOpsActivityInsightView,
|
|
9
7
|
GitOpsChangesView,
|
|
10
|
-
|
|
8
|
+
GitOpsDetailLayout,
|
|
9
|
+
GitOpsGraphFilterRail,
|
|
10
|
+
GitOpsTableView as SharedGitOpsTableView,
|
|
11
11
|
GitOpsTreeGraph,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
RollbackDialog,
|
|
13
|
+
SyncOptionsDialog,
|
|
14
|
+
buildFluxSourceUrlMap,
|
|
15
|
+
buildTreeFacets,
|
|
16
|
+
describeGitOpsTerminating,
|
|
17
|
+
formatGitOpsDestination,
|
|
18
|
+
formatGitOpsSourceUrl,
|
|
19
|
+
getGitOpsResourceStatus,
|
|
20
|
+
getGitOpsTool,
|
|
21
|
+
gitOpsInsightChangeKey,
|
|
17
22
|
initNavigationMap,
|
|
18
23
|
kindToPlural,
|
|
24
|
+
normalizeArgoApplication,
|
|
25
|
+
normalizeFluxHelmRelease,
|
|
26
|
+
normalizeFluxKustomization,
|
|
27
|
+
parseArgoRollbackID,
|
|
28
|
+
toggleSet,
|
|
19
29
|
type APIResource,
|
|
30
|
+
type ArgoActionHandlers,
|
|
31
|
+
type FluxActionHandlers,
|
|
32
|
+
type GitOpsDetailMetadata,
|
|
33
|
+
type GitOpsDetailTab,
|
|
20
34
|
type GitOpsResourceTree,
|
|
21
35
|
type GitOpsInsightRef,
|
|
36
|
+
type GitOpsRow,
|
|
22
37
|
type GitOpsTreeFilters,
|
|
23
38
|
type GitOpsTreeRef,
|
|
24
39
|
type GitOpsTreePreset,
|
|
25
40
|
type SelectedResource,
|
|
26
41
|
} 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
42
|
import { useToast } from '../ui/Toast'
|
|
34
43
|
|
|
35
44
|
import {
|
|
@@ -52,10 +61,7 @@ import {
|
|
|
52
61
|
import { useAPIResources } from '../../api/apiResources'
|
|
53
62
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
54
63
|
import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
|
|
55
|
-
import { Tooltip } from '../ui/Tooltip'
|
|
56
64
|
import { CodeViewer } from '../ui/CodeViewer'
|
|
57
|
-
import { SyncOptionsDialog } from './SyncOptionsDialog'
|
|
58
|
-
import { RollbackDialog } from './RollbackDialog'
|
|
59
65
|
import type { GitOpsHistoryItem } from '@skyhook-io/k8s-ui'
|
|
60
66
|
|
|
61
67
|
const GITOPS_KINDS: APIResource[] = [
|
|
@@ -77,44 +83,6 @@ interface ResourceCountsResponse {
|
|
|
77
83
|
forbidden?: string[]
|
|
78
84
|
}
|
|
79
85
|
|
|
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
86
|
interface GitOpsViewProps {
|
|
119
87
|
namespaces: string[]
|
|
120
88
|
onOpenResource: (resource: SelectedResource) => void
|
|
@@ -130,7 +98,6 @@ export function GitOpsView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
130
98
|
|
|
131
99
|
function GitOpsTableView({ namespaces }: { namespaces: string[] }) {
|
|
132
100
|
const navigate = useNavigate()
|
|
133
|
-
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
134
101
|
const namespacesParam = namespaces.join(',')
|
|
135
102
|
const { data: apiResources, isLoading: apiResourcesLoading } = useAPIResources()
|
|
136
103
|
|
|
@@ -138,38 +105,9 @@ function GitOpsTableView({ namespaces }: { namespaces: string[] }) {
|
|
|
138
105
|
initNavigationMap([...(apiResources ?? []), ...GITOPS_KINDS])
|
|
139
106
|
}, [apiResources])
|
|
140
107
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
108
|
+
// Counts come from radar's /api/resource-counts, kind-filtered to the
|
|
109
|
+
// GitOps set. The extracted GitOpsTableView reads them for the
|
|
110
|
+
// Scope-section mode tabs + the empty-state check.
|
|
173
111
|
const countsQuery = useQuery({
|
|
174
112
|
queryKey: ['gitops-resource-counts', namespacesParam],
|
|
175
113
|
queryFn: async () => {
|
|
@@ -181,20 +119,17 @@ function GitOpsTableView({ namespaces }: { namespaces: string[] }) {
|
|
|
181
119
|
refetchInterval: 60_000,
|
|
182
120
|
})
|
|
183
121
|
|
|
184
|
-
|
|
185
|
-
|
|
122
|
+
// Row-producing fetch: Applications + Kustomizations + HelmReleases,
|
|
123
|
+
// plus the Flux source CRs (GitRepository / HelmRepository /
|
|
124
|
+
// OCIRepository / Bucket) for sourceRef→URL resolution. We skip per-
|
|
125
|
+
// kind requests when the cluster doesn't have the CRD installed; the
|
|
126
|
+
// capability map comes from useAPIResources.
|
|
127
|
+
const rowsQuery = useQuery({
|
|
128
|
+
queryKey: ['gitops-rows-main', namespaces, apiResources?.length ?? 0],
|
|
186
129
|
queryFn: async () => {
|
|
187
130
|
const hasApplications = hasAPIResource(apiResources, 'applications', 'argoproj.io')
|
|
188
131
|
const hasKustomizations = hasAPIResource(apiResources, 'kustomizations', 'kustomize.toolkit.fluxcd.io')
|
|
189
132
|
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
133
|
const hasFluxSources = hasKustomizations || hasHelmReleases
|
|
199
134
|
const hasGitRepos = hasFluxSources && hasAPIResource(apiResources, 'gitrepositories', 'source.toolkit.fluxcd.io')
|
|
200
135
|
const hasHelmRepos = hasFluxSources && hasAPIResource(apiResources, 'helmrepositories', 'source.toolkit.fluxcd.io')
|
|
@@ -221,834 +156,24 @@ function GitOpsTableView({ namespaces }: { namespaces: string[] }) {
|
|
|
221
156
|
refetchInterval: 120_000,
|
|
222
157
|
})
|
|
223
158
|
|
|
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
159
|
return (
|
|
633
|
-
<
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
160
|
+
<SharedGitOpsTableView
|
|
161
|
+
rows={rowsQuery.data ?? []}
|
|
162
|
+
loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading}
|
|
163
|
+
error={(rowsQuery.error as Error | null) ?? null}
|
|
164
|
+
counts={countsQuery.data?.counts ?? {}}
|
|
165
|
+
onRefresh={() => rowsQuery.refetch()}
|
|
166
|
+
onRowClick={(row) => {
|
|
167
|
+
const ns = row.namespace || '_'
|
|
168
|
+
const params = new URLSearchParams()
|
|
169
|
+
params.set('apiGroup', row.group)
|
|
170
|
+
navigate({ pathname: gitOpsDetailPath(row.kindName, ns, row.name), search: params.toString() })
|
|
171
|
+
}}
|
|
172
|
+
searchHotkey
|
|
173
|
+
/>
|
|
640
174
|
)
|
|
641
175
|
}
|
|
642
176
|
|
|
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
177
|
function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
1053
178
|
const location = useLocation()
|
|
1054
179
|
const navigate = useNavigate()
|
|
@@ -1080,8 +205,8 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
1080
205
|
const resourceQ = useResource<any>(kind, namespace, name, group)
|
|
1081
206
|
const treeQ = useGitOpsTree(kind, namespace, name, group, namespaces)
|
|
1082
207
|
const insightsQ = useGitOpsInsights(kind, namespace, name, group, namespaces)
|
|
1083
|
-
const status = resourceQ.data ?
|
|
1084
|
-
const tool =
|
|
208
|
+
const status = resourceQ.data ? getGitOpsResourceStatus(kind, resourceQ.data) : null
|
|
209
|
+
const tool = getGitOpsTool(kind, group)
|
|
1085
210
|
// Argo "auto-sync ON" is determined by spec.syncPolicy.automated being set,
|
|
1086
211
|
// not by health.status === Suspended (which is Argo's CronJob-style suspend).
|
|
1087
212
|
// The toggle button reads from this so the label flips correctly when an
|
|
@@ -1108,10 +233,10 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
1108
233
|
// Terminate) intentionally remain enabled — see the corresponding
|
|
1109
234
|
// carve-out in pkg/gitops/operations.go.
|
|
1110
235
|
const terminating = !!insightsQ.data?.summary?.terminating
|
|
1111
|
-
const terminatingDescriptions =
|
|
236
|
+
const terminatingDescriptions = describeGitOpsTerminating(insightsQ.data?.summary)
|
|
1112
237
|
const terminatingChipTooltip = terminatingDescriptions.chipTooltip
|
|
1113
238
|
const terminatingActionTooltip = terminatingDescriptions.actionDisabledTooltip
|
|
1114
|
-
const [appView, setAppView] = useState<
|
|
239
|
+
const [appView, setAppView] = useState<GitOpsDetailTab>('topology')
|
|
1115
240
|
// When the user clicks an actionable issue alert ("OutOfSync — NodePool
|
|
1116
241
|
// default is out of sync · View →"), we navigate to Changes and focus
|
|
1117
242
|
// that resource. The ref is stringified to a stable key so GitOpsChangesView
|
|
@@ -1186,9 +311,6 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
1186
311
|
const isFluxWorkload = kind === 'kustomizations' || kind === 'helmreleases'
|
|
1187
312
|
const isFlux = tool === 'flux'
|
|
1188
313
|
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
314
|
|
|
1193
315
|
// Set the browser tab title so users with multiple resource tabs open can
|
|
1194
316
|
// tell which is which without focusing each tab. Restore on unmount so a
|
|
@@ -1253,322 +375,179 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
1253
375
|
enabled: shortcutsEnabled && isArgoApp && isRunning,
|
|
1254
376
|
})
|
|
1255
377
|
|
|
378
|
+
// Adapt the OSS-internal row + insights data into the layout's props.
|
|
379
|
+
// The bulk of the JSX is now in <GitOpsDetailLayout>; this wrapper does
|
|
380
|
+
// the OSS-specific things the layout can't (call OSS-side data hooks,
|
|
381
|
+
// open OSS dialogs, talk to OSS Toast, hit OSS keyboard registry).
|
|
382
|
+
const detail: GitOpsDetailMetadata = {
|
|
383
|
+
project: detailRow?.project,
|
|
384
|
+
repository: detailRow?.repository ? formatGitOpsSourceUrl(detailRow.repository) : undefined,
|
|
385
|
+
path: detailRow?.path || undefined,
|
|
386
|
+
chart: detailRow?.chart || undefined,
|
|
387
|
+
destination: formatGitOpsDestination(detailRow?.destination, detailRow?.destinationNamespace),
|
|
388
|
+
autoSyncMode: insightsQ.data?.summary?.autoSyncMode,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const argoHandlers: ArgoActionHandlers | undefined = isArgoApp ? {
|
|
392
|
+
onSyncRequested: () => setSyncDialogOpen(true),
|
|
393
|
+
onRefresh: (refreshType) => {
|
|
394
|
+
setRefreshKind(refreshType)
|
|
395
|
+
argoRefresh.mutate({ namespace, name, hard: refreshType === 'hard' })
|
|
396
|
+
},
|
|
397
|
+
onTerminate: () => argoTerminate.mutate({ namespace, name }),
|
|
398
|
+
onSuspend: () => argoSuspend.mutate({ namespace, name }),
|
|
399
|
+
onResume: () => argoResume.mutate({ namespace, name }),
|
|
400
|
+
syncing: argoSync.isPending,
|
|
401
|
+
refreshing: argoRefresh.isPending,
|
|
402
|
+
refreshingKind: refreshKind,
|
|
403
|
+
terminating: argoTerminate.isPending,
|
|
404
|
+
suspending: argoSuspend.isPending,
|
|
405
|
+
resuming: argoResume.isPending,
|
|
406
|
+
autoSyncEnabled: argoAutoSyncEnabled,
|
|
407
|
+
isRunning,
|
|
408
|
+
} : undefined
|
|
409
|
+
|
|
410
|
+
const fluxHandlers: FluxActionHandlers | undefined = isFlux ? {
|
|
411
|
+
onReconcile: () => fluxReconcile.mutate({ kind, namespace, name }),
|
|
412
|
+
onSyncWithSource: () => fluxSyncWithSource.mutate({ kind, namespace, name }),
|
|
413
|
+
onSuspend: () => fluxSuspend.mutate({ kind, namespace, name }),
|
|
414
|
+
onResume: () => fluxResume.mutate({ kind, namespace, name }),
|
|
415
|
+
reconciling: fluxReconcile.isPending,
|
|
416
|
+
syncingWithSource: fluxSyncWithSource.isPending,
|
|
417
|
+
suspending: fluxSuspend.isPending,
|
|
418
|
+
resuming: fluxResume.isPending,
|
|
419
|
+
} : undefined
|
|
420
|
+
|
|
1256
421
|
return (
|
|
1257
|
-
<
|
|
1258
|
-
{
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
{
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
-
}
|
|
422
|
+
<GitOpsDetailLayout
|
|
423
|
+
identity={{
|
|
424
|
+
kind,
|
|
425
|
+
group,
|
|
426
|
+
namespace,
|
|
427
|
+
name,
|
|
428
|
+
toolLabel: tool === 'argo' ? 'ArgoCD' : 'FluxCD',
|
|
429
|
+
kindLabel: apiKind?.kind ?? kind,
|
|
430
|
+
}}
|
|
431
|
+
parent={parent}
|
|
432
|
+
status={status ? { sync: status.sync, health: status.health, suspended: effectiveSuspended } : null}
|
|
433
|
+
terminating={terminating}
|
|
434
|
+
terminatingChipTooltip={terminatingChipTooltip}
|
|
435
|
+
terminatingActionTooltip={terminatingActionTooltip}
|
|
436
|
+
detail={detail}
|
|
437
|
+
insight={insightsQ.data ?? null}
|
|
438
|
+
insightLoading={insightsQ.isLoading}
|
|
439
|
+
onSelectIssue={(issue) => {
|
|
440
|
+
const ref = issue.refs?.[0]
|
|
441
|
+
if (!ref) return
|
|
442
|
+
setAppView('changes')
|
|
443
|
+
setChangesFocusKey(gitOpsInsightChangeKey(ref))
|
|
444
|
+
// Window the highlight: 4s is long enough to find the row visually
|
|
445
|
+
// but short enough that it doesn't linger if the user navigates
|
|
446
|
+
// away and back.
|
|
447
|
+
window.setTimeout(() => setChangesFocusKey(null), 4000)
|
|
448
|
+
}}
|
|
449
|
+
remediationPending={applyResource.isPending || argoSync.isPending}
|
|
450
|
+
onRemediate={(remediation) => {
|
|
451
|
+
if (remediation.kind === 'create-namespace' && remediation.target) {
|
|
452
|
+
const nsName = remediation.target
|
|
453
|
+
const yamlManifest = `apiVersion: v1\nkind: Namespace\nmetadata:\n name: ${nsName}\n`
|
|
454
|
+
applyResource.mutate(
|
|
455
|
+
{ yaml: yamlManifest, mode: 'apply' },
|
|
456
|
+
{
|
|
457
|
+
onSuccess: () => {
|
|
458
|
+
// Defer the success toast until we know whether the follow-on
|
|
459
|
+
// sync was triggered, so a successful create-namespace + sync
|
|
460
|
+
// failure doesn't yield a misleading "sync triggered" toast.
|
|
461
|
+
if (kind === 'applications') {
|
|
462
|
+
argoSync.mutate(
|
|
463
|
+
{ namespace, name },
|
|
464
|
+
{
|
|
465
|
+
onSuccess: () => {
|
|
466
|
+
showSuccess(`Created namespace ${nsName}`, 'Sync triggered to retry the apply.')
|
|
467
|
+
},
|
|
468
|
+
onError: () => {
|
|
469
|
+
showSuccess(`Created namespace ${nsName}`, "Couldn't trigger sync automatically — click Sync to retry.")
|
|
470
|
+
},
|
|
1461
471
|
},
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
472
|
+
)
|
|
473
|
+
} else {
|
|
474
|
+
showSuccess(`Created namespace ${nsName}`)
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
onError: (err: unknown) => {
|
|
478
|
+
const msg = err instanceof Error ? err.message : 'Unknown error'
|
|
479
|
+
showError(
|
|
480
|
+
`Couldn't create namespace ${nsName}`,
|
|
481
|
+
msg.includes('forbidden')
|
|
482
|
+
? 'Radar lacks RBAC to create namespaces in this cluster. Create it manually or have a cluster-admin do it.'
|
|
483
|
+
: msg,
|
|
1472
484
|
)
|
|
1473
|
-
}
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
}}
|
|
490
|
+
helmValues={helmValues}
|
|
491
|
+
helmValuesOpen={helmValuesOpen}
|
|
492
|
+
onToggleHelmValues={() => setHelmValuesOpen((v) => !v)}
|
|
493
|
+
helmValuesContent={helmValues ? <CodeViewer code={helmValues.yaml} language="yaml" showLineNumbers maxHeight="320px" /> : null}
|
|
494
|
+
isArgoApp={isArgoApp}
|
|
495
|
+
isFlux={isFlux}
|
|
496
|
+
isFluxWorkload={isFluxWorkload}
|
|
497
|
+
argo={argoHandlers}
|
|
498
|
+
flux={fluxHandlers}
|
|
499
|
+
activeTab={appView}
|
|
500
|
+
onTabChange={(tab) => setAppView(tab)}
|
|
501
|
+
fullscreen={graphFullscreen}
|
|
502
|
+
onToggleFullscreen={() => setGraphFullscreen(!graphFullscreen)}
|
|
503
|
+
resourceLoading={resourceQ.isLoading}
|
|
504
|
+
resourceError={(resourceQ.error as Error | null) ?? null}
|
|
505
|
+
onNavigateRoot={() => navigate('/gitops')}
|
|
506
|
+
onNavigateParent={parent ? () => {
|
|
507
|
+
const params = new URLSearchParams()
|
|
508
|
+
if (parent.group) params.set('apiGroup', parent.group)
|
|
509
|
+
navigate({
|
|
510
|
+
pathname: gitOpsDetailPath(parent.kind, parent.namespace || '_', parent.name),
|
|
511
|
+
search: params.toString(),
|
|
512
|
+
})
|
|
513
|
+
} : undefined}
|
|
514
|
+
manageDocumentTitle={false /* OSS handles it via the in-effect-above */}
|
|
515
|
+
renderTabBarCounts={({ tab }) => (
|
|
516
|
+
tab === 'topology' && tree ? <TopologyCounts tree={tree} /> : null
|
|
517
|
+
)}
|
|
518
|
+
renderTabBarAccessory={({ tab }) => (
|
|
519
|
+
tab === 'topology' ? (
|
|
520
|
+
<button
|
|
521
|
+
type="button"
|
|
522
|
+
onClick={() => {
|
|
523
|
+
setGraphSearch('')
|
|
524
|
+
setGraphKinds(new Set())
|
|
525
|
+
setGraphSync(new Set())
|
|
526
|
+
setGraphHealth(new Set())
|
|
527
|
+
setGraphNamespaces(new Set())
|
|
528
|
+
setGraphRoles(new Set())
|
|
1474
529
|
}}
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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
|
-
</>
|
|
530
|
+
className="rounded px-2 py-1 text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
531
|
+
>
|
|
532
|
+
Clear filters
|
|
533
|
+
</button>
|
|
534
|
+
) : null
|
|
1506
535
|
)}
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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' ? (
|
|
536
|
+
renderTabBody={({ tab }) => {
|
|
537
|
+
if (tab === 'activity') {
|
|
538
|
+
return (
|
|
1559
539
|
<GitOpsActivityInsightView
|
|
1560
540
|
insight={insightsQ.data}
|
|
1561
541
|
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
542
|
onRollback={isArgoApp ? (item) => {
|
|
1567
|
-
if (
|
|
543
|
+
if (parseArgoRollbackID(item.id) == null) return
|
|
1568
544
|
setRollbackTarget(item)
|
|
1569
545
|
} : undefined}
|
|
1570
546
|
/>
|
|
1571
|
-
)
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
if (tab === 'changes') {
|
|
550
|
+
return (
|
|
1572
551
|
<GitOpsChangesView
|
|
1573
552
|
insight={insightsQ.data}
|
|
1574
553
|
error={insightsQ.error as Error | null}
|
|
@@ -1576,43 +555,46 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
1576
555
|
focusKey={changesFocusKey}
|
|
1577
556
|
tree={tree}
|
|
1578
557
|
/>
|
|
1579
|
-
)
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
// topology
|
|
561
|
+
return (
|
|
562
|
+
<div className="grid min-h-0 min-w-0 flex-1 grid-cols-[280px_minmax(0,1fr)] max-lg:grid-cols-1">
|
|
563
|
+
<GitOpsGraphFilterRail
|
|
564
|
+
facets={graphFacets}
|
|
565
|
+
preset={graphPreset}
|
|
566
|
+
onPresetChange={setGraphPreset}
|
|
567
|
+
search={graphSearch}
|
|
568
|
+
onSearchChange={setGraphSearch}
|
|
569
|
+
kinds={graphKinds}
|
|
570
|
+
onToggleKind={(value) => toggleSet(graphKinds, setGraphKinds, value)}
|
|
571
|
+
sync={graphSync}
|
|
572
|
+
onToggleSync={(value) => toggleSet(graphSync, setGraphSync, value)}
|
|
573
|
+
health={graphHealth}
|
|
574
|
+
onToggleHealth={(value) => toggleSet(graphHealth, setGraphHealth, value)}
|
|
575
|
+
namespaces={graphNamespaces}
|
|
576
|
+
onToggleNamespace={(value) => toggleSet(graphNamespaces, setGraphNamespaces, value)}
|
|
577
|
+
roles={graphRoles}
|
|
578
|
+
onToggleRole={(value) => toggleSet(graphRoles, setGraphRoles, value)}
|
|
579
|
+
/>
|
|
580
|
+
<div className="min-h-0 min-w-0 border-l border-theme-border max-lg:border-l-0 max-lg:border-t">
|
|
581
|
+
<GitOpsTreeGraph
|
|
582
|
+
tree={tree}
|
|
583
|
+
loading={treeQ.isLoading}
|
|
584
|
+
error={treeQ.error as Error | null}
|
|
585
|
+
onNodeClick={openResourceFromTree}
|
|
1583
586
|
preset={graphPreset}
|
|
1584
587
|
onPresetChange={setGraphPreset}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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)}
|
|
588
|
+
query={graphSearch}
|
|
589
|
+
onQueryChange={setGraphSearch}
|
|
590
|
+
filters={graphFilters}
|
|
591
|
+
showToolbar={false}
|
|
1597
592
|
/>
|
|
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
593
|
</div>
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
594
|
+
</div>
|
|
595
|
+
)
|
|
596
|
+
}}
|
|
597
|
+
>
|
|
1616
598
|
{/* Modals — portaled to body, only render the ones for the current tool. */}
|
|
1617
599
|
{isArgoApp && (
|
|
1618
600
|
<>
|
|
@@ -1622,9 +604,6 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
1622
604
|
pending={argoSync.isPending}
|
|
1623
605
|
onCancel={() => setSyncDialogOpen(false)}
|
|
1624
606
|
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
607
|
argoSync.mutate({ namespace, name, ...opts }, {
|
|
1629
608
|
onSuccess: () => setSyncDialogOpen(false),
|
|
1630
609
|
})
|
|
@@ -1638,9 +617,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
1638
617
|
pending={argoRollback.isPending}
|
|
1639
618
|
onCancel={() => setRollbackTarget(null)}
|
|
1640
619
|
onConfirm={(opts) => {
|
|
1641
|
-
|
|
1642
|
-
// confirm, leaving the captured id no longer parseable.
|
|
1643
|
-
const id = parseRollbackID(rollbackTarget?.id)
|
|
620
|
+
const id = parseArgoRollbackID(rollbackTarget?.id)
|
|
1644
621
|
if (id == null) {
|
|
1645
622
|
showError('Rollback target became invalid', 'The history entry changed while the dialog was open. Reselect a target and try again.')
|
|
1646
623
|
setRollbackTarget(null)
|
|
@@ -1653,19 +630,9 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
1653
630
|
/>
|
|
1654
631
|
</>
|
|
1655
632
|
)}
|
|
1656
|
-
</
|
|
633
|
+
</GitOpsDetailLayout>
|
|
1657
634
|
)
|
|
1658
635
|
}
|
|
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
636
|
type HelmValuesSource = 'flux' | 'argo-object' | 'argo-string' | 'argo-parameters'
|
|
1670
637
|
interface HelmValuesData {
|
|
1671
638
|
yaml: string
|
|
@@ -1718,7 +685,7 @@ function extractHelmValues(kind: string, resource: any): HelmValuesData | null {
|
|
|
1718
685
|
|
|
1719
686
|
function safeStringifyYaml(value: unknown): string {
|
|
1720
687
|
try {
|
|
1721
|
-
return yaml.stringify(value)
|
|
688
|
+
return yaml.stringify(value, { lineWidth: 0 })
|
|
1722
689
|
} catch {
|
|
1723
690
|
return JSON.stringify(value, null, 2)
|
|
1724
691
|
}
|
|
@@ -1732,178 +699,22 @@ function tryParseYaml(value: string): unknown {
|
|
|
1732
699
|
}
|
|
1733
700
|
}
|
|
1734
701
|
|
|
1735
|
-
//
|
|
1736
|
-
//
|
|
1737
|
-
//
|
|
1738
|
-
//
|
|
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
|
-
}
|
|
702
|
+
// AppFact + ViewButton + ActionButton moved into
|
|
703
|
+
// @skyhook-io/k8s-ui's GitOpsDetailLayout (shared with hub-web's fleet
|
|
704
|
+
// detail page). The OSS wrapper above mounts the layout instead of
|
|
705
|
+
// rendering its own header chrome.
|
|
1793
706
|
|
|
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
707
|
|
|
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
708
|
|
|
1898
709
|
function normalizeDetailResource(kind: string, group: string, resource: any): GitOpsRow | null {
|
|
1899
710
|
if (kind === 'applications') return normalizeArgoApplication(resource)
|
|
1900
711
|
if (kind === 'kustomizations') return normalizeFluxKustomization(resource)
|
|
1901
712
|
if (kind === 'helmreleases') return normalizeFluxHelmRelease(resource)
|
|
1902
|
-
const status =
|
|
713
|
+
const status = getGitOpsResourceStatus(kind, resource)
|
|
1903
714
|
return {
|
|
1904
715
|
id: `${group}/${kind}/${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`,
|
|
1905
716
|
mode: 'applications',
|
|
1906
|
-
tool:
|
|
717
|
+
tool: getGitOpsTool(kind, group),
|
|
1907
718
|
kindName: kind,
|
|
1908
719
|
kind: resource.kind ?? kind,
|
|
1909
720
|
group,
|
|
@@ -1929,29 +740,6 @@ function normalizeDetailResource(kind: string, group: string, resource: any): Gi
|
|
|
1929
740
|
}
|
|
1930
741
|
}
|
|
1931
742
|
|
|
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
743
|
|
|
1956
744
|
function gitOpsDetailPath(kind: string, namespace: string, name: string): string {
|
|
1957
745
|
return `/gitops/detail/${encodeURIComponent(kind)}/${encodeURIComponent(namespace || '_')}/${encodeURIComponent(name)}`
|
|
@@ -2000,146 +788,6 @@ async function fetchResourceList(kind: string, group: string, namespacesParam: s
|
|
|
2000
788
|
return res.json()
|
|
2001
789
|
}
|
|
2002
790
|
|
|
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
791
|
function isTerminating(resource: any): boolean {
|
|
2144
792
|
return Boolean(resource?.metadata?.deletionTimestamp)
|
|
2145
793
|
}
|
|
@@ -2160,235 +808,6 @@ function newestConditionTime(resource: any): string {
|
|
|
2160
808
|
return times[times.length - 1] ?? ''
|
|
2161
809
|
}
|
|
2162
810
|
|
|
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
811
|
|
|
2393
812
|
// Inline counts for the topology toolbar — answers "how many resources, how
|
|
2394
813
|
// many of them are healthy / drifted" at a glance, without making the user
|
|
@@ -2415,27 +834,4 @@ function TopologyCounts({ tree }: { tree: GitOpsResourceTree }) {
|
|
|
2415
834
|
)
|
|
2416
835
|
}
|
|
2417
836
|
|
|
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
837
|
|
|
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
|
-
}
|