@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.
- package/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- 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
|
+
}
|