@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.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +81 -18
  3. package/src/api/client.ts +200 -26
  4. package/src/api/rbac.ts +57 -0
  5. package/src/components/compare/CompareViewRoute.tsx +116 -0
  6. package/src/components/compare/useCompareCandidates.ts +27 -0
  7. package/src/components/compare/useCompareLauncher.tsx +76 -0
  8. package/src/components/cost/CostView.tsx +1 -1
  9. package/src/components/gitops/GitOpsView.tsx +258 -1862
  10. package/src/components/helm/ChartBrowser.tsx +61 -10
  11. package/src/components/helm/HelmView.tsx +28 -11
  12. package/src/components/helm/InstallWizard.tsx +5 -5
  13. package/src/components/helm/ManifestDiffViewer.tsx +1 -1
  14. package/src/components/helm/ValuesViewer.tsx +3 -39
  15. package/src/components/helm/helm-utils.ts +4 -0
  16. package/src/components/home/HomeView.tsx +18 -2
  17. package/src/components/resource/HPACharts.tsx +232 -0
  18. package/src/components/resource/PVCUsageBar.tsx +59 -0
  19. package/src/components/resource/PrometheusCharts.tsx +151 -434
  20. package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
  21. package/src/components/resource/RestartChart.tsx +124 -0
  22. package/src/components/resource/RightsizingStrip.tsx +167 -0
  23. package/src/components/resources/CompositeRenderer.tsx +101 -0
  24. package/src/components/resources/renderers/HPARenderer.tsx +17 -1
  25. package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
  26. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  27. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  28. package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
  29. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  30. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  31. package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
  32. package/src/components/resources/renderers/index.ts +1 -0
  33. package/src/components/settings/MyPermissionsDialog.tsx +231 -0
  34. package/src/components/ui/DiagnosticsOverlay.tsx +1 -0
  35. package/src/components/workload/WorkloadView.tsx +107 -3
  36. package/src/context/NavCustomization.tsx +13 -0
  37. package/src/contexts/CapabilitiesContext.tsx +8 -3
  38. package/src/components/gitops/RollbackDialog.tsx +0 -107
  39. package/src/components/gitops/SyncOptionsDialog.tsx +0 -144
@@ -1,35 +1,44 @@
1
- import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
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
- GitOpsIssuesBand,
8
+ GitOpsDetailLayout,
9
+ GitOpsGraphFilterRail,
10
+ GitOpsTableView as SharedGitOpsTableView,
11
11
  GitOpsTreeGraph,
