@skyhook-io/radar-app 1.16.2 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +60 -12
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +168 -55
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.test.ts +40 -0
  8. package/src/api/diagnose.ts +12 -1
  9. package/src/api/radarFeatures.render.test.tsx +19 -1
  10. package/src/api/radarFeatures.ts +3 -0
  11. package/src/api/timelineSource.ts +1 -1
  12. package/src/api/traffic.sources.test.ts +55 -0
  13. package/src/api/traffic.ts +173 -14
  14. package/src/components/NoClusterAccessBanner.tsx +68 -0
  15. package/src/components/applications/ApplicationsView.tsx +3 -0
  16. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  17. package/src/components/capacity/CapacityView.test.tsx +31 -1
  18. package/src/components/capacity/shared.tsx +12 -21
  19. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  20. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  21. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  22. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  23. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  24. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  25. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  26. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  27. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  28. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  29. package/src/components/cnpg/CNPGView.tsx +156 -0
  30. package/src/components/cnpg/paths.ts +13 -0
  31. package/src/components/cnpg/routes.test.ts +60 -0
  32. package/src/components/cnpg/routes.ts +112 -0
  33. package/src/components/cnpg/shared.tsx +290 -0
  34. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  35. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  36. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  37. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  38. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  39. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  40. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  41. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  42. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  43. package/src/components/diagnose/InvestigationView.tsx +39 -2
  44. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  45. package/src/components/diagnose/agentCatalog.ts +2 -2
  46. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  47. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  48. package/src/components/execution/BatchExecutionView.tsx +11 -11
  49. package/src/components/helm/ChartBrowser.tsx +9 -10
  50. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  51. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  52. package/src/components/helm/InstallWizard.tsx +5 -2
  53. package/src/components/helm/RevisionHistory.tsx +3 -2
  54. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  55. package/src/components/helm/ValuesViewer.tsx +1 -1
  56. package/src/components/home/ClusterHealthCard.tsx +7 -0
  57. package/src/components/home/mcpToolCatalog.ts +1 -1
  58. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  59. package/src/components/resources/ResourcesView.tsx +6 -38
  60. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  61. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  63. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  64. package/src/components/settings/SettingsDialog.tsx +122 -19
  65. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  66. package/src/components/timeline/TimelineList.test.ts +47 -29
  67. package/src/components/timeline/TimelineList.tsx +60 -11
  68. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  69. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  70. package/src/components/timeline/TimelineView.tsx +16 -2
  71. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  72. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  73. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  74. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  75. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  76. package/src/components/traffic/TrafficGraph.tsx +109 -26
  77. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  78. package/src/components/traffic/TrafficView.tsx +582 -149
  79. package/src/components/traffic/trafficFilters.test.ts +398 -1
  80. package/src/components/traffic/trafficFilters.ts +408 -0
  81. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  82. package/src/components/whats-new/WhatsNew.tsx +146 -86
  83. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  84. package/src/components/whats-new/releaseNotes.ts +155 -24
  85. package/src/components/workload/WorkloadView.test.ts +2 -1
  86. package/src/components/workload/WorkloadView.tsx +165 -294
  87. package/src/context/TimelineCustomization.tsx +15 -0
  88. package/src/contexts/CapabilitiesContext.tsx +9 -1
  89. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  90. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  91. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  92. package/src/hooks/useResourceCounts.ts +49 -0
  93. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -114,6 +114,10 @@ export function useCanHelmWrite(): boolean {
114
114
  return useContext(CapabilitiesContext).helmWrite
115
115
  }
116
116
 
117
+ export function useIsAuthEnabled(): boolean {
118
+ return useContext(CapabilitiesContext).authEnabled === true
119
+ }
120
+
117
121
  export function useCanNodeWrite(): boolean {
118
122
  return useContext(CapabilitiesContext).nodeWrite
119
123
  }
@@ -158,7 +162,11 @@ export function useNamespacedCapabilities(namespace: string | undefined) {
158
162
  canExec: nsCaps?.exec ?? globalCaps.exec,
159
163
  canViewLogs: nsCaps?.logs ?? globalCaps.logs,
160
164
  canPortForward: nsCaps?.portForward ?? globalCaps.portForward,
165
+ canHelmWrite: nsCaps?.helmWrite ?? globalCaps.helmWrite,
161
166
  workloadWrites: nsCaps?.workloadWrites ?? globalCaps.workloadWrites,
162
167
  workloadWritesPending: Boolean(namespace && isPending),
163
- }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, globalCaps.workloadWrites, isPending, namespace, nsCaps])
168
+ // A failed refetch keeps the last successful namespace answer; with none,
169
+ // the global value is not an answer for this namespace.
170
+ helmWriteUnknown: Boolean(namespace && !nsCaps && (isPending || error)),
171
+ }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, globalCaps.helmWrite, globalCaps.workloadWrites, error, isPending, namespace, nsCaps])
164
172
  }
