@skyhook-io/radar-app 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -0,0 +1,1042 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { useLocation, useNavigate } from 'react-router-dom'
3
+ import { useQuery } from '@tanstack/react-query'
4
+ import yaml from 'yaml'
5
+ import {
6
+ GitOpsActivityInsightView,
7
+ GitOpsChangesView,
8
+ GitOpsDetailLayout,
9
+ GitOpsGraphFilterRail,
10
+ GitOpsTableView as SharedGitOpsTableView,
11
+ FreshnessControl,
12
+ GitOpsTreeGraph,
13
+ RollbackDialog,
14
+ SyncOptionsDialog,
15
+ buildFluxSourceUrlMap,
16
+ buildTreeFacets,
17
+ describeGitOpsTerminating,
18
+ formatGitOpsDestination,
19
+ formatGitOpsSourceUrl,
20
+ getGitOpsResourceStatus,
21
+ getGitOpsTool,
22
+ isArgoOperationInProgress,
23
+ isArgoSuspendedByRadar,
24
+ gitOpsInsightChangeKey,
25
+ initNavigationMap,
26
+ kindToPlural,
27
+ normalizeArgoApplication,
28
+ normalizeFluxHelmRelease,
29
+ normalizeFluxKustomization,
30
+ parseArgoRollbackID,
31
+ toggleSet,
32
+ type APIResource,
33
+ type ArgoActionHandlers,
34
+ type FluxActionHandlers,
35
+ type GitOpsDetailMetadata,
36
+ type GitOpsDetailTab,
37
+ type GitOpsResourceTree,
38
+ type GitOpsInsightRef,
39
+ type GitOpsRow,
40
+ type GitOpsRowAction,
41
+ type GitOpsTreeFilters,
42
+ type GitOpsTreeRef,
43
+ type GitOpsTreePreset,
44
+ type SelectedResource,
45
+ } from '@skyhook-io/k8s-ui'
46
+ import { useToast } from '../ui/Toast'
47
+
48
+ import {
49
+ fetchJSON,
50
+ buildArgoResourceSyncVars,
51
+ useApplyResource,
52
+ useArgoRefresh,
53
+ useArgoResourceValidation,
54
+ useArgoResume,
55
+ useArgoRollback,
56
+ useArgoSuspend,
57
+ useArgoSync,
58
+ useArgoTerminate,
59
+ useFluxReconcile,
60
+ useFluxResume,
61
+ useFluxSuspend,
62
+ useFluxSyncWithSource,
63
+ useGitOpsInsights,
64
+ useGitOpsTree,
65
+ useResource,
66
+ } from '../../api/client'
67
+ import { useAPIResources } from '../../api/apiResources'
68
+ import { useConnection } from '../../context/ConnectionContext'
69
+ import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
70
+ import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
71
+ import { CodeViewer } from '../ui/CodeViewer'
72
+ import { ArgoResourceDiffLoader } from './ArgoResourceDiffLoader'
73
+ import { RevisionMetaChip } from './RevisionMetaChip'
74
+ import type { GitOpsHistoryItem } from '@skyhook-io/k8s-ui'
75
+
76
+ const GITOPS_KINDS: APIResource[] = [
77
+ { name: 'applications', kind: 'Application', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
78
+ { name: 'applicationsets', kind: 'ApplicationSet', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
79
+ { name: 'appprojects', kind: 'AppProject', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
80
+ { name: 'kustomizations', kind: 'Kustomization', group: 'kustomize.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
81
+ { name: 'helmreleases', kind: 'HelmRelease', group: 'helm.toolkit.fluxcd.io', version: 'v2', namespaced: true, verbs: ['list', 'get'], isCrd: true },
82
+ { name: 'gitrepositories', kind: 'GitRepository', group: 'source.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
83
+ { name: 'ocirepositories', kind: 'OCIRepository', group: 'source.toolkit.fluxcd.io', version: 'v1beta2', namespaced: true, verbs: ['list', 'get'], isCrd: true },
84
+ { name: 'helmrepositories', kind: 'HelmRepository', group: 'source.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
85
+ { name: 'alerts', kind: 'Alert', group: 'notification.toolkit.fluxcd.io', version: 'v1beta3', namespaced: true, verbs: ['list', 'get'], isCrd: true },
86
+ ]
87
+
88
+ type ArgoSyncDialogTarget =
89
+ | { scope: 'application' }
90
+ | { scope: 'resource'; resource: GitOpsInsightRef }
91
+
92
+ const KIND_BY_NAME = new Map(GITOPS_KINDS.map((k) => [k.name, k]))
93
+
94
+ // Rows are the table's primary content; their poll cadence is what the toolbar
95
+ // freshness signal advertises ("Auto-refreshes every 2m"). Single source of
96
+ // truth so the signal can't drift from the actual refetchInterval below.
97
+ const GITOPS_ROWS_REFRESH_INTERVAL_MS = 120_000
98
+
99
+ interface ResourceCountsResponse {
100
+ counts: Record<string, number>
101
+ forbidden?: string[]
102
+ unavailable?: string[]
103
+ }
104
+
105
+ interface GitOpsViewProps {
106
+ namespaces: string[]
107
+ onOpenResource: (resource: SelectedResource) => void
108
+ onClearNamespaces?: () => void
109
+ // Opens the global Settings dialog — backs the "Connect Argo CD" hint on the
110
+ // Changes tab of an Argo Application detail page.
111
+ onOpenSettings?: () => void
112
+ }
113
+
114
+ export function GitOpsView({ namespaces, onOpenResource, onClearNamespaces, onOpenSettings }: GitOpsViewProps) {
115
+ const location = useLocation()
116
+ if (location.pathname.startsWith('/gitops/detail/')) {
117
+ return <GitOpsDetailView namespaces={namespaces} onOpenResource={onOpenResource} onOpenSettings={onOpenSettings} />
118
+ }
119
+ return <GitOpsTableView namespaces={namespaces} onClearNamespaces={onClearNamespaces} />
120
+ }
121
+
122
+ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string[]; onClearNamespaces?: () => void }) {
123
+ const navigate = useNavigate()
124
+ const { connection } = useConnection()
125
+ const namespacesParam = namespaces.join(',')
126
+ const { data: apiResources, isLoading: apiResourcesLoading } = useAPIResources()
127
+
128
+ const argoSync = useArgoSync()
129
+ const argoRefresh = useArgoRefresh()
130
+ const argoTerminate = useArgoTerminate()
131
+ const argoSuspend = useArgoSuspend()
132
+ const argoResume = useArgoResume()
133
+ const fluxReconcile = useFluxReconcile()
134
+ const fluxSyncWithSource = useFluxSyncWithSource()
135
+ const fluxSuspend = useFluxSuspend()
136
+ const fluxResume = useFluxResume()
137
+
138
+ const [syncDialogRow, setSyncDialogRow] = useState<GitOpsRow | null>(null)
139
+ const [pendingActions, setPendingActions] = useState<Map<string, Set<GitOpsRowAction>>>(new Map())
140
+
141
+ // Mark an action as in-flight (or done) for a given row. Cloning the
142
+ // outer Map + inner Set keeps the state immutable so React rerenders
143
+ // and the per-item spinner flips at the right moment.
144
+ function markAction(rowId: string, action: GitOpsRowAction, on: boolean) {
145
+ setPendingActions((prev) => {
146
+ const next = new Map(prev)
147
+ const current = new Set(next.get(rowId) ?? [])
148
+ if (on) current.add(action)
149
+ else current.delete(action)
150
+ if (current.size === 0) next.delete(rowId)
151
+ else next.set(rowId, current)
152
+ return next
153
+ })
154
+ }
155
+
156
+ useEffect(() => {
157
+ initNavigationMap([...(apiResources ?? []), ...GITOPS_KINDS])
158
+ }, [apiResources])
159
+
160
+ const hasGitOpsRowResource = useMemo(() => (
161
+ hasAPIResource(apiResources, 'applications', 'argoproj.io') ||
162
+ hasAPIResource(apiResources, 'kustomizations', 'kustomize.toolkit.fluxcd.io') ||
163
+ hasAPIResource(apiResources, 'helmreleases', 'helm.toolkit.fluxcd.io')
164
+ ), [apiResources])
165
+
166
+ // Counts come from radar's /api/resource-counts. The extracted
167
+ // GitOpsTableView reads only the GitOps keys for mode tabs + empty-state
168
+ // checks.
169
+ const countsQuery = useQuery({
170
+ queryKey: ['gitops-resource-counts', namespacesParam],
171
+ queryFn: async () => {
172
+ const params = new URLSearchParams()
173
+ if (namespaces.length > 0) params.set('namespaces', namespacesParam)
174
+ return fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
175
+ },
176
+ staleTime: 10_000,
177
+ refetchInterval: 60_000,
178
+ })
179
+
180
+ // Row-producing fetch: Applications + Kustomizations + HelmReleases,
181
+ // plus the Flux source CRs (GitRepository / HelmRepository /
182
+ // OCIRepository / Bucket) for sourceRef→URL resolution. We skip per-
183
+ // kind requests when the cluster doesn't have the CRD installed; the
184
+ // capability map comes from useAPIResources.
185
+ const rowsQuery = useQuery({
186
+ queryKey: ['gitops-rows-main', namespaces, apiResources?.length ?? 0],
187
+ queryFn: async () => {
188
+ const hasApplications = hasAPIResource(apiResources, 'applications', 'argoproj.io')
189
+ const hasKustomizations = hasAPIResource(apiResources, 'kustomizations', 'kustomize.toolkit.fluxcd.io')
190
+ const hasHelmReleases = hasAPIResource(apiResources, 'helmreleases', 'helm.toolkit.fluxcd.io')
191
+ const hasFluxSources = hasKustomizations || hasHelmReleases
192
+ const hasGitRepos = hasFluxSources && hasAPIResource(apiResources, 'gitrepositories', 'source.toolkit.fluxcd.io')
193
+ const hasHelmRepos = hasFluxSources && hasAPIResource(apiResources, 'helmrepositories', 'source.toolkit.fluxcd.io')
194
+ const hasOCIRepos = hasFluxSources && hasAPIResource(apiResources, 'ocirepositories', 'source.toolkit.fluxcd.io')
195
+ const hasBuckets = hasFluxSources && hasAPIResource(apiResources, 'buckets', 'source.toolkit.fluxcd.io')
196
+ const [applications, kustomizations, helmReleases, gitRepos, helmRepos, ociRepos, buckets] = await Promise.all([
197
+ hasApplications ? fetchResourceList('applications', 'argoproj.io', namespacesParam) : Promise.resolve([]),
198
+ hasKustomizations ? fetchResourceList('kustomizations', 'kustomize.toolkit.fluxcd.io', namespacesParam) : Promise.resolve([]),
199
+ hasHelmReleases ? fetchResourceList('helmreleases', 'helm.toolkit.fluxcd.io', namespacesParam) : Promise.resolve([]),
200
+ hasGitRepos ? fetchResourceList('gitrepositories', 'source.toolkit.fluxcd.io', '') : Promise.resolve([]),
201
+ hasHelmRepos ? fetchResourceList('helmrepositories', 'source.toolkit.fluxcd.io', '') : Promise.resolve([]),
202
+ hasOCIRepos ? fetchResourceList('ocirepositories', 'source.toolkit.fluxcd.io', '') : Promise.resolve([]),
203
+ hasBuckets ? fetchResourceList('buckets', 'source.toolkit.fluxcd.io', '') : Promise.resolve([]),
204
+ ])
205
+ const fluxSourceUrls = buildFluxSourceUrlMap([...gitRepos, ...helmRepos, ...ociRepos, ...buckets])
206
+ return [
207
+ ...applications.map((r) => normalizeArgoApplication(r)),
208
+ ...kustomizations.map((r) => normalizeFluxKustomization(r, fluxSourceUrls)),
209
+ ...helmReleases.map((r) => normalizeFluxHelmRelease(r, fluxSourceUrls)),
210
+ ]
211
+ },
212
+ enabled: !apiResourcesLoading,
213
+ staleTime: 30_000,
214
+ refetchInterval: GITOPS_ROWS_REFRESH_INTERVAL_MS,
215
+ })
216
+
217
+ // Row mutations invalidate granular keys (['resource', …], ['gitops-tree', …])
218
+ // that don't match the table's aggregate gitops-rows-main / counts queries,
219
+ // so refetch those explicitly — otherwise a row keeps showing the pre-action
220
+ // state (e.g. "Suspend" after a successful suspend) until the 120s poll,
221
+ // inviting a duplicate request. Radar serves reads from an informer cache that
222
+ // lags the write by the watch-propagation delay, so refetch once now (covers
223
+ // an already-current cache) and once shortly after to catch the propagated
224
+ // update; refetch() forces a fetch regardless of staleTime. The toolbar's
225
+ // manual refresh reuses refetchTable so rows + counts stay in sync.
226
+ // Return the combined promise so the toolbar's refresh animation waits for the
227
+ // real fetches to settle before showing its success checkmark.
228
+ const refetchTable = () => Promise.all([rowsQuery.refetch(), countsQuery.refetch()])
229
+ const refetchTableAfterMutation = () => {
230
+ refetchTable()
231
+ window.setTimeout(refetchTable, 1200)
232
+ }
233
+
234
+ // Cold-cache catch-up: right after a cluster/namespace switch (or first open)
235
+ // the GitOps CRD informers can still be warming, so the first list resolves
236
+ // EMPTY even though apps exist — stranding the user on "No applications found"
237
+ // until the 120s poll (which is why a manual Refresh "fixes" it). When the
238
+ // fetch settles empty, briefly retry (bounded) to catch the cache as it syncs.
239
+ // Reset the budget whenever the cluster (apiResources identity) or namespace
240
+ // scope changes so each switch gets a fresh set of retries.
241
+ const coldRetriesRef = useRef(0)
242
+ // While we're still retrying a cold cache, the view shows a spinner (not the
243
+ // false "No applications found") — so the user sees "loading", not "empty".
244
+ const [coldRetrying, setColdRetrying] = useState(false)
245
+ useEffect(() => { coldRetriesRef.current = 0; setColdRetrying(false) }, [apiResources, namespacesParam])
246
+ useEffect(() => {
247
+ if (!hasGitOpsRowResource || rowsQuery.error) { setColdRetrying(false); return }
248
+ if (apiResourcesLoading || rowsQuery.isFetching) return
249
+ if ((rowsQuery.data?.length ?? 0) > 0) { setColdRetrying(false); return }
250
+ if (coldRetriesRef.current >= 4) { setColdRetrying(false); return }
251
+ setColdRetrying(true)
252
+ const t = window.setTimeout(() => { coldRetriesRef.current += 1; refetchTable() }, 2000)
253
+ return () => window.clearTimeout(t)
254
+ // eslint-disable-next-line react-hooks/exhaustive-deps
255
+ }, [rowsQuery.data, rowsQuery.isFetching, rowsQuery.error, apiResourcesLoading, hasGitOpsRowResource])
256
+
257
+ const handleRowAction = (row: GitOpsRow, action: GitOpsRowAction) => {
258
+ const { kindName: kind, namespace, name, id } = row
259
+ const settle = { onSuccess: refetchTableAfterMutation, onSettled: () => markAction(id, action, false) }
260
+ markAction(id, action, true)
261
+ switch (action) {
262
+ case 'sync':
263
+ // Argo Sync is the one action that confirms — same dialog the
264
+ // detail page uses. The mutation fires from onConfirm; clear the
265
+ // in-flight flag here since the dialog now owns the lifecycle.
266
+ markAction(id, action, false)
267
+ setSyncDialogRow(row)
268
+ return
269
+ case 'refresh':
270
+ argoRefresh.mutate({ namespace, name, hard: false }, settle)
271
+ return
272
+ case 'hard-refresh':
273
+ argoRefresh.mutate({ namespace, name, hard: true }, settle)
274
+ return
275
+ case 'terminate':
276
+ argoTerminate.mutate({ namespace, name }, settle)
277
+ return
278
+ case 'suspend':
279
+ if (row.tool === 'argo') argoSuspend.mutate({ namespace, name }, settle)
280
+ else fluxSuspend.mutate({ kind, namespace, name }, settle)
281
+ return
282
+ case 'resume':
283
+ if (row.tool === 'argo') argoResume.mutate({ namespace, name }, settle)
284
+ else fluxResume.mutate({ kind, namespace, name }, settle)
285
+ return
286
+ case 'reconcile':
287
+ fluxReconcile.mutate({ kind, namespace, name }, settle)
288
+ return
289
+ case 'sync-with-source':
290
+ fluxSyncWithSource.mutate({ kind, namespace, name }, settle)
291
+ return
292
+ }
293
+ }
294
+
295
+ return (
296
+ <>
297
+ <SharedGitOpsTableView
298
+ rows={rowsQuery.data ?? []}
299
+ loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading || coldRetrying}
300
+ error={(rowsQuery.error as Error | null) ?? null}
301
+ counts={countsQuery.data?.counts ?? {}}
302
+ countsUnavailable={countsQuery.data?.unavailable}
303
+ freshnessSlot={
304
+ <FreshnessControl
305
+ mode="auto"
306
+ dataUpdatedAt={rowsQuery.dataUpdatedAt}
307
+ onRefresh={refetchTable}
308
+ connectionState={connection.state}
309
+ />
310
+ }
311
+ onRowClick={(row) => {
312
+ const ns = row.namespace || '_'
313
+ const params = new URLSearchParams()
314
+ params.set('apiGroup', row.group)
315
+ navigate({ pathname: gitOpsDetailPath(row.kindName, ns, row.name), search: params.toString() })
316
+ }}
317
+ onRowAction={handleRowAction}
318
+ pendingRowActions={pendingActions}
319
+ searchHotkey
320
+ globalNamespaces={namespaces}
321
+ onClearNamespaces={onClearNamespaces}
322
+ />
323
+ <SyncOptionsDialog
324
+ open={!!syncDialogRow}
325
+ appLabel={syncDialogRow ? `${syncDialogRow.namespace}/${syncDialogRow.name}` : ''}
326
+ pending={argoSync.isPending}
327
+ onCancel={() => setSyncDialogRow(null)}
328
+ onConfirm={(opts) => {
329
+ if (!syncDialogRow) return
330
+ const { namespace, name } = syncDialogRow
331
+ argoSync.mutate(
332
+ { namespace, name, ...opts },
333
+ // onSettled so the dialog closes on both success and error —
334
+ // otherwise the error toast surfaces behind the still-open
335
+ // modal and the user can't read it.
336
+ { onSuccess: refetchTableAfterMutation, onSettled: () => setSyncDialogRow(null) },
337
+ )
338
+ }}
339
+ />
340
+ </>
341
+ )
342
+ }
343
+
344
+ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOpsViewProps) {
345
+ const location = useLocation()
346
+ const navigate = useNavigate()
347
+ const { showError, showSuccess } = useToast()
348
+ const parts = location.pathname.split('/').filter(Boolean)
349
+ const kind = parts[2] || 'applications'
350
+ const namespace = parts[3] === '_' ? '' : decodePathPart(parts[3] || '')
351
+ const name = decodePathPart(parts[4] || '')
352
+ const group = new URLSearchParams(location.search).get('apiGroup') || (KIND_BY_NAME.get(kind)?.group ?? '')
353
+ const apiKind = KIND_BY_NAME.get(kind)
354
+ // Parent lineage from the ?from=kind|namespace|name query param. Set by
355
+ // openResourceFromTree when the user clicks a child GitOps node from a
356
+ // parent's graph. Renders an extra breadcrumb segment + "↑ Open parent"
357
+ // button so the user always knows where they came from. Falls back to
358
+ // null (no breadcrumb) for direct/deep links.
359
+ const parent = useMemo<{ kind: string; namespace: string; name: string; group: string } | null>(() => {
360
+ const raw = new URLSearchParams(location.search).get('from')
361
+ if (!raw) return null
362
+ const [pKind = '', pNs = '', pName = ''] = raw.split('|')
363
+ if (!pKind || !pName) return null
364
+ return {
365
+ kind: pKind,
366
+ namespace: pNs,
367
+ name: pName,
368
+ group: KIND_BY_NAME.get(pKind)?.group ?? '',
369
+ }
370
+ }, [location.search])
371
+
372
+ const resourceQ = useResource<any>(kind, namespace, name, group)
373
+ const treeQ = useGitOpsTree(kind, namespace, name, group, namespaces)
374
+ const insightsQ = useGitOpsInsights(kind, namespace, name, group, namespaces)
375
+ const status = resourceQ.data ? getGitOpsResourceStatus(kind, resourceQ.data) : null
376
+ const tool = getGitOpsTool(kind, group)
377
+ // Argo "auto-sync ON" is determined by spec.syncPolicy.automated being set,
378
+ // not by health.status === Suspended (which is Argo's CronJob-style suspend).
379
+ // The toggle button reads from this so the label flips correctly when an
380
+ // app is in Manual mode or suspended via Radar's annotations.
381
+ const argoAutoSyncEnabled = kind === 'applications' && Boolean(resourceQ.data?.spec?.syncPolicy?.automated)
382
+ // Radar-driven Argo suspension is signaled by annotations that record the
383
+ // pre-suspend prune/selfHeal state for restoration on resume. When present,
384
+ // the app is in a deliberately-paused state (vs. Manual mode, which is a
385
+ // normal operational choice) and should surface a Suspended chip alongside
386
+ // the other status indicators. Shared with the fleet table's row normalizer
387
+ // (isArgoSuspendedByRadar) so both surfaces agree on what "suspended" means.
388
+ const argoSuspendedByRadar = kind === 'applications' && isArgoSuspendedByRadar(resourceQ.data)
389
+ const effectiveSuspended = (status?.suspended ?? false) || argoSuspendedByRadar
390
+ // Lifecycle gate: when the resource is pending deletion, mutating
391
+ // actions are futile (the controller is processing finalizers and
392
+ // ignores reconcile/sync triggers). Surface it visually + disable
393
+ // the affected buttons. Read-style verbs (Refresh, Hard refresh,
394
+ // Terminate) intentionally remain enabled — see the corresponding
395
+ // carve-out in pkg/gitops/operations.go.
396
+ const terminating = !!insightsQ.data?.summary?.terminating
397
+ const terminatingDescriptions = describeGitOpsTerminating(insightsQ.data?.summary)
398
+ const terminatingChipTooltip = terminatingDescriptions.chipTooltip
399
+ const terminatingActionTooltip = terminatingDescriptions.actionDisabledTooltip
400
+ const [appView, setAppView] = useState<GitOpsDetailTab>('topology')
401
+ // When the user clicks an actionable issue alert ("OutOfSync — NodePool
402
+ // default is out of sync · View →"), we navigate to Changes and focus
403
+ // that resource. The ref is stringified to a stable key so GitOpsChangesView
404
+ // can find and scroll it; cleared after a few seconds so the highlight
405
+ // doesn't persist past its purpose.
406
+ const [changesFocusKey, setChangesFocusKey] = useState<string | null>(null)
407
+ const [graphPreset, setGraphPreset] = useState<GitOpsTreePreset>('compact')
408
+ const [graphSearch, setGraphSearch] = useState('')
409
+ const [graphKinds, setGraphKinds] = useState<Set<string>>(new Set())
410
+ const [graphSync, setGraphSync] = useState<Set<string>>(new Set())
411
+ const [graphHealth, setGraphHealth] = useState<Set<string>>(new Set())
412
+ const [graphNamespaces, setGraphNamespaces] = useState<Set<string>>(new Set())
413
+ const [graphRoles, setGraphRoles] = useState<Set<string>>(new Set())
414
+ const [graphFullscreen, setGraphFullscreen] = useState(false)
415
+ const [helmValuesOpen, setHelmValuesOpen] = useState(false)
416
+
417
+ const argoSync = useArgoSync()
418
+ const argoResourceValidation = useArgoResourceValidation()
419
+ const argoRefresh = useArgoRefresh()
420
+ const argoTerminate = useArgoTerminate()
421
+ const argoSuspend = useArgoSuspend()
422
+ const argoResume = useArgoResume()
423
+ const argoRollback = useArgoRollback()
424
+ const applyResource = useApplyResource()
425
+ const fluxReconcile = useFluxReconcile()
426
+ const fluxSyncWithSource = useFluxSyncWithSource()
427
+ const fluxSuspend = useFluxSuspend()
428
+ const fluxResume = useFluxResume()
429
+
430
+ const [syncDialogTarget, setSyncDialogTarget] = useState<ArgoSyncDialogTarget | null>(null)
431
+ // Doubles as the "open" flag (truthy = dialog open) and the data carrier
432
+ // for which history entry to roll back to.
433
+ const [rollbackTarget, setRollbackTarget] = useState<GitOpsHistoryItem | null>(null)
434
+ // Disambiguates which refresh button is in flight (both share argoRefresh).
435
+ const [refreshKind, setRefreshKind] = useState<'normal' | 'hard'>('normal')
436
+
437
+ function openArgoSyncDialog(target: ArgoSyncDialogTarget) {
438
+ argoResourceValidation.reset()
439
+ setSyncDialogTarget(target)
440
+ }
441
+
442
+ function closeArgoSyncDialog() {
443
+ argoResourceValidation.reset()
444
+ setSyncDialogTarget(null)
445
+ }
446
+
447
+ const detailRow = resourceQ.data ? normalizeDetailResource(kind, group, resourceQ.data) : null
448
+ const tree = treeQ.data ?? null
449
+ const helmValues = useMemo(() => extractHelmValues(kind, resourceQ.data), [kind, resourceQ.data])
450
+ const graphFilters = useMemo<GitOpsTreeFilters>(() => ({
451
+ kinds: graphKinds,
452
+ sync: graphSync,
453
+ health: graphHealth,
454
+ namespaces: graphNamespaces,
455
+ roles: graphRoles,
456
+ }), [graphHealth, graphKinds, graphNamespaces, graphRoles, graphSync])
457
+ const graphFacets = useMemo(() => buildTreeFacets(tree), [tree])
458
+
459
+ function openResourceFromTree(ref: GitOpsTreeRef | GitOpsInsightRef) {
460
+ if (isGitOpsDetailRef(ref) && isValidKubernetesName(ref.name)) {
461
+ const detailKind = kindToPlural(ref.kind)
462
+ // The tree's root node is this page's own subject — clicking it must not
463
+ // open a nested copy of the same detail page (which stacks an identical
464
+ // "GitOps / X / X" breadcrumb, and again, ad infinitum). A self-reference
465
+ // is a no-op; the header already represents this resource.
466
+ if (detailKind === kind && (ref.namespace || '') === (namespace || '') && ref.name === name) {
467
+ return
468
+ }
469
+ const params = new URLSearchParams()
470
+ if (ref.group) params.set('apiGroup', ref.group)
471
+ // Lineage breadcrumb support: when the user opens a child GitOps CR
472
+ // from inside a parent's tree, encode the parent into the URL so
473
+ // the child page can render "GitOps / parent / child" + "↑ Open
474
+ // parent" affordance. Encoded as kind|namespace|name (a single
475
+ // "from" param keeps the URL short; multi-level lineage isn't
476
+ // supported here yet — the deepest valid breadcrumb is parent →
477
+ // child. Going further would need either a chain encoding or
478
+ // history-state walking, both deferred until the use case shows up).
479
+ const fromKind = apiKind?.name ?? kind
480
+ if (fromKind && name) {
481
+ params.set('from', `${fromKind}|${namespace || ''}|${name}`)
482
+ }
483
+ navigate({ pathname: gitOpsDetailPath(detailKind, ref.namespace || '_', ref.name), search: params.toString() })
484
+ return
485
+ }
486
+ onOpenResource({ kind: kindToPlural(ref.kind), namespace: ref.namespace || '', name: ref.name, group: ref.group })
487
+ }
488
+
489
+ const isRunning = resourceQ.data?.status?.operationState?.phase === 'Running'
490
+ const operationInProgress = isArgoOperationInProgress(resourceQ.data)
491
+ const isFluxWorkload = kind === 'kustomizations' || kind === 'helmreleases'
492
+ const isFlux = tool === 'flux'
493
+ const isArgoApp = kind === 'applications'
494
+
495
+ // Detail-page shortcuts. Skip when a modal is already open so a stray "s"
496
+ // in an input field doesn't pop another sync dialog.
497
+ const shortcutsEnabled = !syncDialogTarget && !rollbackTarget
498
+ useRegisterShortcut({
499
+ id: 'gitops-detail-sync',
500
+ keys: 's',
501
+ description: isArgoApp ? 'Open sync options' : 'Reconcile',
502
+ category: 'GitOps',
503
+ scope: 'gitops',
504
+ handler: () => {
505
+ if (effectiveSuspended || terminating || operationInProgress) return
506
+ if (isArgoApp) openArgoSyncDialog({ scope: 'application' })
507
+ else if (isFlux) fluxReconcile.mutate({ kind, namespace, name })
508
+ },
509
+ enabled: shortcutsEnabled && (isArgoApp || isFlux) && !effectiveSuspended && !terminating && !(isArgoApp && operationInProgress),
510
+ })
511
+ useRegisterShortcut({
512
+ id: 'gitops-detail-refresh',
513
+ keys: 'r',
514
+ description: 'Refresh application',
515
+ category: 'GitOps',
516
+ scope: 'gitops',
517
+ handler: () => {
518
+ if (!isArgoApp) return
519
+ setRefreshKind('normal')
520
+ argoRefresh.mutate({ namespace, name, hard: false })
521
+ },
522
+ enabled: shortcutsEnabled && isArgoApp,
523
+ })
524
+ useRegisterShortcut({
525
+ id: 'gitops-detail-hard-refresh',
526
+ keys: 'Shift+R',
527
+ description: 'Hard refresh (re-resolve source from Git)',
528
+ category: 'GitOps',
529
+ scope: 'gitops',
530
+ handler: () => {
531
+ if (!isArgoApp) return
532
+ setRefreshKind('hard')
533
+ argoRefresh.mutate({ namespace, name, hard: true })
534
+ },
535
+ enabled: shortcutsEnabled && isArgoApp,
536
+ })
537
+ useRegisterShortcut({
538
+ id: 'gitops-detail-terminate',
539
+ keys: 't',
540
+ description: 'Terminate running sync',
541
+ category: 'GitOps',
542
+ scope: 'gitops',
543
+ handler: () => {
544
+ if (isArgoApp && isRunning) argoTerminate.mutate({ namespace, name })
545
+ },
546
+ enabled: shortcutsEnabled && isArgoApp && isRunning,
547
+ })
548
+
549
+ // Adapt the OSS-internal row + insights data into the layout's props.
550
+ // The bulk of the JSX is now in <GitOpsDetailLayout>; this wrapper does
551
+ // the OSS-specific things the layout can't (call OSS-side data hooks,
552
+ // open OSS dialogs, talk to OSS Toast, hit OSS keyboard registry).
553
+ const detail: GitOpsDetailMetadata = {
554
+ project: detailRow?.project,
555
+ repository: detailRow?.repository ? formatGitOpsSourceUrl(detailRow.repository) : undefined,
556
+ path: detailRow?.path || undefined,
557
+ chart: detailRow?.chart || undefined,
558
+ destination: formatGitOpsDestination(detailRow?.destination, detailRow?.destinationNamespace),
559
+ autoSyncMode: insightsQ.data?.summary?.autoSyncMode,
560
+ }
561
+
562
+ const argoHandlers: ArgoActionHandlers | undefined = isArgoApp ? {
563
+ onSyncRequested: () => openArgoSyncDialog({ scope: 'application' }),
564
+ onRefresh: (refreshType) => {
565
+ setRefreshKind(refreshType)
566
+ argoRefresh.mutate({ namespace, name, hard: refreshType === 'hard' })
567
+ },
568
+ onTerminate: () => argoTerminate.mutate({ namespace, name }),
569
+ onSuspend: () => argoSuspend.mutate({ namespace, name }),
570
+ onResume: () => argoResume.mutate({ namespace, name }),
571
+ syncing: argoSync.isPending,
572
+ refreshing: argoRefresh.isPending,
573
+ refreshingKind: refreshKind,
574
+ terminating: argoTerminate.isPending,
575
+ suspending: argoSuspend.isPending,
576
+ resuming: argoResume.isPending,
577
+ autoSyncEnabled: argoAutoSyncEnabled,
578
+ isRunning,
579
+ operationInProgress,
580
+ } : undefined
581
+
582
+ const fluxHandlers: FluxActionHandlers | undefined = isFlux ? {
583
+ onReconcile: () => fluxReconcile.mutate({ kind, namespace, name }),
584
+ onSyncWithSource: () => fluxSyncWithSource.mutate({ kind, namespace, name }),
585
+ onSuspend: () => fluxSuspend.mutate({ kind, namespace, name }),
586
+ onResume: () => fluxResume.mutate({ kind, namespace, name }),
587
+ reconciling: fluxReconcile.isPending,
588
+ syncingWithSource: fluxSyncWithSource.isPending,
589
+ suspending: fluxSuspend.isPending,
590
+ resuming: fluxResume.isPending,
591
+ } : undefined
592
+
593
+ return (
594
+ <GitOpsDetailLayout
595
+ identity={{
596
+ kind,
597
+ group,
598
+ namespace,
599
+ name,
600
+ toolLabel: tool === 'argo' ? 'ArgoCD' : 'FluxCD',
601
+ kindLabel: apiKind?.kind ?? kind,
602
+ }}
603
+ parent={parent}
604
+ status={status ? { sync: status.sync, health: status.health, suspended: effectiveSuspended } : null}
605
+ terminating={terminating}
606
+ terminatingChipTooltip={terminatingChipTooltip}
607
+ terminatingActionTooltip={terminatingActionTooltip}
608
+ detail={detail}
609
+ insight={insightsQ.data ?? null}
610
+ insightLoading={insightsQ.isLoading}
611
+ renderRevisionMeta={
612
+ isArgoApp && insightsQ.data?.capabilities?.revisionMetadataAvailable
613
+ ? (revision) => (
614
+ <RevisionMetaChip appNamespace={namespace} appName={name} revision={revision} />
615
+ )
616
+ : undefined
617
+ }
618
+ onSelectIssue={(issue) => {
619
+ const ref = issue.refs?.[0]
620
+ if (!ref) return
621
+ setAppView('changes')
622
+ setChangesFocusKey(gitOpsInsightChangeKey(ref))
623
+ // Window the highlight: 4s is long enough to find the row visually
624
+ // but short enough that it doesn't linger if the user navigates
625
+ // away and back.
626
+ window.setTimeout(() => setChangesFocusKey(null), 4000)
627
+ }}
628
+ remediationPending={applyResource.isPending || argoSync.isPending}
629
+ onRemediate={(remediation) => {
630
+ if (remediation.kind === 'create-namespace' && remediation.target) {
631
+ const nsName = remediation.target
632
+ const yamlManifest = `apiVersion: v1\nkind: Namespace\nmetadata:\n name: ${nsName}\n`
633
+ applyResource.mutate(
634
+ { yaml: yamlManifest, mode: 'apply' },
635
+ {
636
+ onSuccess: () => {
637
+ // Defer the success toast until we know whether the follow-on
638
+ // sync was triggered, so a successful create-namespace + sync
639
+ // failure doesn't yield a misleading "sync triggered" toast.
640
+ if (kind === 'applications') {
641
+ argoSync.mutate(
642
+ { namespace, name },
643
+ {
644
+ onSuccess: () => {
645
+ showSuccess(`Created namespace ${nsName}`, 'Sync triggered to retry the apply.')
646
+ },
647
+ onError: () => {
648
+ showSuccess(`Created namespace ${nsName}`, "Couldn't trigger sync automatically — click Sync to retry.")
649
+ },
650
+ },
651
+ )
652
+ } else {
653
+ showSuccess(`Created namespace ${nsName}`)
654
+ }
655
+ },
656
+ onError: (err: unknown) => {
657
+ const msg = err instanceof Error ? err.message : 'Unknown error'
658
+ showError(
659
+ `Couldn't create namespace ${nsName}`,
660
+ msg.includes('forbidden')
661
+ ? 'Radar lacks RBAC to create namespaces in this cluster. Create it manually or have a cluster-admin do it.'
662
+ : msg,
663
+ )
664
+ },
665
+ },
666
+ )
667
+ }
668
+ }}
669
+ helmValues={helmValues}
670
+ helmValuesOpen={helmValuesOpen}
671
+ onToggleHelmValues={() => setHelmValuesOpen((v) => !v)}
672
+ helmValuesContent={helmValues ? <CodeViewer code={helmValues.yaml} language="yaml" showLineNumbers maxHeight="320px" /> : null}
673
+ isArgoApp={isArgoApp}
674
+ isFlux={isFlux}
675
+ isFluxWorkload={isFluxWorkload}
676
+ argo={argoHandlers}
677
+ flux={fluxHandlers}
678
+ activeTab={appView}
679
+ onTabChange={(tab) => setAppView(tab)}
680
+ fullscreen={graphFullscreen}
681
+ onToggleFullscreen={() => setGraphFullscreen(!graphFullscreen)}
682
+ resourceLoading={resourceQ.isLoading}
683
+ resourceError={(resourceQ.error as Error | null) ?? null}
684
+ onNavigateRoot={() => navigate('/gitops')}
685
+ onNavigateParent={parent ? () => {
686
+ const params = new URLSearchParams()
687
+ if (parent.group) params.set('apiGroup', parent.group)
688
+ navigate({
689
+ pathname: gitOpsDetailPath(parent.kind, parent.namespace || '_', parent.name),
690
+ search: params.toString(),
691
+ })
692
+ } : undefined}
693
+ manageDocumentTitle={false /* title handled centrally in App's radarPageTitle */}
694
+ renderTabBarCounts={({ tab }) => (
695
+ tab === 'topology' && tree ? <TopologyCounts tree={tree} /> : null
696
+ )}
697
+ renderTabBarAccessory={({ tab }) => (
698
+ tab === 'topology' ? (
699
+ <button
700
+ type="button"
701
+ onClick={() => {
702
+ setGraphSearch('')
703
+ setGraphKinds(new Set())
704
+ setGraphSync(new Set())
705
+ setGraphHealth(new Set())
706
+ setGraphNamespaces(new Set())
707
+ setGraphRoles(new Set())
708
+ }}
709
+ className="rounded px-2 py-1 text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
710
+ >
711
+ Clear filters
712
+ </button>
713
+ ) : null
714
+ )}
715
+ renderTabBody={({ tab }) => {
716
+ if (tab === 'activity') {
717
+ return (
718
+ <GitOpsActivityInsightView
719
+ insight={insightsQ.data}
720
+ error={insightsQ.error as Error | null}
721
+ onRollback={isArgoApp && !operationInProgress ? (item) => {
722
+ if (parseArgoRollbackID(item.id) == null) return
723
+ setRollbackTarget(item)
724
+ } : undefined}
725
+ />
726
+ )
727
+ }
728
+ if (tab === 'changes') {
729
+ return (
730
+ <GitOpsChangesView
731
+ insight={insightsQ.data}
732
+ error={insightsQ.error as Error | null}
733
+ onOpenResource={openResourceFromTree}
734
+ onSyncResource={isArgoApp ? (resource) => openArgoSyncDialog({ scope: 'resource', resource }) : undefined}
735
+ syncResourceDisabledReason={isArgoApp ? (
736
+ terminating
737
+ ? terminatingActionTooltip
738
+ : effectiveSuspended
739
+ ? 'Resume the Application before syncing a resource.'
740
+ : operationInProgress || argoSync.isPending
741
+ ? 'Wait for the current sync operation to finish.'
742
+ : undefined
743
+ ) : undefined}
744
+ focusKey={changesFocusKey}
745
+ tree={tree}
746
+ renderResourceDiff={isArgoApp ? (ref) => (
747
+ <ArgoResourceDiffLoader appNamespace={namespace} appName={name} resourceRef={ref} />
748
+ ) : undefined}
749
+ onOpenSettings={onOpenSettings}
750
+ />
751
+ )
752
+ }
753
+ // topology
754
+ return (
755
+ <div className="grid min-h-0 min-w-0 flex-1 grid-cols-[280px_minmax(0,1fr)] max-lg:grid-cols-1">
756
+ <GitOpsGraphFilterRail
757
+ facets={graphFacets}
758
+ preset={graphPreset}
759
+ onPresetChange={setGraphPreset}
760
+ search={graphSearch}
761
+ onSearchChange={setGraphSearch}
762
+ kinds={graphKinds}
763
+ onToggleKind={(value) => toggleSet(graphKinds, setGraphKinds, value)}
764
+ sync={graphSync}
765
+ onToggleSync={(value) => toggleSet(graphSync, setGraphSync, value)}
766
+ health={graphHealth}
767
+ onToggleHealth={(value) => toggleSet(graphHealth, setGraphHealth, value)}
768
+ namespaces={graphNamespaces}
769
+ onToggleNamespace={(value) => toggleSet(graphNamespaces, setGraphNamespaces, value)}
770
+ roles={graphRoles}
771
+ onToggleRole={(value) => toggleSet(graphRoles, setGraphRoles, value)}
772
+ />
773
+ <div className="min-h-0 min-w-0 border-l border-theme-border max-lg:border-l-0 max-lg:border-t">
774
+ <GitOpsTreeGraph
775
+ tree={tree}
776
+ loading={treeQ.isLoading}
777
+ error={treeQ.error as Error | null}
778
+ onNodeClick={openResourceFromTree}
779
+ preset={graphPreset}
780
+ onPresetChange={setGraphPreset}
781
+ query={graphSearch}
782
+ onQueryChange={setGraphSearch}
783
+ filters={graphFilters}
784
+ showToolbar={false}
785
+ />
786
+ </div>
787
+ </div>
788
+ )
789
+ }}
790
+ >
791
+ {/* Modals — portaled to body, only render the ones for the current tool. */}
792
+ {isArgoApp && (
793
+ <>
794
+ <SyncOptionsDialog
795
+ open={!!syncDialogTarget}
796
+ appLabel={`${namespace}/${name}`}
797
+ resource={syncDialogTarget?.scope === 'resource' ? syncDialogTarget.resource : undefined}
798
+ pending={argoSync.isPending}
799
+ autoSyncEnabled={argoAutoSyncEnabled}
800
+ validationPending={argoResourceValidation.isPending}
801
+ operationInProgress={operationInProgress}
802
+ validationResult={argoResourceValidation.data}
803
+ validationError={argoResourceValidation.error?.message}
804
+ onCancel={closeArgoSyncDialog}
805
+ onValidationReset={() => argoResourceValidation.reset()}
806
+ onValidate={syncDialogTarget?.scope === 'resource' ? (opts) => {
807
+ argoResourceValidation.mutate(buildArgoResourceSyncVars(namespace, name, syncDialogTarget.resource, opts))
808
+ } : undefined}
809
+ onConfirm={(opts) => {
810
+ if (!syncDialogTarget) return
811
+ const variables = syncDialogTarget.scope === 'resource'
812
+ ? buildArgoResourceSyncVars(namespace, name, syncDialogTarget.resource, opts)
813
+ : { namespace, name, ...opts }
814
+ argoSync.mutate(variables, {
815
+ onSettled: closeArgoSyncDialog,
816
+ })
817
+ }}
818
+ />
819
+ <RollbackDialog
820
+ open={!!rollbackTarget}
821
+ appLabel={`${namespace}/${name}`}
822
+ revision={rollbackTarget?.revision || ''}
823
+ historyId={rollbackTarget?.id}
824
+ pending={argoRollback.isPending}
825
+ onCancel={() => setRollbackTarget(null)}
826
+ onConfirm={(opts) => {
827
+ const id = parseArgoRollbackID(rollbackTarget?.id)
828
+ if (id == null) {
829
+ showError('Rollback target became invalid', 'The history entry changed while the dialog was open. Reselect a target and try again.')
830
+ setRollbackTarget(null)
831
+ return
832
+ }
833
+ argoRollback.mutate({ namespace, name, id, ...opts }, {
834
+ onSettled: () => setRollbackTarget(null),
835
+ })
836
+ }}
837
+ />
838
+ </>
839
+ )}
840
+ </GitOpsDetailLayout>
841
+ )
842
+ }
843
+ type HelmValuesSource = 'flux' | 'argo-object' | 'argo-string' | 'argo-parameters'
844
+ interface HelmValuesData {
845
+ yaml: string
846
+ keyCount: number
847
+ source: HelmValuesSource
848
+ }
849
+
850
+ // Both Flux HelmRelease and Argo CD Application-with-Helm-source carry user
851
+ // overrides for chart values, but spell them differently. We surface them via
852
+ // a single disclosure on the GitOps detail page; this helper normalizes the
853
+ // four flavors we may encounter into one renderable shape.
854
+ function extractHelmValues(kind: string, resource: any): HelmValuesData | null {
855
+ if (!resource) return null
856
+ if (kind === 'helmreleases') {
857
+ const values = resource?.spec?.values
858
+ if (values && typeof values === 'object' && Object.keys(values).length > 0) {
859
+ return { yaml: safeStringifyYaml(values), keyCount: Object.keys(values).length, source: 'flux' }
860
+ }
861
+ return null
862
+ }
863
+ if (kind === 'applications') {
864
+ const helm = resource?.spec?.source?.helm
865
+ if (helm?.valuesObject && typeof helm.valuesObject === 'object' && Object.keys(helm.valuesObject).length > 0) {
866
+ return {
867
+ yaml: safeStringifyYaml(helm.valuesObject),
868
+ keyCount: Object.keys(helm.valuesObject).length,
869
+ source: 'argo-object',
870
+ }
871
+ }
872
+ if (typeof helm?.values === 'string' && helm.values.trim() !== '') {
873
+ const parsed = tryParseYaml(helm.values)
874
+ const keyCount = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? Object.keys(parsed).length : 0
875
+ return { yaml: helm.values, keyCount, source: 'argo-string' }
876
+ }
877
+ if (Array.isArray(helm?.parameters) && helm.parameters.length > 0) {
878
+ const obj: Record<string, unknown> = {}
879
+ for (const param of helm.parameters) {
880
+ if (param?.name) obj[param.name] = param.value
881
+ }
882
+ if (Object.keys(obj).length === 0) return null
883
+ return {
884
+ yaml: safeStringifyYaml(obj),
885
+ keyCount: Object.keys(obj).length,
886
+ source: 'argo-parameters',
887
+ }
888
+ }
889
+ }
890
+ return null
891
+ }
892
+
893
+ function safeStringifyYaml(value: unknown): string {
894
+ try {
895
+ return yaml.stringify(value, { lineWidth: 0 })
896
+ } catch {
897
+ return JSON.stringify(value, null, 2)
898
+ }
899
+ }
900
+
901
+ function tryParseYaml(value: string): unknown {
902
+ try {
903
+ return yaml.parse(value)
904
+ } catch {
905
+ return null
906
+ }
907
+ }
908
+
909
+ // AppFact + ViewButton + ActionButton moved into
910
+ // @skyhook-io/k8s-ui's GitOpsDetailLayout (shared with hub-web's fleet
911
+ // detail page). The OSS wrapper above mounts the layout instead of
912
+ // rendering its own header chrome.
913
+
914
+
915
+
916
+ function normalizeDetailResource(kind: string, group: string, resource: any): GitOpsRow | null {
917
+ if (kind === 'applications') return normalizeArgoApplication(resource)
918
+ if (kind === 'kustomizations') return normalizeFluxKustomization(resource)
919
+ if (kind === 'helmreleases') return normalizeFluxHelmRelease(resource)
920
+ const status = getGitOpsResourceStatus(kind, resource)
921
+ return {
922
+ id: `${group}/${kind}/${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`,
923
+ mode: 'applications',
924
+ tool: getGitOpsTool(kind, group),
925
+ kindName: kind,
926
+ kind: resource.kind ?? kind,
927
+ group,
928
+ name: resource.metadata?.name ?? '',
929
+ namespace: resource.metadata?.namespace ?? '',
930
+ project: resource.metadata?.namespace ?? '',
931
+ labels: resource.metadata?.labels ?? {},
932
+ sync: status?.sync ?? 'Unknown',
933
+ health: status?.health ?? 'Unknown',
934
+ suspended: status?.suspended ?? resource.spec?.suspend === true,
935
+ repository: resource.spec?.url ?? resource.spec?.sourceRef?.name ?? '',
936
+ targetRevision: resource.status?.artifact?.revision ?? resource.status?.lastAppliedRevision ?? resource.status?.lastAttemptedRevision ?? '',
937
+ path: resource.spec?.path ?? '',
938
+ chart: resource.spec?.chart?.spec?.chart ?? '',
939
+ destination: 'in-cluster',
940
+ destinationNamespace: resource.spec?.targetNamespace ?? resource.metadata?.namespace ?? '',
941
+ createdAt: resource.metadata?.creationTimestamp ?? '',
942
+ lastSync: newestConditionTime(resource),
943
+ autoSync: !resource.spec?.suspend,
944
+ terminating: isTerminating(resource),
945
+ terminationStartedAt: terminationStartedAt(resource),
946
+ raw: resource,
947
+ }
948
+ }
949
+
950
+
951
+ function gitOpsDetailPath(kind: string, namespace: string, name: string): string {
952
+ return `/gitops/detail/${encodeURIComponent(kind)}/${encodeURIComponent(namespace || '_')}/${encodeURIComponent(name)}`
953
+ }
954
+
955
+ function decodePathPart(value: string): string {
956
+ try {
957
+ return decodeURIComponent(value)
958
+ } catch {
959
+ return value
960
+ }
961
+ }
962
+
963
+ function isGitOpsDetailRef(ref: GitOpsTreeRef | GitOpsInsightRef): boolean {
964
+ const kind = ref.kind.toLowerCase()
965
+ if (ref.group === 'argoproj.io') {
966
+ return kind === 'application' || kind === 'applicationset' || kind === 'appproject'
967
+ }
968
+ if (ref.group === 'kustomize.toolkit.fluxcd.io') return kind === 'kustomization'
969
+ if (ref.group === 'helm.toolkit.fluxcd.io') return kind === 'helmrelease'
970
+ // Flux source CRs (GitRepository/HelmRepository/OCIRepository/Bucket/HelmChart)
971
+ // are NOT GitOps detail-page CRs — they're config objects with spec/status
972
+ // but no managed-resource tree. The standard resource drawer renders them
973
+ // cleanly. Keep this in sync with pkg/gitops/tree/graph.go classifyGitOpsKind.
974
+ return false
975
+ }
976
+
977
+ function isValidKubernetesName(name: string): boolean {
978
+ return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(name)
979
+ }
980
+
981
+ function hasAPIResource(resources: APIResource[] | undefined, name: string, group: string): boolean {
982
+ return (resources ?? []).some((resource) => resource.name === name && resource.group === group)
983
+ }
984
+
985
+ async function fetchResourceList(kind: string, group: string, namespacesParam: string): Promise<any[]> {
986
+ const params = new URLSearchParams()
987
+ if (namespacesParam) params.set('namespaces', namespacesParam)
988
+ if (group) params.set('group', group)
989
+ const res = await fetch(apiUrl(`/resources/${kind}?${params}`), {
990
+ credentials: getCredentialsMode(),
991
+ headers: getAuthHeaders(),
992
+ })
993
+ if (res.status === 400 || res.status === 403 || res.status === 404) return []
994
+ if (!res.ok) throw new Error(`Failed to fetch ${kind}: HTTP ${res.status}`)
995
+ return res.json()
996
+ }
997
+
998
+ function isTerminating(resource: any): boolean {
999
+ return Boolean(resource?.metadata?.deletionTimestamp)
1000
+ }
1001
+
1002
+ // terminationStartedAt extracts the RFC3339 deletion timestamp, or
1003
+ // undefined when the resource isn't being deleted. Centralized so all
1004
+ // three normalizers (Argo, Flux Kustomization, Flux HelmRelease) agree
1005
+ // on the field path.
1006
+ function terminationStartedAt(resource: any): string | undefined {
1007
+ return resource?.metadata?.deletionTimestamp || undefined
1008
+ }
1009
+
1010
+ function newestConditionTime(resource: any): string {
1011
+ const times = (resource.status?.conditions ?? [])
1012
+ .map((condition: any) => condition.lastTransitionTime)
1013
+ .filter(Boolean)
1014
+ .sort()
1015
+ return times[times.length - 1] ?? ''
1016
+ }
1017
+
1018
+
1019
+ // Inline counts for the topology toolbar — answers "how many resources, how
1020
+ // many of them are healthy / drifted" at a glance, without making the user
1021
+ // count facets in the filter rail.
1022
+ function TopologyCounts({ tree }: { tree: GitOpsResourceTree }) {
1023
+ const nodes = (tree.nodes ?? []).filter((n) => n.role !== 'group' && n.role !== 'root')
1024
+ const total = nodes.length
1025
+ if (total === 0) return null
1026
+ const healthy = nodes.filter((n) => (n.health || '').toLowerCase() === 'healthy').length
1027
+ const degraded = nodes.filter((n) => {
1028
+ const h = (n.health || '').toLowerCase()
1029
+ return h === 'degraded' || h === 'missing' || h === 'unhealthy'
1030
+ }).length
1031
+ const outOfSync = nodes.filter((n) => (n.sync || '').toLowerCase() === 'outofsync').length
1032
+ return (
1033
+ <div className="hidden min-w-0 flex-1 items-center gap-3 truncate text-[11px] text-theme-text-tertiary sm:flex">
1034
+ <span><span className="text-theme-text-primary">{total}</span> resources</span>
1035
+ {healthy > 0 && <span className="flex items-center gap-1"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> {healthy} healthy</span>}
1036
+ {/* Bad-news counts use status colors on the number itself so the worst
1037
+ fact in the row visually pops, not just the dot next to it. */}
1038
+ {degraded > 0 && <span className="flex items-center gap-1 font-medium text-red-600 dark:text-red-400"><span className="h-1.5 w-1.5 rounded-full bg-red-500" /> {degraded} degraded</span>}
1039
+ {outOfSync > 0 && <span className="flex items-center gap-1 font-medium text-amber-700 dark:text-amber-400"><span className="h-1.5 w-1.5 rounded-full bg-amber-500" /> {outOfSync} out of sync</span>}
1040
+ </div>
1041
+ )
1042
+ }