12
- GitOpsStatusStrip,
13
- HealthStatusBadge,
14
- SyncStatusBadge,
15
- formatCompactAge,
16
- formatRelativeAgeTime,
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
- const [mode, setMode] = useState<GitOpsMode>('applications')
142
- const [viewMode, setViewMode] = useState<GitOpsViewMode>('table')
143
- const [search, setSearch] = useState('')
144
- const [syncFilters, setSyncFilters] = useState<Set<string>>(new Set())
145
- const [healthFilters, setHealthFilters] = useState<Set<string>>(new Set())
146
- const [projectFilters, setProjectFilters] = useState<Set<string>>(new Set())
147
- const [namespaceFilters, setNamespaceFilters] = useState<Set<string>>(new Set())
148
- const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set())
149
- const [showLabelsDropdown, setShowLabelsDropdown] = useState(false)
150
- const [labelSearch, setLabelSearch] = useState('')
151
- const [automationFilter, setAutomationFilter] = useState<'all' | 'auto' | 'manual' | 'suspended'>('all')
152
- // Lifecycle filter: surface zombies (terminating but stuck) and let the
153
- // user filter them in/out. Default is 'all' so the fleet doesn't hide
154
- // problem resources by accident; 'terminating' focuses to investigate
155
- // stuck cleanups; 'active' hides them when the user wants to ignore
156
- // resources that are on their way out.
157
- const [lifecycleFilter, setLifecycleFilter] = useState<'all' | 'terminating' | 'active'>('all')
158
- const [sortKey, setSortKey] = useState<SortKey>('health')
159
-
160
- useRegisterShortcut({
161
- id: 'gitops-focus-search',
162
- keys: '/',
163
- category: 'GitOps',
164
- description: 'Focus GitOps search',
165
- scope: 'gitops',
166
- handler: (event) => {
167
- event.preventDefault()
168
- searchInputRef.current?.focus()
169
- },
170
- allowInInputs: false,
171
- })
172
-
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
- const applicationQuery = useQuery({
185
- queryKey: ['gitops-applications-main', namespaces, apiResources?.length ?? 0],
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
- <section className="border-b border-theme-border px-3 py-2">
634
- <div className="mb-1.5 flex items-center gap-2">
635
- <Icon className="h-3.5 w-3.5 text-theme-text-tertiary" />
636
- <span className="text-[10px] font-medium uppercase tracking-wider text-theme-text-tertiary">{title}</span>
637
- </div>
638
- <div className="space-y-0.5">{children}</div>
639
- </section>
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 ? getGitOpsStatus(kind, resourceQ.data) : null
1084
- const tool = getTool(kind, group)
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 = describeTerminating(insightsQ.data?.summary)
236
+ const terminatingDescriptions = describeGitOpsTerminating(insightsQ.data?.summary)
1112
237
  const terminatingChipTooltip = terminatingDescriptions.chipTooltip
1113
238
  const terminatingActionTooltip = terminatingDescriptions.actionDisabledTooltip
1114
- const [appView, setAppView] = useState<GitOpsAppView>('topology')
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
- <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-theme-base">
1258
- {!graphFullscreen && <div className="shrink-0 border-b border-theme-border bg-theme-base px-4 py-3">
1259
- <div className="flex flex-wrap items-start justify-between gap-3">
1260
- <div className="min-w-0">
1261
- {/* Breadcrumb collapses into the title row: parent ("GitOps") +
1262
- resource name on one line. Tool + Kind are *properties* of
1263
- this resource, not navigation, so they live as a neutral
1264
- chip alongside the status badges instead of as breadcrumb
1265
- segments. Future nested cases (app-of-apps) can extend the
1266
- breadcrumb with intermediate parent links here. */}
1267
- <div className="flex flex-wrap items-center gap-2">
1268
- <button
1269
- type="button"
1270
- onClick={() => navigate('/gitops')}
1271
- className="shrink-0 text-xs font-medium text-sky-500 transition-colors hover:text-sky-400"
1272
- >
1273
- GitOps
1274
- </button>
1275
- <span className="shrink-0 text-xs text-theme-text-tertiary">/</span>
1276
- {/* Parent breadcrumb segment when the user opened this CR
1277
- from inside another CR's tree (app-of-apps, Flux
1278
- Kustomization-applies-Kustomization, etc). Without this,
1279
- navigation between nested GitOps surfaces feels like
1280
- unrelated jumps; with it, the lineage is always visible
1281
- and clickable. The segment renders smaller than the
1282
- current title to avoid competing for visual weight. */}
1283
- {parent && (
1284
- <>
1285
- <button
1286
- type="button"
1287
- onClick={() => {
1288
- const params = new URLSearchParams()
1289
- if (parent.group) params.set('apiGroup', parent.group)
1290
- navigate({
1291
- pathname: gitOpsDetailPath(parent.kind, parent.namespace || '_', parent.name),
1292
- search: params.toString(),
1293
- })
1294
- }}
1295
- className="shrink-0 truncate max-w-[200px] text-xs font-medium text-sky-500 transition-colors hover:text-sky-400"
1296
- title={`Open parent: ${parent.namespace ? `${parent.namespace}/` : ''}${parent.name}`}
1297
- >
1298
- {parent.namespace ? `${parent.namespace}/` : ''}{parent.name}
1299
- </button>
1300
- <span className="shrink-0 text-xs text-theme-text-tertiary">/</span>
1301
- </>
1302
- )}
1303
- <h1 className="min-w-0 truncate text-lg font-semibold text-theme-text-primary">
1304
- {namespace ? `${namespace}/` : ''}{name}
1305
- </h1>
1306
- {/* When Terminating, suppress Sync/Health badges entirely.
1307
- They reflect the last observed reconcile state and become
1308
- factually stale the moment deletion is initiated — Flux
1309
- is processing finalizers, not syncing, and the resource
1310
- isn't "Progressing" toward a healthy state, it's being
1311
- torn down. Showing "Syncing · Progressing · Terminating"
1312
- side-by-side is contradictory and actively misleading;
1313
- Argo CD's own UI suppresses these badges for the same
1314
- reason. The Terminating chip becomes the sole status
1315
- indicator. */}
1316
- {status && !terminating && (
1317
- <>
1318
- <SyncStatusBadge sync={status.sync} suspended={effectiveSuspended} />
1319
- {!effectiveSuspended && <HealthStatusBadge health={status.health} />}
1320
- </>
1321
- )}
1322
- {terminating && (
1323
- <Tooltip content={terminatingChipTooltip}>
1324
- <span className="badge border border-orange-500/40 bg-orange-500/10 text-orange-400">
1325
- <Trash2 className="h-3 w-3" />
1326
- Terminating
1327
- </span>
1328
- </Tooltip>
1329
- )}
1330
- <span className="inline-flex shrink-0 items-center rounded border border-theme-border bg-theme-hover/50 px-1.5 py-0.5 text-[11px] font-medium text-theme-text-secondary">
1331
- {tool === 'argo' ? 'ArgoCD' : 'FluxCD'} · {apiKind?.kind ?? kind}
1332
- </span>
1333
- </div>
1334
- {/* Header carries the *spec/config* facts — where this app lives
1335
- and how it syncs. The deployment row (status strip below)
1336
- carries dynamic per-deploy facts (latest revision, age,
1337
- resource health). Splitting static config from live state
1338
- avoids the "I changed nothing but everything looks different"
1339
- effect of mixing them. */}
1340
- <div className="mt-2 flex flex-wrap gap-x-5 gap-y-0.5 text-[11px] text-theme-text-tertiary">
1341
- <AppFact label="Project" value={detailRow?.project || '-'} />
1342
- {detailRow?.repository && <AppFact label="Source" value={formatSourceRepo(detailRow.repository)} />}
1343
- {detailRow?.path && <AppFact label="Path" value={detailRow.path} />}
1344
- {detailRow?.chart && <AppFact label="Chart" value={detailRow.chart} />}
1345
- <AppFact label="Destination" value={formatDestination(detailRow?.destination, detailRow?.destinationNamespace)} />
1346
- {insightsQ.data?.summary?.autoSyncMode && (
1347
- <AppFact label="Sync mode" value={insightsQ.data.summary.autoSyncMode} />
1348
- )}
1349
- </div>
1350
- </div>
1351
- <div className="flex flex-wrap items-center gap-2">
1352
- {isArgoApp && (
1353
- <>
1354
- <ActionButton label="Sync…" description="Apply manifests from Git to the cluster. Opens an options dialog (prune, dry-run, revision)." icon={RefreshCw} loading={argoSync.isPending} onClick={() => setSyncDialogOpen(true)} disabled={effectiveSuspended || terminating} disabledReason={terminating ? terminatingActionTooltip : undefined} primary />
1355
- <ActionButton
1356
- label="Refresh"
1357
- description="Re-check Git for new commits and recompute sync status. Doesn't apply anything."
1358
- icon={RotateCw}
1359
- loading={argoRefresh.isPending && refreshKind === 'normal'}
1360
- onClick={() => {
1361
- setRefreshKind('normal')
1362
- argoRefresh.mutate({ namespace, name, hard: false })
1363
- }}
1364
- />
1365
- <ActionButton
1366
- label="Hard refresh"
1367
- description="Like Refresh, but also bypasses Argo's manifest cache (re-renders Helm/Kustomize)."
1368
- icon={RotateCw}
1369
- loading={argoRefresh.isPending && refreshKind === 'hard'}
1370
- onClick={() => {
1371
- setRefreshKind('hard')
1372
- argoRefresh.mutate({ namespace, name, hard: true })
1373
- }}
1374
- />
1375
- {isRunning && <ActionButton label="Terminate" description="Cancel the in-progress sync operation." icon={XCircle} loading={argoTerminate.isPending} onClick={() => argoTerminate.mutate({ namespace, name })} danger />}
1376
- {argoAutoSyncEnabled
1377
- ? <ActionButton label="Disable auto-sync" description="Stop Argo from automatically syncing Git changes. Manual Sync still works." icon={Pause} loading={argoSuspend.isPending} onClick={() => argoSuspend.mutate({ namespace, name })} disabled={terminating} disabledReason={terminating ? terminatingActionTooltip : undefined} />
1378
- : <ActionButton label="Enable auto-sync" description="Re-enable automatic syncing of Git changes to the cluster." icon={Play} loading={argoResume.isPending} onClick={() => argoResume.mutate({ namespace, name })} disabled={terminating} disabledReason={terminating ? terminatingActionTooltip : undefined} />}
1379
- </>
1380
- )}
1381
- {isFlux && (
1382
- <>
1383
- <ActionButton label="Reconcile" description="Tell Flux to reconcile this resource now: re-read its source, re-apply the manifests, update status. Skips waiting for the regular reconciliation interval." icon={RefreshCw} loading={fluxReconcile.isPending} onClick={() => fluxReconcile.mutate({ kind, namespace, name })} disabled={effectiveSuspended || terminating} disabledReason={terminating ? terminatingActionTooltip : undefined} primary />
1384
- {isFluxWorkload && (
1385
- <ActionButton
1386
- label="Sync with source"
1387
- description="Reconcile the upstream source CR (GitRepository/HelmRepository) first — re-fetching from Git or Helm — then reconcile this resource against the refreshed source. Useful right after pushing a commit when you don't want to wait for the source's poll interval."
1388
- icon={GitCommit}
1389
- loading={fluxSyncWithSource.isPending}
1390
- onClick={() => fluxSyncWithSource.mutate({ kind, namespace, name })}
1391
- disabled={terminating}
1392
- disabledReason={terminating ? terminatingActionTooltip : undefined}
1393
- />
1394
- )}
1395
- {status?.suspended
1396
- ? <ActionButton label="Resume" description="Resume Flux reconciliation. Flux will start applying changes from the source again on its normal interval." icon={Play} loading={fluxResume.isPending} onClick={() => fluxResume.mutate({ kind, namespace, name })} disabled={terminating} disabledReason={terminating ? terminatingActionTooltip : undefined} />
1397
- : <ActionButton label="Suspend" description="Pause Flux reconciliation. The resource stays exactly as-is — new commits in the source won't be applied until you resume." icon={Pause} loading={fluxSuspend.isPending} onClick={() => fluxSuspend.mutate({ kind, namespace, name })} disabled={terminating} disabledReason={terminating ? terminatingActionTooltip : undefined} />}
1398
- </>
1399
- )}
1400
- </div>
1401
- </div>
1402
- </div>}
1403
- {!graphFullscreen && (
1404
- <>
1405
- <GitOpsStatusStrip insight={insightsQ.data} loading={insightsQ.isLoading} />
1406
- <GitOpsIssuesBand
1407
- issues={insightsQ.data?.issues}
1408
- terminating={terminating}
1409
- onSelectIssue={(issue) => {
1410
- const ref = issue.refs?.[0]
1411
- if (!ref) return
1412
- setAppView('changes')
1413
- setChangesFocusKey(insightChangeKey(ref))
1414
- // Window the highlight: 4s is long enough to find the row
1415
- // visually but short enough that it doesn't linger if the user
1416
- // navigates away and comes back.
1417
- window.setTimeout(() => setChangesFocusKey(null), 4000)
1418
- }}
1419
- remediationPending={applyResource.isPending || argoSync.isPending}
1420
- onRemediate={(remediation) => {
1421
- if (remediation.kind === 'create-namespace' && remediation.target) {
1422
- const nsName = remediation.target
1423
- const yaml = `apiVersion: v1\nkind: Namespace\nmetadata:\n name: ${nsName}\n`
1424
- applyResource.mutate(
1425
- { yaml, mode: 'apply' },
1426
- {
1427
- onSuccess: () => {
1428
- // Defer the success toast until we know whether the
1429
- // follow-on sync was triggered, so a successful
1430
- // create-namespace followed by a sync failure doesn't
1431
- // produce a "Triggering a sync" toast that argoSync's
1432
- // own error toast immediately contradicts.
1433
- if (kind === 'applications') {
1434
- argoSync.mutate(
1435
- { namespace, name },
1436
- {
1437
- onSuccess: () => {
1438
- showSuccess(
1439
- `Created namespace ${nsName}`,
1440
- 'Sync triggered to retry the apply.',
1441
- )
1442
- },
1443
- onError: () => {
1444
- // argoSync's mutation meta already raises its
1445
- // own "Failed to trigger sync" toast; here we
1446
- // tell the user explicitly that the *namespace*
1447
- // landed even though the sync didn't, so they
1448
- // know to click Sync manually.
1449
- showSuccess(
1450
- `Created namespace ${nsName}`,
1451
- "Couldn't trigger sync automatically — click Sync to retry.",
1452
- )
1453
- },
1454
- },
1455
- )
1456
- } else {
1457
- // Non-Argo callers don't get the auto-sync chain; the
1458
- // create-namespace landing is the only thing to report.
1459
- showSuccess(`Created namespace ${nsName}`)
1460
- }
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
- onError: (err: unknown) => {
1463
- const msg = err instanceof Error ? err.message : 'Unknown error'
1464
- showError(
1465
- `Couldn't create namespace ${nsName}`,
1466
- msg.includes('forbidden')
1467
- ? 'Radar lacks RBAC to create namespaces in this cluster. Create it manually or have a cluster-admin do it.'
1468
- : msg,
1469
- )
1470
- },
1471
- },
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
- {helmValues && (
1477
- <div className="shrink-0 border-b border-theme-border bg-theme-base">
1478
- <button
1479
- type="button"
1480
- onClick={() => setHelmValuesOpen((v) => !v)}
1481
- className="flex w-full items-center gap-2 px-4 py-2 text-left text-xs text-theme-text-secondary hover:bg-theme-hover"
1482
- aria-expanded={helmValuesOpen}
1483
- >
1484
- {helmValuesOpen ? (
1485
- <ChevronDown className="h-3.5 w-3.5 shrink-0" />
1486
- ) : (
1487
- <ChevronRight className="h-3.5 w-3.5 shrink-0" />
1488
- )}
1489
- <Settings className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
1490
- <span className="font-medium text-theme-text-primary">Helm values</span>
1491
- <span className="tabular-nums text-theme-text-tertiary">
1492
- {helmValues.keyCount} {helmValues.keyCount === 1 ? 'key' : 'keys'}
1493
- </span>
1494
- {helmValues.source === 'argo-parameters' && (
1495
- <span className="text-[10px] uppercase tracking-wider text-theme-text-tertiary">parameters</span>
1496
- )}
1497
- </button>
1498
- {helmValuesOpen && (
1499
- <div className="border-t border-theme-border bg-theme-surface px-4 py-3">
1500
- <CodeViewer code={helmValues.yaml} language="yaml" showLineNumbers maxHeight="320px" />
1501
- </div>
1502
- )}
1503
- </div>
1504
- )}
1505
- </>
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
- {resourceQ.isLoading ? (
1509
- <div className="flex flex-1 items-center justify-center text-theme-text-secondary">
1510
- <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading GitOps resource…
1511
- </div>
1512
- ) : resourceQ.error ? (
1513
- <div className="p-4 text-sm text-red-500">Failed to load resource: {(resourceQ.error as Error).message}</div>
1514
- ) : (
1515
- <div className={graphShellClass}>
1516
- <div className="flex shrink-0 items-center justify-between gap-3 border-b border-theme-border bg-theme-base px-4 py-2">
1517
- <div className="flex items-center gap-1 rounded-md border border-theme-border bg-theme-surface p-1">
1518
- <ViewButton active={appView === 'topology'} icon={GitBranch} label="Topology" onClick={() => setAppView('topology')} />
1519
- <ViewButton active={appView === 'changes'} icon={GitCommit} label="Resources" onClick={() => setAppView('changes')} />
1520
- <ViewButton active={appView === 'activity'} icon={Clock3} label="Activity" onClick={() => setAppView('activity')} />
1521
- </div>
1522
- {graphFullscreen ? (
1523
- <div className="min-w-0 flex-1 truncate text-sm font-medium text-theme-text-primary">
1524
- {namespace ? `${namespace}/` : ''}{name}
1525
- </div>
1526
- ) : (
1527
- appView === 'topology' && tree && <TopologyCounts tree={tree} />
1528
- )}
1529
- <div className="flex items-center gap-2">
1530
- {appView === 'topology' && (
1531
- <>
1532
- <button
1533
- type="button"
1534
- onClick={() => {
1535
- setGraphSearch('')
1536
- setGraphKinds(new Set())
1537
- setGraphSync(new Set())
1538
- setGraphHealth(new Set())
1539
- setGraphNamespaces(new Set())
1540
- setGraphRoles(new Set())
1541
- }}
1542
- className="rounded px-2 py-1 text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
1543
- >
1544
- Clear filters
1545
- </button>
1546
- <button
1547
- type="button"
1548
- onClick={() => setGraphFullscreen(!graphFullscreen)}
1549
- className="rounded-md border border-theme-border bg-theme-surface px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
1550
- >
1551
- {graphFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
1552
- </button>
1553
- </>
1554
- )}
1555
- </div>
1556
- </div>
1557
-
1558
- {appView === 'activity' ? (
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 (parseRollbackID(item.id) == null) return
543
+ if (parseArgoRollbackID(item.id) == null) return
1568
544
  setRollbackTarget(item)
1569
545
  } : undefined}
1570
546
  />
1571
- ) : appView === 'changes' ? (
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
- <div className="grid min-h-0 min-w-0 flex-1 grid-cols-[280px_minmax(0,1fr)] max-lg:grid-cols-1">
1581
- <GitOpsGraphFilterRail
1582
- facets={graphFacets}
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
- search={graphSearch}
1586
- onSearchChange={setGraphSearch}
1587
- kinds={graphKinds}
1588
- onToggleKind={(value) => toggleSet(graphKinds, setGraphKinds, value)}
1589
- sync={graphSync}
1590
- onToggleSync={(value) => toggleSet(graphSync, setGraphSync, value)}
1591
- health={graphHealth}
1592
- onToggleHealth={(value) => toggleSet(graphHealth, setGraphHealth, value)}
1593
- namespaces={graphNamespaces}
1594
- onToggleNamespace={(value) => toggleSet(graphNamespaces, setGraphNamespaces, value)}
1595
- roles={graphRoles}
1596
- onToggleRole={(value) => toggleSet(graphRoles, setGraphRoles, value)}
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
- </div>
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
- // Defensive: history may have refreshed between modal-open and
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
- </div>
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
- // formatDestination collapses the canonical in-cluster API server URL
1736
- // ("https://kubernetes.default.svc" or the variant Argo writes when the
1737
- // destination is the same cluster the controller runs in) to the friendlier
1738
- // "in-cluster". Other server values pass through unchanged. The namespace
1739
- // is appended with an explicit "Namespace:" qualifier so the relationship
1740
- // reads unambiguously — bare `/ ns` could be mistaken for a sub-path.
1741
- function formatDestination(server: string | undefined, namespace: string | undefined): string {
1742
- let host = (server || '').trim()
1743
- if (host === '' || host === 'https://kubernetes.default.svc' || host === 'in-cluster') {
1744
- host = 'in-cluster'
1745
- } else {
1746
- host = host.replace(/^https?:\/\//, '')
1747
- }
1748
- return namespace ? `${host}, Namespace: ${namespace}` : host
1749
- }
1750
-
1751
- function AppFact({ label, value }: { label: string; value: string }) {
1752
- // inline-flex (not flex) so each fact sizes to its content; the parent's
1753
- // flex-wrap handles row breaks at narrow viewports. max-w-full is the
1754
- // safety net for the rare case of a single value wider than the screen
1755
- // (truncate + tooltip kicks in then). Without it, very long destinations
1756
- // would force the page to scroll horizontally.
1757
- return (
1758
- <span className="inline-flex min-w-0 max-w-full items-baseline gap-1">
1759
- <span className="shrink-0 text-theme-text-tertiary">{label}:</span>
1760
- <Tooltip content={value} delay={400} wrapperClassName="min-w-0">
1761
- <span className="block truncate text-theme-text-primary">{value}</span>
1762
- </Tooltip>
1763
- </span>
1764
- )
1765
- }
1766
-
1767
- function ViewButton({
1768
- active,
1769
- icon: Icon,
1770
- label,
1771
- onClick,
1772
- }: {
1773
- active: boolean
1774
- icon: ComponentType<{ className?: string }>
1775
- label: string
1776
- onClick: () => void
1777
- }) {
1778
- return (
1779
- <button
1780
- type="button"
1781
- onClick={onClick}
1782
- className={`inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors ${
1783
- active
1784
- ? 'bg-skyhook-500 text-white'
1785
- : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
1786
- }`}
1787
- >
1788
- <Icon className="h-3.5 w-3.5" />
1789
- {label}
1790
- </button>
1791
- )
1792
- }
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 = getGitOpsStatus(kind, resource)
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: getTool(kind, group),
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
- }