@@ -0,0 +1,457 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5
+ import { SetImageDialog, canConfirmGitOpsWrite, setImageSelectionKey, type WorkloadImageInventory } from '@skyhook-io/k8s-ui'
6
+ import { afterEach, expect, it, vi } from 'vitest'
7
+ import { useGitOpsWriteGuard, type GitOpsWriteGuardState } from './useGitOpsWriteGuard'
8
+
9
+ const mocks = vi.hoisted(() => ({
10
+ applications: { data: undefined as unknown[] | undefined, isPending: true, isError: false },
11
+ inherited: { data: undefined as unknown, isPending: false, isError: false },
12
+ target: { data: undefined as unknown, relationships: undefined as unknown, isLoading: false, isError: false },
13
+ evidence: vi.fn(),
14
+ evidenceUnsupported: false,
15
+ }))
16
+ vi.mock('../api/client', () => ({
17
+ useResource: (kind: string) => (kind === 'deployments' ? mocks.target : { data: undefined, isLoading: false, isError: false }),
18
+ useResourceWithRelationships: () => mocks.inherited,
19
+ useResources: () => mocks.applications,
20
+ useRadarFeature: () => ({
21
+ guard: async (fn: () => unknown) => {
22
+ if (!mocks.evidenceUnsupported) return fn()
23
+ const { RadarFeatureUnsupportedError } = await import('../api/radarFeatures')
24
+ throw new RadarFeatureUnsupportedError('gitopsWriteEvidence', { currentVersion: 'v1.16.0' })
25
+ },
26
+ gatedKey: mocks.evidenceUnsupported ? ['radar-feature-unsupported'] : [],
27
+ }),
28
+ fetchGitOpsWriteEvidence: (...args: unknown[]) => mocks.evidence(...args),
29
+ }))
30
+
31
+ afterEach(() => {
32
+ mocks.evidenceUnsupported = false
33
+ mocks.applications.data = undefined
34
+ mocks.applications.isPending = true
35
+ mocks.applications.isError = false
36
+ mocks.inherited = { data: undefined, isPending: false, isError: false }
37
+ mocks.target = { data: undefined, relationships: undefined, isLoading: false, isError: false }
38
+ mocks.evidence.mockReset()
39
+ })
40
+
41
+ it.each(['resolved', 'failed', 'empty', 'ambiguous'])('waits for Argo Application namespace lookup before handling %s ownership', async (outcome) => {
42
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
43
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
44
+ const root = createRoot(document.createElement('div'))
45
+ let state: GitOpsWriteGuardState
46
+ const onLoad = vi.fn(async (): Promise<WorkloadImageInventory> => ({
47
+ target: { kind: 'Deployment', group: 'apps', resource: 'deployments', namespace: 'prod', name: 'web' },
48
+ containers: [{ type: 'container', name: 'app', image: 'app:old' }],
49
+ behavior: { type: 'rolling' },
50
+ }))
51
+ mocks.evidence.mockResolvedValue({ uid: 'deployment', resourceVersion: '1', owner: { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }, policy: null, paths: [] })
52
+ function Harness() {
53
+ state = useGitOpsWriteGuard({
54
+ target: { kind: 'Deployment', group: 'apps', namespace: 'prod', name: 'web' },
55
+ resource: {},
56
+ relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: '', name: 'web' }] },
57
+ writes: [{ scope: 'spec', paths: ['spec.replicas'] }],
58
+ })
59
+ return outcome !== 'resolved' ? <SetImageDialog open workloadLabel="Deployment" workloadName="web" workloadResource="deployments" ownership={state.guard ? { guard: state.guard } : undefined} onLoad={onLoad} onClose={() => {}} onConfirm={async () => {}} /> : null
60
+ }
61
+ const render = () => root.render(<QueryClientProvider client={queryClient}><Harness /></QueryClientProvider>)
62
+ try {
63
+ await act(async () => render())
64
+ expect(state!.guard?.pending).toBe(true)
65
+ expect(mocks.evidence).not.toHaveBeenCalled()
66
+ mocks.applications.isPending = false
67
+ if (outcome === 'resolved') mocks.applications.data = [{ metadata: { name: 'web', namespace: 'argocd' } }]
68
+ else if (outcome === 'failed') mocks.applications.isError = true
69
+ else mocks.applications.data = outcome === 'empty' ? [] : ['argocd-a', 'argocd-b'].map((namespace) => ({ metadata: { name: 'web', namespace } }))
70
+ await act(async () => render())
71
+ if (outcome === 'resolved') {
72
+ await act(async () => vi.waitFor(() => expect(mocks.evidence).toHaveBeenCalledTimes(1)))
73
+ expect(mocks.evidence.mock.calls[0][0].owner.namespace).toBe('argocd')
74
+ } else {
75
+ // The server resolves the namespace; the browser sends the name alone.
76
+ await act(async () => vi.waitFor(() => expect(mocks.evidence).toHaveBeenCalledTimes(1)))
77
+ expect(mocks.evidence.mock.calls[0][0].owner.namespace).toBe('')
78
+ await act(async () => vi.waitFor(() => expect(state!.guard?.pending).toBe(false)))
79
+ expect(state!.ownership.lookupError).toBe(false)
80
+ expect(state!.guard?.pending).toBe(false)
81
+ expect(state!.guard?.requiresAck).toBe(true)
82
+ expect(state!.guard?.level).toBe('may-revert')
83
+ expect(canConfirmGitOpsWrite(state!.guard, true)).toBe(true)
84
+ expect(document.body.textContent).not.toContain('Management ownership unavailable')
85
+ expect(document.body.textContent).toContain('Argo CD')
86
+ }
87
+ } finally {
88
+ await act(async () => root.unmount())
89
+ queryClient.clear()
90
+ }
91
+ })
92
+
93
+ async function renderGuard(options: Parameters<typeof useGitOpsWriteGuard>[0]) {
94
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
95
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
96
+ const root = createRoot(document.createElement('div'))
97
+ const ref: { state?: GitOpsWriteGuardState } = {}
98
+ function Harness() {
99
+ ref.state = useGitOpsWriteGuard(options)
100
+ return null
101
+ }
102
+ await act(async () => root.render(<QueryClientProvider client={queryClient}><Harness /></QueryClientProvider>))
103
+ return {
104
+ ref,
105
+ queryClient,
106
+ rerender: () => act(async () => root.render(<QueryClientProvider client={queryClient}><Harness /></QueryClientProvider>)),
107
+ cleanup: async () => {
108
+ await act(async () => root.unmount())
109
+ queryClient.clear()
110
+ },
111
+ }
112
+ }
113
+
114
+ const deployment = { kind: 'Deployment', group: 'apps', namespace: 'prod', name: 'web' }
115
+ const replicas = [{ scope: 'spec' as const, paths: ['spec.replicas'] }]
116
+
117
+ it('reads missing relationships as unknown ownership, not as unmanaged', async () => {
118
+ const h = await renderGuard({ target: deployment, resource: {}, relationshipsUnavailable: true, writes: replicas })
119
+ try {
120
+ expect(mocks.evidence).not.toHaveBeenCalled()
121
+ expect(h.ref.state!.guard?.pending).toBe(false)
122
+ expect(h.ref.state!.guard?.level).toBe('may-revert')
123
+ expect(h.ref.state!.guard?.requiresAck).toBe(true)
124
+ expect(h.ref.state!.guard?.ownershipError).toContain("hasn't mapped this resource's owners")
125
+ expect(canConfirmGitOpsWrite(h.ref.state!.guard, false)).toBe(false)
126
+ } finally {
127
+ await h.cleanup()
128
+ }
129
+ })
130
+
131
+ it('asks for an acknowledgment when the server saw a manager the browser could not resolve', async () => {
132
+ const h = await renderGuard({ target: deployment, resource: {}, relationships: {}, declaredManager: 'Argo CD', writes: replicas })
133
+ try {
134
+ expect(h.ref.state!.guard?.requiresAck).toBe(true)
135
+ expect(h.ref.state!.guard?.ownershipError).toContain('Argo CD')
136
+ } finally {
137
+ await h.cleanup()
138
+ }
139
+ })
140
+
141
+ it('ignores the declared manager once an owner resolves', async () => {
142
+ mocks.evidence.mockResolvedValue({ uid: 'u', resourceVersion: '1', owner: { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }, policy: null, paths: [] })
143
+ const h = await renderGuard({
144
+ target: deployment,
145
+ resource: {},
146
+ relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }] },
147
+ declaredManager: 'Argo CD',
148
+ writes: replicas,
149
+ })
150
+ try {
151
+ await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
152
+ expect(h.ref.state!.guard?.ownershipError).toBeNull()
153
+ expect(h.ref.state!.guard?.owner?.name).toBe('web')
154
+ } finally {
155
+ await h.cleanup()
156
+ }
157
+ })
158
+
159
+ it('treats a refetch of the evidence as pending', async () => {
160
+ let resolveSecond: (v: unknown) => void = () => {}
161
+ const evidence = { uid: 'u', resourceVersion: '1', owner: { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }, policy: null, paths: [] }
162
+ mocks.evidence.mockResolvedValueOnce(evidence).mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
163
+ const h = await renderGuard({
164
+ target: deployment,
165
+ resource: {},
166
+ relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }] },
167
+ writes: replicas,
168
+ })
169
+ try {
170
+ await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
171
+ await act(async () => { void h.queryClient.invalidateQueries({ queryKey: ['gitops-write-evidence'] }) })
172
+ await h.rerender()
173
+ expect(h.ref.state!.guard?.pending).toBe(true)
174
+ expect(canConfirmGitOpsWrite(h.ref.state!.guard, true)).toBe(false)
175
+ await act(async () => resolveSecond(evidence))
176
+ await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
177
+ } finally {
178
+ await h.cleanup()
179
+ }
180
+ })
181
+
182
+ it('does not keep an exemption after a refetch fails', async () => {
183
+ const exempt = {
184
+ uid: 'u',
185
+ resourceVersion: '1',
186
+ owner: { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' },
187
+ policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null, respectIgnoreDifferences: true },
188
+ paths: [{ path: 'spec.replicas', lastApplied: 'present', ownedBy: [], ownedByGitOps: false, ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' }],
189
+ }
190
+ mocks.evidence.mockResolvedValueOnce(exempt).mockRejectedValueOnce(new Error('503 cache not ready'))
191
+ const h = await renderGuard({
192
+ target: deployment,
193
+ resource: {},
194
+ relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }] },
195
+ writes: replicas,
196
+ })
197
+ try {
198
+ await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
199
+ expect(h.ref.state!.guard?.level).toBe('info')
200
+ await act(async () => { await h.queryClient.invalidateQueries({ queryKey: ['gitops-write-evidence'] }) })
201
+ await act(async () => vi.waitFor(() => {
202
+ expect(h.ref.state!.guard?.pending).toBe(false)
203
+ expect(h.ref.state!.guard?.level).toBe('may-revert')
204
+ }))
205
+ expect(h.ref.state!.guard?.requiresAck).toBe(true)
206
+ } finally {
207
+ await h.cleanup()
208
+ }
209
+ })
210
+
211
+ it('asks for an acknowledgment, not a block, on a Radar without write evidence', async () => {
212
+ mocks.evidenceUnsupported = true
213
+ const h = await renderGuard({
214
+ target: deployment,
215
+ resource: {},
216
+ relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }] },
217
+ writes: replicas,
218
+ })
219
+ try {
220
+ await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
221
+ expect(mocks.evidence).not.toHaveBeenCalled()
222
+ const guard = h.ref.state!.guard
223
+ expect(guard?.level).toBe('may-revert')
224
+ expect(guard?.perWrite[0].reason).toContain('need a newer Radar')
225
+ expect(canConfirmGitOpsWrite(guard, false)).toBe(false)
226
+ expect(canConfirmGitOpsWrite(guard, true)).toBe(true)
227
+ } finally {
228
+ await h.cleanup()
229
+ }
230
+ })
231
+
232
+ const pod ={ kind: 'Pod', group: '', namespace: 'prod', name: 'web-1-abc' }
233
+ const podRelationships = {
234
+ owner: { kind: 'ReplicaSet', group: 'apps', namespace: 'prod', name: 'web-1' },
235
+ deployment: { kind: 'Deployment', group: 'apps', namespace: 'prod', name: 'web' },
236
+ }
237
+
238
+ it('reads a parent without relationships as unknown ownership, not as unmanaged', async () => {
239
+ mocks.inherited = { data: { resource: { metadata: { labels: { 'kustomize.toolkit.fluxcd.io/name': 'apps' } } } }, isPending: false, isError: false }
240
+ const h = await renderGuard({ target: pod, resource: {}, relationships: podRelationships, writes: replicas })
241
+ try {
242
+ expect(mocks.evidence).not.toHaveBeenCalled()
243
+ expect(h.ref.state!.guard?.level).toBe('may-revert')
244
+ expect(h.ref.state!.guard?.requiresAck).toBe(true)
245
+ expect(h.ref.state!.guard?.ownershipError).toContain("hasn't mapped this resource's owners")
246
+ } finally {
247
+ await h.cleanup()
248
+ }
249
+ })
250
+
251
+ it('does not keep an exemption after the parent lookup fails', async () => {
252
+ const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }
253
+ mocks.inherited = { data: { resource: {}, relationships: { managedBy: [app] } }, isPending: false, isError: false }
254
+ mocks.evidence.mockResolvedValue({
255
+ uid: 'u',
256
+ resourceVersion: '1',
257
+ owner: app,
258
+ policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null, respectIgnoreDifferences: true },
259
+ paths: [{ path: 'spec.replicas', lastApplied: 'present', ownedBy: [], ownedByGitOps: false, ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' }],
260
+ })
261
+ const h = await renderGuard({ target: pod, resource: {}, relationships: podRelationships, writes: replicas })
262
+ try {
263
+ await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
264
+ expect(h.ref.state!.guard?.level).toBe('info')
265
+ // React Query keeps the parent's data after a failed refetch.
266
+ mocks.inherited = { ...mocks.inherited, isError: true }
267
+ await h.rerender()
268
+ expect(h.ref.state!.guard?.pending).toBe(false)
269
+ expect(h.ref.state!.guard?.level).toBe('may-revert')
270
+ expect(h.ref.state!.guard?.requiresAck).toBe(true)
271
+ } finally {
272
+ await h.cleanup()
273
+ }
274
+ })
275
+
276
+ it('does not keep an exemption after the target lookup fails', async () => {
277
+ const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' }
278
+ mocks.target = { data: {}, relationships: { managedBy: [app] }, isLoading: false, isError: false }
279
+ mocks.evidence.mockResolvedValue({
280
+ uid: 'u',
281
+ resourceVersion: '1',
282
+ owner: app,
283
+ policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null, respectIgnoreDifferences: true },
284
+ paths: [{ path: 'spec.replicas', lastApplied: 'present', ownedBy: [], ownedByGitOps: false, ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' }],
285
+ })
286
+ const h = await renderGuard({ target: deployment, writes: replicas })
287
+ try {
288
+ await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
289
+ expect(h.ref.state!.guard?.level).toBe('info')
290
+ // React Query keeps the target's data after a failed refetch.
291
+ mocks.target = { ...mocks.target, isError: true }
292
+ await h.rerender()
293
+ expect(h.ref.state!.guard?.pending).toBe(false)
294
+ expect(h.ref.state!.guard?.level).toBe('may-revert')
295
+ expect(canConfirmGitOpsWrite(h.ref.state!.guard, false)).toBe(false)
296
+ } finally {
297
+ await h.cleanup()
298
+ }
299
+ })
300
+
301
+ it('asks for an acknowledgment, not a block, when the parent cannot be read', async () => {
302
+ mocks.inherited = { data: undefined, isPending: false, isError: true }
303
+ const h = await renderGuard({ target: pod, resource: {}, relationships: podRelationships, writes: replicas })
304
+ try {
305
+ expect(mocks.evidence).not.toHaveBeenCalled()
306
+ expect(h.ref.state!.guard?.level).toBe('may-revert')
307
+ expect(h.ref.state!.guard?.ownershipError).toContain("couldn't read the resource that owns this one")
308
+ expect(canConfirmGitOpsWrite(h.ref.state!.guard, false)).toBe(false)
309
+ expect(canConfirmGitOpsWrite(h.ref.state!.guard, true)).toBe(true)
310
+ } finally {
311
+ await h.cleanup()
312
+ }
313
+ })
314
+
315
+ it('lets an ignore rule waive the acknowledgment once the server confirms a name-only Argo match', async () => {
316
+ mocks.applications.isPending = false
317
+ mocks.applications.data = [{ metadata: { name: 'web', namespace: 'argocd' } }]
318
+ const exempt = (ownerTracksTarget: boolean) => ({
319
+ uid: 'u',
320
+ resourceVersion: '1',
321
+ owner: { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' },
322
+ ownerTracksTarget,
323
+ policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null, respectIgnoreDifferences: true },
324
+ paths: [{ path: 'spec.replicas', lastApplied: 'present', ownedBy: [], ownedByGitOps: false, ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' }],
325
+ })
326
+ for (const [tracks, level] of [[true, 'info'], [false, 'may-revert']] as const) {
327
+ mocks.evidence.mockResolvedValueOnce(exempt(tracks))
328
+ const h = await renderGuard({
329
+ target: deployment,
330
+ resource: {},
331
+ // Argo CD 3's tracking id omits the namespace of Applications in its own namespace.
332
+ relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: '', name: 'web' }] },
333
+ writes: replicas,
334
+ })
335
+ try {
336
+ await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
337
+ expect(h.ref.state!.guard?.level).toBe(level)
338
+ } finally {
339
+ await h.cleanup()
340
+ }
341
+ }
342
+ })
343
+
344
+ it("takes the Application the server confirmed when the browser can't list Applications", async () => {
345
+ mocks.applications.isPending = false
346
+ mocks.applications.data = []
347
+ mocks.evidence.mockResolvedValueOnce({
348
+ uid: 'u',
349
+ resourceVersion: '1',
350
+ owner: { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'web' },
351
+ ownerTracksTarget: true,
352
+ policy: { tool: 'argocd', auto: true, selfHeal: true, prune: false, suspended: null, respectIgnoreDifferences: true },
353
+ paths: [{ path: 'spec.replicas', lastApplied: 'present', ownedBy: [], ownedByGitOps: false, ignored: 'effective', ignoredBy: 'spec.ignoreDifferences' }],
354
+ })
355
+ const h = await renderGuard({
356
+ target: deployment,
357
+ resource: {},
358
+ relationships: { managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: '', name: 'web' }] },
359
+ writes: replicas,
360
+ })
361
+ try {
362
+ await act(async () => vi.waitFor(() => expect(h.ref.state!.guard?.pending).toBe(false)))
363
+ expect(mocks.evidence.mock.calls[0][0].owner.namespace).toBe('')
364
+ expect(h.ref.state!.guard?.owner?.namespace).toBe('argocd')
365
+ expect(h.ref.state!.guard?.level).toBe('info')
366
+ expect(h.ref.state!.ownership.ownerVerified).toBe(true)
367
+ } finally {
368
+ await h.cleanup()
369
+ }
370
+ })
371
+
372
+ it('reports the listed and changed containers so the host can judge exactly those', async () => {
373
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
374
+ const root = createRoot(document.createElement('div'))
375
+ const onSelectionChange = vi.fn()
376
+ const guard = { owner: null, helmRelease: null, pending: false, level: 'none', perWrite: [], summary: '', syncPolicy: null, requiresAck: false, ownershipError: null } as never
377
+ const onLoad = vi.fn(async (): Promise<WorkloadImageInventory> => ({
378
+ target: { kind: 'Deployment', group: 'apps', resource: 'deployments', namespace: 'prod', name: 'web' },
379
+ containers: [{ type: 'container', name: 'app', image: 'app:old' }, { type: 'initContainer', name: 'migrate', image: 'migrate:old' }],
380
+ behavior: { type: 'rolling' },
381
+ }))
382
+ try {
383
+ await act(async () => root.render(<SetImageDialog open workloadLabel="Deployment" workloadName="web" workloadResource="deployments" ownership={{ guard, onSelectionChange }} onLoad={onLoad} onClose={() => {}} onConfirm={async () => {}} />))
384
+ await act(async () => vi.waitFor(() => expect(onSelectionChange).toHaveBeenLastCalledWith({
385
+ containers: [{ type: 'container', name: 'app' }, { type: 'initContainer', name: 'migrate' }],
386
+ changed: [],
387
+ })))
388
+ } finally {
389
+ await act(async () => root.unmount())
390
+ }
391
+ })
392
+
393
+ it('keeps Update disabled until the host has judged the containers being changed', async () => {
394
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
395
+ const host = document.createElement('div')
396
+ document.body.appendChild(host)
397
+ const root = createRoot(host)
398
+ const unmanaged = { owner: null, helmRelease: null, pending: false, level: 'none', perWrite: [], summary: '', syncPolicy: null, requiresAck: false, ownershipError: null } as never
399
+ const onLoad = vi.fn(async (): Promise<WorkloadImageInventory> => ({
400
+ target: { kind: 'Deployment', group: 'apps', resource: 'deployments', namespace: 'prod', name: 'web' },
401
+ containers: [{ type: 'container', name: 'app', image: 'app:old' }],
402
+ behavior: { type: 'rolling' },
403
+ }))
404
+ const changed = setImageSelectionKey({ containers: [{ type: 'container', name: 'app' }], changed: [{ type: 'container', name: 'app' }] })
405
+ const render = (selectionKey: string) =>
406
+ root.render(<SetImageDialog open workloadLabel="Deployment" workloadName="web" workloadResource="deployments" ownership={{ guard: unmanaged, onSelectionChange: () => {}, selectionKey }} onLoad={onLoad} onClose={() => {}} onConfirm={async () => {}} />)
407
+ const update = () => [...document.querySelectorAll('button')].find((b) => /^Update \d+ image/.test(b.textContent ?? '')) as HTMLButtonElement | undefined
408
+ try {
409
+ await act(async () => render(''))
410
+ const input = await vi.waitFor(() => {
411
+ const el = document.querySelector('input[aria-label^="New image"]') as HTMLInputElement | null
412
+ expect(el).not.toBeNull()
413
+ return el!
414
+ })
415
+ await act(async () => {
416
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'app:new')
417
+ input.dispatchEvent(new Event('input', { bubbles: true }))
418
+ })
419
+ // The guard on screen was judged before the edit.
420
+ expect(update()?.disabled).toBe(true)
421
+ await act(async () => render(changed))
422
+ expect(update()?.disabled).toBe(false)
423
+ } finally {
424
+ await act(async () => root.unmount())
425
+ host.remove()
426
+ }
427
+ })
428
+
429
+ it("doesn't hold Update for a host that reports the selection without judging it", async () => {
430
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
431
+ const host = document.createElement('div')
432
+ document.body.appendChild(host)
433
+ const root = createRoot(host)
434
+ const unmanaged = { owner: null, helmRelease: null, pending: false, level: 'none', perWrite: [], summary: '', syncPolicy: null, requiresAck: false, ownershipError: null } as never
435
+ const onLoad = vi.fn(async (): Promise<WorkloadImageInventory> => ({
436
+ target: { kind: 'Deployment', group: 'apps', resource: 'deployments', namespace: 'prod', name: 'web' },
437
+ containers: [{ type: 'container', name: 'app', image: 'app:old' }],
438
+ behavior: { type: 'rolling' },
439
+ }))
440
+ try {
441
+ await act(async () => root.render(<SetImageDialog open workloadLabel="Deployment" workloadName="web" workloadResource="deployments" ownership={{ guard: unmanaged, onSelectionChange: () => {} }} onLoad={onLoad} onClose={() => {}} onConfirm={async () => {}} />))
442
+ const input = await vi.waitFor(() => {
443
+ const el = document.querySelector('input[aria-label^="New image"]') as HTMLInputElement | null
444
+ expect(el).not.toBeNull()
445
+ return el!
446
+ })
447
+ await act(async () => {
448
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'app:new')
449
+ input.dispatchEvent(new Event('input', { bubbles: true }))
450
+ })
451
+ const update = [...document.querySelectorAll('button')].find((b) => /^Update \d+ image/.test(b.textContent ?? '')) as HTMLButtonElement | undefined
452
+ expect(update?.disabled).toBe(false)
453
+ } finally {
454
+ await act(async () => root.unmount())
455
+ host.remove()
456
+ }
457
+ })
@@ -0,0 +1,193 @@
1
+ import { useMemo } from 'react'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import {
4
+ evaluateGitOpsWriteGuard,
5
+ gitOpsWriteEvidencePaths,
6
+ type GitOpsWrite,
7
+ type GitOpsWriteEvidence,
8
+ type GitOpsWriteGuard,
9
+ type GitOpsWriteTarget,
10
+ } from '@skyhook-io/k8s-ui'
11
+ import type { Relationships } from '../types'
12
+ import { fetchGitOpsWriteEvidence, useResource, useRadarFeature } from '../api/client'
13
+ import { getApiBase } from '../api/config'
14
+ import { kindToPluralWithGroup } from '../utils/navigation'
15
+ import { gitOpsOwnerKindRef, useResolvedGitOpsOwner, type ResolvedGitOpsOwner } from './useResolvedGitOpsOwner'
16
+
17
+ export interface GitOpsWriteGuardState {
18
+ /** Undefined only while disabled. */
19
+ guard: GitOpsWriteGuard | undefined
20
+ ownership: ResolvedGitOpsOwner
21
+ evidence: GitOpsWriteEvidence | undefined
22
+ }
23
+
24
+ // Evaluates the GitOps write guard for a write to `target`: resolves the
25
+ // owner (or takes one the host already resolved), asks the server for the
26
+ // field-level evidence the browser can't see, and classifies the writes.
27
+ //
28
+ // `relationships`/`resource` may be omitted; the target is then fetched.
29
+ export function useGitOpsWriteGuard({
30
+ target,
31
+ writes,
32
+ relationships,
33
+ resource,
34
+ ownership: resolved,
35
+ relationshipsUnavailable = false,
36
+ declaredManager,
37
+ evidencePaths: requestedPaths,
38
+ enabled = true,
39
+ }: {
40
+ target: GitOpsWriteTarget
41
+ writes: GitOpsWrite[]
42
+ /** Paths to ask the server about, when wider than the writes being judged
43
+ * (every container a dialog lists, while judging the ones being changed). */
44
+ evidencePaths?: string[]
45
+ relationships?: Relationships
46
+ resource?: unknown
47
+ /** Pass when the host already resolved ownership for this target. */
48
+ ownership?: ResolvedGitOpsOwner
49
+ /** The host read the target but the server sent no relationships (its
50
+ * topology isn't built yet), so ownership is unknown rather than absent. */
51
+ relationshipsUnavailable?: boolean
52
+ /** A manager the server detected on its own (e.g. "Argo CD"). When the
53
+ * browser can't resolve an owner, ownership is unknown rather than absent. */
54
+ declaredManager?: string | null
55
+ enabled?: boolean
56
+ }): GitOpsWriteGuardState {
57
+ const needsTarget = enabled && !resolved && relationships === undefined
58
+ const targetQuery = useResource<any>(
59
+ needsTarget ? kindToPluralWithGroup(target.kind, target.group) : '',
60
+ target.namespace,
61
+ target.name,
62
+ target.group || undefined,
63
+ // Keyed by API base: a host can point one QueryClient at several clusters
64
+ // (Radar Hub's fleet Diagnose panel), and the shared resource keys aren't.
65
+ { enabled: needsTarget, cacheScope: getApiBase() },
66
+ )
67
+ const ownRelationships = relationships ?? targetQuery.relationships
68
+ const ownResolution = useResolvedGitOpsOwner({
69
+ kind: kindToPluralWithGroup(target.kind, target.group),
70
+ group: target.group || undefined,
71
+ namespace: target.namespace,
72
+ name: target.name,
73
+ relationships: ownRelationships,
74
+ resource: resource ?? targetQuery.data,
75
+ enabled: enabled && !resolved,
76
+ cacheScope: getApiBase(),
77
+ })
78
+ const ownership = resolved ?? ownResolution
79
+
80
+ const targetLookupPending = needsTarget && targetQuery.isLoading
81
+ const targetLookupFailed = needsTarget && targetQuery.isError
82
+ const lookupPending = targetLookupPending || ownership.lookupPending
83
+ const managed = Boolean(ownership.owner || ownership.helmOwner)
84
+
85
+ const writesKey = JSON.stringify(writes)
86
+ const requestedPathsKey = requestedPaths ? JSON.stringify(requestedPaths) : ''
87
+ const paths = useMemo(
88
+ () => (requestedPathsKey ? (JSON.parse(requestedPathsKey) as string[]) : gitOpsWriteEvidencePaths(JSON.parse(writesKey))),
89
+ [requestedPathsKey, writesKey],
90
+ )
91
+ const ownerRef = useMemo(() => {
92
+ if (ownership.owner) {
93
+ return { ...gitOpsOwnerKindRef(ownership.owner), namespace: ownership.owner.namespace, name: ownership.owner.name }
94
+ }
95
+ if (ownership.helmOwner) {
96
+ return { kind: 'HelmRelease', group: '', namespace: ownership.helmOwner.namespace, name: ownership.helmOwner.name }
97
+ }
98
+ return null
99
+ }, [ownership.owner, ownership.helmOwner])
100
+ // An Argo owner without a namespace is still asked about: the server finds
101
+ // the Application whose status lists the object.
102
+ const canFetchEvidence = managed && !lookupPending && !targetLookupFailed && !ownership.lookupError
103
+
104
+ const evidenceFeature = useRadarFeature('gitopsWriteEvidence')
105
+ const evidenceQuery = useQuery({
106
+ // The API base identifies the cluster when a host switches it (Radar Hub).
107
+ queryKey: ['gitops-write-evidence', getApiBase(), target.kind, target.group, target.namespace, target.name, paths, ownerRef, ...evidenceFeature.gatedKey],
108
+ queryFn: () =>
109
+ evidenceFeature.guard(() =>
110
+ fetchGitOpsWriteEvidence({
111
+ kind: target.kind,
112
+ group: target.group,
113
+ namespace: target.namespace,
114
+ name: target.name,
115
+ paths,
116
+ owner: ownerRef ?? undefined,
117
+ }),
118
+ ),
119
+ enabled: enabled && canFetchEvidence,
120
+ staleTime: 0,
121
+ retry: false,
122
+ })
123
+
124
+ const targetRelationshipsMissing = needsTarget && targetQuery.data != null && targetQuery.relationships == null
125
+ const unresolvedDeclaredManager = Boolean(declaredManager) && !managed && !lookupPending
126
+ const unknownOwner = "so it can't tell whether a GitOps tool or Helm manages it."
127
+ const ownershipError = targetLookupFailed
128
+ ? `Radar couldn't read this resource, ${unknownOwner}`
129
+ : ownership.lookupError
130
+ ? `Radar couldn't read the resource that owns this one, ${unknownOwner}`
131
+ : relationshipsUnavailable || targetRelationshipsMissing || ownership.relationshipsUnavailable
132
+ ? `Radar hasn't mapped this resource's owners yet, ${unknownOwner}`
133
+ : unresolvedDeclaredManager
134
+ ? `Its labels mark it as managed by ${declaredManager}, but Radar couldn't find the owner to check whether it would revert this change.`
135
+ : null
136
+
137
+ // React Query keeps earlier data after a failed refetch, and while the query
138
+ // is disabled (an unreadable target or owner); an exemption it carried may no
139
+ // longer hold.
140
+ const evidence = evidenceQuery.isError || !canFetchEvidence ? undefined : evidenceQuery.data
141
+
142
+ // Argo CD 3's tracking id omits the namespace of Applications in its own
143
+ // namespace, and the browser's namespace view may hide them; take the
144
+ // Application the server confirmed.
145
+ const confirmedOwnership = useMemo<ResolvedGitOpsOwner>(() => {
146
+ const owner = ownership.owner
147
+ const namespace = evidence?.ownerTracksTarget ? evidence.owner?.namespace : undefined
148
+ if (!owner || owner.namespace || !namespace) return ownership
149
+ return { ...ownership, owner: { ...owner, namespace }, ownerVerified: true }
150
+ }, [ownership, evidence])
151
+
152
+ const guard = useMemo(
153
+ () =>
154
+ enabled
155
+ ? evaluateGitOpsWriteGuard({
156
+ target,
157
+ owner: confirmedOwnership.owner,
158
+ helmRelease: ownership.helmOwner,
159
+ // A refetch counts as pending: an earlier verdict may no longer hold.
160
+ ownerPending: lookupPending || (canFetchEvidence && (evidenceQuery.isPending || evidenceQuery.isFetching)),
161
+ ownershipError,
162
+ // The Application's status listing the object confirms a name match.
163
+ ownerMatchedByName: ownership.ownerMatchedByName && !evidence?.ownerTracksTarget,
164
+ evidence,
165
+ evidenceError: evidenceQuery.error instanceof Error ? evidenceQuery.error.message : null,
166
+ writes: JSON.parse(writesKey),
167
+ })
168
+ : undefined,
169
+ // target fields are listed individually so an inline literal doesn't
170
+ // re-evaluate every render.
171
+ // eslint-disable-next-line react-hooks/exhaustive-deps
172
+ [
173
+ enabled,
174
+ target.kind,
175
+ target.group,
176
+ target.namespace,
177
+ target.name,
178
+ confirmedOwnership.owner,
179
+ ownership.helmOwner,
180
+ lookupPending,
181
+ canFetchEvidence,
182
+ evidenceQuery.isPending,
183
+ evidenceQuery.isFetching,
184
+ evidence,
185
+ evidenceQuery.error,
186
+ ownershipError,
187
+ ownership.ownerMatchedByName,
188
+ writesKey,
189
+ ],
190
+ )
191
+
192
+ return { guard, ownership: confirmedOwnership, evidence }
193
+ }