@skyhook-io/k8s-ui 1.19.0 → 1.20.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.
- package/package.json +9 -2
- package/src/components/applications/ApplicationDetail.tsx +8 -2
- package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
- package/src/components/compare/ResourceCompareView.test.tsx +54 -0
- package/src/components/compare/ResourceCompareView.tsx +35 -12
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
- package/src/components/gitops/GitOpsTableView.tsx +26 -7
- package/src/components/gitops/RollbackDialog.test.tsx +80 -0
- package/src/components/gitops/RollbackDialog.tsx +25 -6
- package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
- package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
- package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
- package/src/components/gitops/applicationset-row.test.ts +12 -0
- package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
- package/src/components/gitops/detail-helpers.test.ts +8 -2
- package/src/components/gitops/detail-helpers.ts +9 -11
- package/src/components/gitops/index.ts +1 -1
- package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +30 -12
- package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
- package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +37 -0
- package/src/components/gitops/insights/insights-helpers.ts +12 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -1
- package/src/components/issues/diagnostic.ts +14 -1
- package/src/components/issues/severity.ts +14 -0
- package/src/components/kueue/AdmissionPath.tsx +223 -0
- package/src/components/kueue/QueueEvidence.tsx +148 -0
- package/src/components/kueue/constants.ts +1 -0
- package/src/components/kueue/eligibility.ts +43 -0
- package/src/components/kueue/flavors-tried.ts +79 -0
- package/src/components/kueue/helpers.test.ts +105 -0
- package/src/components/kueue/policy.ts +100 -0
- package/src/components/kueue/queue-detail.test.tsx +317 -0
- package/src/components/kueue/queue-status.ts +42 -0
- package/src/components/kueue/related-read.ts +48 -0
- package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
- package/src/components/logs/StructuredLogLine.test.tsx +22 -0
- package/src/components/logs/StructuredLogLine.tsx +8 -3
- package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
- package/src/components/resources/KueueAdmissionSection.tsx +51 -12
- package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
- package/src/components/resources/KueuePendingPosition.tsx +91 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
- package/src/components/resources/ResourcesSidebar.tsx +3 -2
- package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
- package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
- package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
- package/src/components/resources/ResourcesView.tsx +50 -3
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +7 -0
- package/src/components/resources/get-pod-phase-display.test.ts +70 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
- package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
- package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
- package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/JobRenderer.tsx +7 -2
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
- package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
- package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
- package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
- package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
- package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
- package/src/components/resources/renderers/crd-cells.tsx +55 -0
- package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
- package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
- package/src/components/resources/renderers/index.ts +6 -0
- package/src/components/resources/renderers/kueue-cells.tsx +5 -0
- package/src/components/resources/resource-utils-argo.test.ts +94 -1
- package/src/components/resources/resource-utils-argo.ts +84 -5
- package/src/components/resources/resource-utils-crd.test.ts +32 -0
- package/src/components/resources/resource-utils-crd.ts +26 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
- package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
- package/src/components/resources/resource-utils-kueue.test.ts +15 -0
- package/src/components/resources/resource-utils-kueue.ts +137 -4
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
- package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
- package/src/components/resources/resource-utils-ray.test.ts +42 -1
- package/src/components/resources/resource-utils-ray.ts +38 -0
- package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
- package/src/components/resources/resource-utils-strimzi.ts +35 -0
- package/src/components/resources/resource-utils.ts +45 -1
- package/src/components/shared/ResourceActionsBar.tsx +83 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
- package/src/components/topology/K8sResourceNode.test.ts +14 -0
- package/src/components/topology/K8sResourceNode.tsx +18 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +96 -94
- package/src/components/topology/layout.ts +3 -1
- package/src/components/topology/pod-group-expansion.test.ts +38 -0
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/RadarUpgradeNote.tsx +8 -0
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/PodList.tsx +35 -9
- package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
- package/src/components/workload/WorkloadView.tsx +61 -8
- package/src/components/workload/workload-logs-availability.test.ts +7 -0
- package/src/types/core.ts +40 -0
- package/src/types/gitops-insights.ts +11 -2
- package/src/types/gitops-tree.ts +2 -2
- package/src/types/kueue-queues.ts +84 -0
- package/src/types/read-coverage.ts +2 -1
- package/src/types/scheduling.ts +72 -0
- package/src/utils/animation.test.ts +38 -0
- package/src/utils/animation.ts +34 -0
- package/src/utils/api-resources-crd.test.ts +11 -0
- package/src/utils/api-resources.ts +5 -1
- package/src/utils/applications.test.ts +15 -1
- package/src/utils/applications.ts +26 -1
- package/src/utils/crd-schema.test.ts +107 -0
- package/src/utils/crd-schema.ts +201 -0
- package/src/utils/format.test.ts +37 -1
- package/src/utils/format.ts +28 -80
- package/src/utils/gitops-owner.test.ts +28 -2
- package/src/utils/gitops-owner.ts +19 -0
- package/src/utils/kueue-queue-workloads.ts +19 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +90 -1
- package/src/utils/topology-neighborhood.ts +55 -3
- package/src/utils/workload-history-notes.test.ts +76 -0
- package/src/utils/workload-history-notes.ts +93 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { renderToString } from 'react-dom/server'
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import type { RadarUpgradeRequirement } from '../../types/fetch-error'
|
|
7
|
+
import { GitOpsDetailLayout, type ArgoActionHandlers, type GitOpsDetailStatus } from './GitOpsDetailLayout'
|
|
8
|
+
import { buildRowActionItems, normalizeArgoApplication } from './GitOpsTableView'
|
|
9
|
+
import { ResourceActionsBar } from '../shared/ResourceActionsBar'
|
|
10
|
+
|
|
11
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
12
|
+
|
|
13
|
+
// SSR splits interpolated text with <!-- --> markers; strip so assertions read as prose.
|
|
14
|
+
const html = (node: Parameters<typeof renderToString>[0]) => renderToString(node).replace(/<!-- -->/g, '')
|
|
15
|
+
|
|
16
|
+
// The attributes of the button labelled `label`.
|
|
17
|
+
function buttonAttrs(markup: string, label: string): string {
|
|
18
|
+
const chunk = markup.split('<button').find((part) => part.includes(`${label}</button>`))
|
|
19
|
+
if (chunk === undefined) throw new Error(`no ${label} button rendered`)
|
|
20
|
+
return chunk.slice(0, chunk.indexOf('>'))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const upgrade: RadarUpgradeRequirement = {
|
|
24
|
+
feature: 'Auto-sync controls for Applications with automated.enabled: false',
|
|
25
|
+
currentVersion: 'v1.16.3',
|
|
26
|
+
latestVersion: 'v1.17.0',
|
|
27
|
+
}
|
|
28
|
+
const NOTE = 'Auto-sync controls for Applications with automated.enabled: false need the latest Radar (v1.17) on this cluster. It runs v1.16.3.'
|
|
29
|
+
// RadarUpgradeNote sets the feature in its own <span>.
|
|
30
|
+
const NOTE_MARKUP = 'Auto-sync controls for Applications with automated.enabled: false</span> need the latest Radar (v1.17) on this cluster. It runs v1.16.3.'
|
|
31
|
+
|
|
32
|
+
function app(automated: Record<string, unknown> | undefined, annotations?: Record<string, string>) {
|
|
33
|
+
return {
|
|
34
|
+
apiVersion: 'argoproj.io/v1alpha1',
|
|
35
|
+
metadata: { name: 'guestbook', namespace: 'argocd', annotations },
|
|
36
|
+
spec: { project: 'default', source: {}, destination: {}, syncPolicy: automated ? { automated } : {} },
|
|
37
|
+
status: {},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function argo(overrides: Partial<ArgoActionHandlers> = {}): ArgoActionHandlers {
|
|
42
|
+
const noop = () => {}
|
|
43
|
+
return {
|
|
44
|
+
onSyncRequested: noop, onRefresh: noop, onTerminate: noop, onSuspend: noop, onResume: noop,
|
|
45
|
+
syncing: false, refreshing: false, refreshingKind: 'normal', terminating: false, suspending: false, resuming: false,
|
|
46
|
+
autoSyncEnabled: false, isRunning: false,
|
|
47
|
+
...overrides,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function layout(handlers: ArgoActionHandlers, status: GitOpsDetailStatus | null = null) {
|
|
52
|
+
return (
|
|
53
|
+
<GitOpsDetailLayout
|
|
54
|
+
identity={{ kind: 'applications', group: 'argoproj.io', namespace: 'argocd', name: 'guestbook', toolLabel: 'ArgoCD', kindLabel: 'Application' }}
|
|
55
|
+
status={status}
|
|
56
|
+
terminating={false}
|
|
57
|
+
terminatingChipTooltip=""
|
|
58
|
+
terminatingActionTooltip=""
|
|
59
|
+
detail={{ project: 'default' }}
|
|
60
|
+
insight={null}
|
|
61
|
+
insightLoading={false}
|
|
62
|
+
isArgoApp
|
|
63
|
+
isFlux={false}
|
|
64
|
+
isFluxWorkload={false}
|
|
65
|
+
argo={handlers}
|
|
66
|
+
activeTab="activity"
|
|
67
|
+
onTabChange={() => {}}
|
|
68
|
+
fullscreen={false}
|
|
69
|
+
onToggleFullscreen={() => {}}
|
|
70
|
+
renderTabBody={() => null}
|
|
71
|
+
onNavigateRoot={() => {}}
|
|
72
|
+
/>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const enableAutoSync = (markup: string) => buttonAttrs(markup, 'Enable auto-sync')
|
|
77
|
+
|
|
78
|
+
describe('Enable auto-sync on the GitOps detail page', () => {
|
|
79
|
+
it('is disabled with the upgrade note when the Radar cannot clear enabled: false', () => {
|
|
80
|
+
const markup = html(layout(argo({ resumeUpgrade: upgrade })))
|
|
81
|
+
expect(enableAutoSync(markup)).toMatch(/\sdisabled=""/)
|
|
82
|
+
expect(markup).toContain(NOTE_MARKUP)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('is enabled, with no note, when the Radar advertises the behavior', () => {
|
|
86
|
+
const markup = html(layout(argo({ resumeUpgrade: null })))
|
|
87
|
+
expect(enableAutoSync(markup)).not.toMatch(/\sdisabled=""/)
|
|
88
|
+
expect(markup).not.toContain('need the latest Radar')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Disable auto-sync works on every Radar; only the way back on is gated.
|
|
92
|
+
it('leaves Disable auto-sync alone', () => {
|
|
93
|
+
const markup = html(layout(argo({ autoSyncEnabled: true, resumeUpgrade: upgrade })))
|
|
94
|
+
expect(markup).toContain('Disable auto-sync')
|
|
95
|
+
expect(markup).not.toContain('need the latest Radar')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('tooltip', () => {
|
|
99
|
+
afterEach(() => { vi.useRealTimers() })
|
|
100
|
+
|
|
101
|
+
it.each([
|
|
102
|
+
{ name: 'saved settings', status: { sync: 'OutOfSync', health: 'Healthy', suspended: true } as GitOpsDetailStatus, settings: undefined, copy: 'Turn auto-sync back on with its current or saved settings.' },
|
|
103
|
+
{ name: 'first-time enable', status: { sync: 'OutOfSync', health: 'Healthy', suspended: false } as GitOpsDetailStatus, settings: { prune: false, selfHeal: true }, copy: 'Argo applies new commits and reverts live edits (self-heal). Resources removed from Git are kept (prune off).' },
|
|
104
|
+
{ name: 'first-time enable on a Radar that starts self-heal off', status: { sync: 'OutOfSync', health: 'Healthy', suspended: false } as GitOpsDetailStatus, settings: { prune: false, selfHeal: false }, copy: 'Argo applies new commits. Resources removed from Git are kept (prune off).' },
|
|
105
|
+
{ name: 'a block switched off in place with prune on', status: { sync: 'OutOfSync', health: 'Healthy', suspended: false } as GitOpsDetailStatus, settings: { prune: true, selfHeal: false }, copy: 'Argo applies new commits. Resources removed from Git are deleted (prune on).' },
|
|
106
|
+
{ name: 'unknown settings', status: null, settings: null, copy: 'Turn on automated sync, so Argo applies new commits from Git.' },
|
|
107
|
+
])('explains $name', async ({ status, settings, copy }) => {
|
|
108
|
+
vi.useFakeTimers()
|
|
109
|
+
const element = document.createElement('div')
|
|
110
|
+
document.body.appendChild(element)
|
|
111
|
+
const root = createRoot(element)
|
|
112
|
+
try {
|
|
113
|
+
await act(async () => { root.render(layout(argo({ resumeSettings: settings }), status)) })
|
|
114
|
+
const button = [...element.querySelectorAll('button')].find((b) => b.textContent === 'Enable auto-sync')!
|
|
115
|
+
await act(async () => {
|
|
116
|
+
button.parentElement!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
|
|
117
|
+
vi.advanceTimersByTime(1000)
|
|
118
|
+
})
|
|
119
|
+
expect(document.body.querySelector('[role="tooltip"]')?.textContent).toBe(copy)
|
|
120
|
+
} finally {
|
|
121
|
+
await act(async () => root.unmount())
|
|
122
|
+
element.remove()
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it.each([
|
|
127
|
+
{ name: 'Sync is available', copy: 'Stop Argo from automatically syncing Git changes. Manual Sync still works.' },
|
|
128
|
+
{ name: 'an operation holds Sync', running: true, copy: 'Stop Argo from automatically syncing Git changes.' },
|
|
129
|
+
{ name: 'the Application is suspended', suspended: true, copy: 'Stop Argo from automatically syncing Git changes.' },
|
|
130
|
+
{ name: 'an ApplicationSet owns it', controller: { kind: 'ApplicationSet' as const, name: 'clusters' }, copy: "Stop Argo from automatically syncing Git changes. Manual Sync still works. ApplicationSet clusters can turn it back on; to keep it off, change the ApplicationSet's template." },
|
|
131
|
+
{ name: 'a parent Application manages it', controller: { kind: 'Application' as const, name: 'apps' }, copy: 'Stop Argo from automatically syncing Git changes. Manual Sync still works. Application apps manages this one from Git and can turn it back on when it syncs; to keep it off, change it in Git.' },
|
|
132
|
+
{ name: 'an operation holds Sync on an ApplicationSet-owned app', running: true, controller: { kind: 'ApplicationSet' as const, name: 'clusters' }, copy: "Stop Argo from automatically syncing Git changes. ApplicationSet clusters can turn it back on; to keep it off, change the ApplicationSet's template." },
|
|
133
|
+
] as Array<{ name: string; running?: boolean; suspended?: boolean; controller?: ArgoActionHandlers['autoSyncController']; copy: string }>)('describes Disable auto-sync when $name', async ({ running = false, suspended, controller, copy }) => {
|
|
134
|
+
vi.useFakeTimers()
|
|
135
|
+
const element = document.createElement('div')
|
|
136
|
+
document.body.appendChild(element)
|
|
137
|
+
const root = createRoot(element)
|
|
138
|
+
try {
|
|
139
|
+
await act(async () => { root.render(layout(argo({ autoSyncEnabled: true, isRunning: running, autoSyncController: controller }), suspended ? { sync: 'Synced', health: 'Suspended', suspended: true } as GitOpsDetailStatus : null)) })
|
|
140
|
+
const button = [...element.querySelectorAll('button')].find((b) => b.textContent === 'Disable auto-sync')!
|
|
141
|
+
await act(async () => {
|
|
142
|
+
button.parentElement!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
|
|
143
|
+
vi.advanceTimersByTime(1000)
|
|
144
|
+
})
|
|
145
|
+
expect(document.body.querySelector('[role="tooltip"]')?.textContent).toBe(copy)
|
|
146
|
+
} finally {
|
|
147
|
+
await act(async () => root.unmount())
|
|
148
|
+
element.remove()
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('explains the disabled button with the note copy', async () => {
|
|
153
|
+
vi.useFakeTimers()
|
|
154
|
+
const element = document.createElement('div')
|
|
155
|
+
document.body.appendChild(element)
|
|
156
|
+
const root = createRoot(element)
|
|
157
|
+
await act(async () => { root.render(layout(argo({ resumeUpgrade: upgrade }))) })
|
|
158
|
+
const button = [...element.querySelectorAll('button')].find((b) => b.textContent === 'Enable auto-sync')!
|
|
159
|
+
await act(async () => {
|
|
160
|
+
button.parentElement!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
|
|
161
|
+
vi.advanceTimersByTime(1000)
|
|
162
|
+
})
|
|
163
|
+
expect(document.body.querySelector('[role="tooltip"]')?.textContent).toBe(NOTE)
|
|
164
|
+
await act(async () => root.unmount())
|
|
165
|
+
element.remove()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('Resume in the GitOps table row menu', () => {
|
|
171
|
+
const resume = (raw: ReturnType<typeof app>, gate: RadarUpgradeRequirement | null) =>
|
|
172
|
+
buildRowActionItems(normalizeArgoApplication(raw), () => {}, undefined, gate).find((item) => item.key === 'resume')
|
|
173
|
+
const suspended = { 'radarhq.io/suspended-prune': 'true' }
|
|
174
|
+
|
|
175
|
+
it('is disabled with the note copy for a suspended app whose automated block says enabled: false', () => {
|
|
176
|
+
const item = resume(app({ enabled: false, prune: true }, suspended), upgrade)
|
|
177
|
+
expect(item).toMatchObject({ disabled: true, disabledReason: NOTE })
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('stays enabled when the Radar advertises the behavior', () => {
|
|
181
|
+
expect(resume(app({ enabled: false, prune: true }, suspended), null)).toMatchObject({ disabled: false, disabledReason: undefined })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// A Radar-suspended app has no automated block; every Radar resumes it.
|
|
185
|
+
it('stays enabled for a suspended app without enabled: false', () => {
|
|
186
|
+
expect(resume(app(undefined, suspended), upgrade)).toMatchObject({ disabled: false, disabledReason: undefined })
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('Enable Auto-Sync in the resource actions bar', () => {
|
|
191
|
+
function bar(data: ReturnType<typeof app>, gate: RadarUpgradeRequirement | null) {
|
|
192
|
+
const markup = html(
|
|
193
|
+
<ResourceActionsBar
|
|
194
|
+
resource={{ kind: 'applications', group: 'argoproj.io', namespace: 'argocd', name: 'guestbook' }}
|
|
195
|
+
data={data}
|
|
196
|
+
onArgoResume={() => {}}
|
|
197
|
+
argoResumeUpgrade={gate}
|
|
198
|
+
/>,
|
|
199
|
+
)
|
|
200
|
+
return buttonAttrs(markup, 'Enable Auto-Sync')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
it('is disabled for an app with enabled: false on a Radar without the behavior', () => {
|
|
204
|
+
expect(bar(app({ enabled: false, prune: true }), upgrade)).toMatch(/\sdisabled=""/)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('is enabled when the Radar advertises the behavior', () => {
|
|
208
|
+
expect(bar(app({ enabled: false, prune: true }), null)).not.toMatch(/\sdisabled=""/)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// A Manual app without an automated block resumes correctly on every Radar.
|
|
212
|
+
it('is enabled for a Manual app without an automated block', () => {
|
|
213
|
+
expect(bar(app(undefined), upgrade)).not.toMatch(/\sdisabled=""/)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe("the drawer's one-click Sync", () => {
|
|
218
|
+
it("hands the Application's sync options to the caller", async () => {
|
|
219
|
+
const onArgoSync = vi.fn()
|
|
220
|
+
const host = document.createElement('div')
|
|
221
|
+
document.body.append(host)
|
|
222
|
+
const root = createRoot(host)
|
|
223
|
+
const application = { ...app(undefined), spec: { project: 'default', source: {}, destination: {}, syncPolicy: { syncOptions: ['CreateNamespace=true'] } } }
|
|
224
|
+
await act(async () => root.render(
|
|
225
|
+
<ResourceActionsBar
|
|
226
|
+
resource={{ kind: 'applications', namespace: 'argocd', name: 'guestbook', group: 'argoproj.io' }}
|
|
227
|
+
data={application}
|
|
228
|
+
onArgoSync={onArgoSync}
|
|
229
|
+
/>,
|
|
230
|
+
))
|
|
231
|
+
const sync = [...host.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Sync')!
|
|
232
|
+
await act(async () => sync.click())
|
|
233
|
+
expect(onArgoSync).toHaveBeenCalledWith({ namespace: 'argocd', name: 'guestbook', applicationSyncOptions: ['CreateNamespace=true'] })
|
|
234
|
+
await act(async () => root.unmount())
|
|
235
|
+
host.remove()
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe("the drawer's Sync with an options dialog", () => {
|
|
240
|
+
const source = { repoURL: 'https://example.com/app.git', path: 'app', targetRevision: 'main' }
|
|
241
|
+
const application = { ...app(undefined), status: { sync: { revision: 'abc1234def', comparedTo: { source } } }, spec: { project: 'default', source, destination: {}, syncPolicy: { syncOptions: ['CreateNamespace=true'] } } }
|
|
242
|
+
type OnArgoSync = (params: object) => void | Promise<unknown>
|
|
243
|
+
const bar = (name: string, onArgoSync: OnArgoSync) => (
|
|
244
|
+
<ResourceActionsBar
|
|
245
|
+
resource={{ kind: 'applications', namespace: 'argocd', name, group: 'argoproj.io' }}
|
|
246
|
+
data={{ ...application, metadata: { ...application.metadata, name } }}
|
|
247
|
+
onArgoSync={onArgoSync}
|
|
248
|
+
argoSyncDialog={{ pinsComparedRevision: true }}
|
|
249
|
+
/>
|
|
250
|
+
)
|
|
251
|
+
async function open(onArgoSync: OnArgoSync) {
|
|
252
|
+
const host = document.createElement('div')
|
|
253
|
+
document.body.append(host)
|
|
254
|
+
const root = createRoot(host)
|
|
255
|
+
await act(async () => root.render(bar('guestbook', onArgoSync)))
|
|
256
|
+
const sync = [...host.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Sync…')!
|
|
257
|
+
await act(async () => sync.click())
|
|
258
|
+
return {
|
|
259
|
+
rerender: async (name: string) => { await act(async () => root.render(bar(name, onArgoSync))) },
|
|
260
|
+
close: async () => { await act(async () => root.unmount()); host.remove() },
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// A closed dialog's portal stays briefly for its exit animation; use the newest.
|
|
264
|
+
const dialog = () => [...document.querySelectorAll('[role="dialog"]')].at(-1)!
|
|
265
|
+
const dialogButton = (label: string) => [...dialog().querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === label)!
|
|
266
|
+
|
|
267
|
+
it('opens the dialog instead of syncing, and syncs nothing on Cancel', async () => {
|
|
268
|
+
const onArgoSync = vi.fn<OnArgoSync>()
|
|
269
|
+
const { close } = await open(onArgoSync)
|
|
270
|
+
expect(dialog().textContent).toContain('Leave empty for the revision Argo last compared (abc1234def).')
|
|
271
|
+
expect(onArgoSync).not.toHaveBeenCalled()
|
|
272
|
+
await act(async () => dialogButton('Cancel').click())
|
|
273
|
+
expect(onArgoSync).not.toHaveBeenCalled()
|
|
274
|
+
await close()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('stays open with its choices when the sync fails, and closes once one succeeds', async () => {
|
|
278
|
+
let fail = true
|
|
279
|
+
const onArgoSync = vi.fn<OnArgoSync>(() => (fail ? Promise.reject(new Error('denied')) : Promise.resolve()))
|
|
280
|
+
const { close } = await open(onArgoSync)
|
|
281
|
+
const prune = () => [...dialog().querySelectorAll('label')].find((l) => l.textContent?.startsWith('Prune'))!.querySelector('input')!
|
|
282
|
+
await act(async () => prune().click())
|
|
283
|
+
await act(async () => dialogButton('Sync now').click())
|
|
284
|
+
expect(onArgoSync).toHaveBeenCalledTimes(1)
|
|
285
|
+
expect(dialog().textContent).toContain('Sync now')
|
|
286
|
+
expect(dialog().querySelector('[role="alert"]')?.textContent).toBe('Sync failed: denied')
|
|
287
|
+
expect(prune().checked).toBe(true)
|
|
288
|
+
fail = false
|
|
289
|
+
await act(async () => dialogButton('Sync now').click())
|
|
290
|
+
expect(onArgoSync.mock.calls[1][0]).toMatchObject({ prune: true })
|
|
291
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 600)) })
|
|
292
|
+
expect(document.body.textContent).not.toContain('Sync now')
|
|
293
|
+
await close()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('is disabled while an Argo operation runs, instead of opening a dialog that can do nothing', async () => {
|
|
297
|
+
const host = document.createElement('div')
|
|
298
|
+
document.body.append(host)
|
|
299
|
+
const root = createRoot(host)
|
|
300
|
+
const running = { ...application, status: { ...application.status, operationState: { phase: 'Running' } } }
|
|
301
|
+
await act(async () => root.render(
|
|
302
|
+
<ResourceActionsBar
|
|
303
|
+
resource={{ kind: 'applications', namespace: 'argocd', name: 'guestbook', group: 'argoproj.io' }}
|
|
304
|
+
data={running}
|
|
305
|
+
onArgoSync={vi.fn<OnArgoSync>()}
|
|
306
|
+
argoSyncDialog={{ pinsComparedRevision: true }}
|
|
307
|
+
/>,
|
|
308
|
+
))
|
|
309
|
+
const sync = [...host.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Sync…')!
|
|
310
|
+
expect(sync.disabled).toBe(true)
|
|
311
|
+
await act(async () => root.unmount())
|
|
312
|
+
host.remove()
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('closes, dropping its choices, when the drawer switches to another Application', async () => {
|
|
316
|
+
const onArgoSync = vi.fn<OnArgoSync>()
|
|
317
|
+
const { rerender, close } = await open(onArgoSync)
|
|
318
|
+
const prune = [...dialog().querySelectorAll('label')].find((l) => l.textContent?.startsWith('Prune'))!.querySelector('input')!
|
|
319
|
+
await act(async () => prune.click())
|
|
320
|
+
await rerender('other')
|
|
321
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 600)) })
|
|
322
|
+
expect(document.body.textContent).not.toContain('Sync now')
|
|
323
|
+
await rerender('guestbook')
|
|
324
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 600)) })
|
|
325
|
+
expect(document.body.textContent).not.toContain('Sync now')
|
|
326
|
+
await rerender('other')
|
|
327
|
+
const reopen = [...document.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Sync…')!
|
|
328
|
+
await act(async () => reopen.click())
|
|
329
|
+
const prunedAgain = [...dialog().querySelectorAll('label')].find((l) => l.textContent?.startsWith('Prune'))!.querySelector('input')!
|
|
330
|
+
expect(prunedAgain.checked).toBe(false)
|
|
331
|
+
await act(async () => dialogButton('Sync now').click())
|
|
332
|
+
expect(onArgoSync.mock.calls[0][0]).toMatchObject({ name: 'other', prune: false })
|
|
333
|
+
await close()
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it("syncs with the dialog's choices and the Application's options", async () => {
|
|
337
|
+
const onArgoSync = vi.fn<OnArgoSync>()
|
|
338
|
+
const { close } = await open(onArgoSync)
|
|
339
|
+
const prune = [...dialog().querySelectorAll('label')].find((l) => l.textContent?.startsWith('Prune'))!.querySelector('input')!
|
|
340
|
+
await act(async () => prune.click())
|
|
341
|
+
const confirm = dialogButton('Sync now')
|
|
342
|
+
expect(confirm.disabled).toBe(false)
|
|
343
|
+
await act(async () => confirm.click())
|
|
344
|
+
expect(onArgoSync).toHaveBeenCalledTimes(1)
|
|
345
|
+
expect(onArgoSync.mock.calls[0][0]).toMatchObject({ namespace: 'argocd', name: 'guestbook', applicationSyncOptions: ['CreateNamespace=true'], prune: true, dryRun: false })
|
|
346
|
+
expect(onArgoSync.mock.calls[0][0]).not.toHaveProperty('syncOptions')
|
|
347
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 600)) })
|
|
348
|
+
expect(document.body.textContent).not.toContain('Sync now')
|
|
349
|
+
await close()
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
describe('auto-sync support pending or unavailable', () => {
|
|
354
|
+
it.each(["Checking this cluster's Radar…", "Couldn't check this cluster's Radar support. Refresh to try again."])('disables each affected control with its reason: %s', reason => {
|
|
355
|
+
const markup = html(layout(argo({ resumeBlockedReason: reason })))
|
|
356
|
+
expect(enableAutoSync(markup)).toMatch(/\sdisabled=""/)
|
|
357
|
+
expect(markup).toContain(reason.replace(/'/g, '''))
|
|
358
|
+
const raw = app({ enabled: false }, { 'radarhq.io/suspended-prune': 'true' })
|
|
359
|
+
const onAction = vi.fn()
|
|
360
|
+
const item = buildRowActionItems(normalizeArgoApplication(raw), onAction, undefined, null, reason).find(i => i.key === 'resume')!
|
|
361
|
+
expect(item).toMatchObject({ disabled: true, disabledReason: reason })
|
|
362
|
+
item.onClick()
|
|
363
|
+
expect(onAction).not.toHaveBeenCalled()
|
|
364
|
+
const bar = html(<ResourceActionsBar resource={{ kind: 'applications', group: 'argoproj.io', namespace: 'argocd', name: 'guestbook' }} data={raw} onArgoResume={() => {}} argoResumeBlockedReason={reason} />)
|
|
365
|
+
expect(buttonAttrs(bar, 'Enable Auto-Sync')).toMatch(/\sdisabled=""/)
|
|
366
|
+
const ordinary = buildRowActionItems(normalizeArgoApplication(app(undefined, { 'radarhq.io/suspended-prune': 'true' })), onAction, undefined, null, reason).find(i => i.key === 'resume')!
|
|
367
|
+
expect(ordinary.disabled).toBe(false)
|
|
368
|
+
})
|
|
369
|
+
})
|
|
@@ -20,8 +20,9 @@ describe('parseArgoRollbackID', () => {
|
|
|
20
20
|
test('empty string returns null (would be Number("") === 0)', () => {
|
|
21
21
|
expect(parseArgoRollbackID('')).toBeNull()
|
|
22
22
|
})
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// Argo numbers history from 0, so the first deploy is a rollback target.
|
|
24
|
+
test('"0" is the first history entry', () => {
|
|
25
|
+
expect(parseArgoRollbackID('0')).toBe(0)
|
|
25
26
|
})
|
|
26
27
|
test('negative numbers return null', () => {
|
|
27
28
|
expect(parseArgoRollbackID('-5')).toBeNull()
|
|
@@ -29,6 +30,11 @@ describe('parseArgoRollbackID', () => {
|
|
|
29
30
|
test('Flux condition type string returns null', () => {
|
|
30
31
|
expect(parseArgoRollbackID('Ready')).toBeNull()
|
|
31
32
|
})
|
|
33
|
+
// Number() reads each of these as a number; with 0 now valid, a blank id
|
|
34
|
+
// would otherwise target the first deploy.
|
|
35
|
+
test.each([' ', '0x1f', '1e3', '1.5', '+1', ' 1'])('%j returns null', (id) => {
|
|
36
|
+
expect(parseArgoRollbackID(id)).toBeNull()
|
|
37
|
+
})
|
|
32
38
|
test('positive integer returns the number', () => {
|
|
33
39
|
expect(parseArgoRollbackID('42')).toBe(42)
|
|
34
40
|
})
|
|
@@ -52,19 +52,17 @@ export function gitOpsInsightChangeKey(ref: { kind: string; namespace?: string;
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// parseArgoRollbackID parses an Argo HistoryItem.id into the int64 the
|
|
55
|
-
// rollback API needs.
|
|
56
|
-
//
|
|
57
|
-
// - id is
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// first revision).
|
|
55
|
+
// rollback API needs. Argo's controller numbers history from 0, so "0" is
|
|
56
|
+
// the first deploy and a valid target. Returns null when:
|
|
57
|
+
// - id is missing or empty
|
|
58
|
+
// - id is not a plain non-negative integer: Flux condition rows reuse the
|
|
59
|
+
// same slot for condition.type, and Number() would also read "", " ",
|
|
60
|
+
// "0x1f" or "1e3" as a number and route the rollback to a wrong entry
|
|
61
|
+
// - id is negative
|
|
63
62
|
export function parseArgoRollbackID(id: string | undefined): number | null {
|
|
64
|
-
if (!id) return null
|
|
63
|
+
if (!id || !/^\d+$/.test(id)) return null
|
|
65
64
|
const n = Number(id)
|
|
66
|
-
|
|
67
|
-
return n
|
|
65
|
+
return Number.isSafeInteger(n) ? n : null
|
|
68
66
|
}
|
|
69
67
|
|
|
70
68
|
// describeGitOpsTerminating renders the two tooltip strings the detail
|
|
@@ -44,7 +44,7 @@ export {
|
|
|
44
44
|
gitOpsDetailGroup,
|
|
45
45
|
isDestinationRef,
|
|
46
46
|
} from './detail-helpers'
|
|
47
|
-
export { SyncOptionsDialog } from './SyncOptionsDialog'
|
|
47
|
+
export { SyncOptionsDialog, argoSyncOptionsForRequest } from './SyncOptionsDialog'
|
|
48
48
|
export type { SyncOptionsDialogProps, ArgoSyncOpts } from './SyncOptionsDialog'
|
|
49
49
|
export { RollbackDialog } from './RollbackDialog'
|
|
50
50
|
export type { RollbackDialogProps } from './RollbackDialog'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import type { GitOpsInsight } from '../../../types'
|
|
4
|
+
import type { RadarUpgradeRequirement } from '../../../types/fetch-error'
|
|
5
|
+
import { GitOpsActivityInsightView } from './GitOpsInsightViews'
|
|
6
|
+
|
|
7
|
+
function render(insight: GitOpsInsight, rollbackUpgrade?: RadarUpgradeRequirement | null): string {
|
|
8
|
+
return renderToString(<GitOpsActivityInsightView insight={insight} onRollback={() => {}} rollbackUpgrade={rollbackUpgrade} />).replace(/<!-- -->/g, '')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function rollbackButtons(insight: GitOpsInsight, rollbackUpgrade?: RadarUpgradeRequirement | null): { disabled: boolean }[] {
|
|
12
|
+
const html = render(insight, rollbackUpgrade)
|
|
13
|
+
return [...html.matchAll(/<button([^>]*)>Rollback<\/button>/g)].map((m) => ({ disabled: /\sdisabled=""/.test(m[1]) }))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function insight(autoSyncMode: string): GitOpsInsight {
|
|
17
|
+
return {
|
|
18
|
+
summary: { tool: 'argocd', kind: 'Application', namespace: 'argocd', name: 'guestbook', autoSyncMode },
|
|
19
|
+
capabilities: { sync: true, refresh: true, terminate: true, suspend: true, resume: true, syncWithSource: false, selectiveSync: true, rollback: true },
|
|
20
|
+
history: [
|
|
21
|
+
{ phase: 'Succeeded', revision: 'eee555' },
|
|
22
|
+
{ id: '2', revision: 'ccc333', rollbackable: true },
|
|
23
|
+
{ id: '1', revision: 'bbb222', rollbackable: false },
|
|
24
|
+
{ id: '0', revision: 'aaa111' },
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('GitOpsActivityInsightView rollback', () => {
|
|
30
|
+
// The rollback API refuses an entry with no source, so its button is
|
|
31
|
+
// disabled; the operation row has no id and gets no button.
|
|
32
|
+
it('disables Rollback only on entries the backend marks not rollbackable', () => {
|
|
33
|
+
expect(rollbackButtons(insight('Manual'))).toEqual([{ disabled: false }, { disabled: true }, { disabled: false }])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('disables every Rollback while auto-sync is on', () => {
|
|
37
|
+
expect(rollbackButtons(insight('Auto · prune'))).toEqual([{ disabled: true }, { disabled: true }, { disabled: true }])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// An older Radar writes a rollback the API server drops (and refuses id 0),
|
|
41
|
+
// so a newer UI disables every entry and says which Radar it needs.
|
|
42
|
+
describe('on a Radar without argoRollbackSync', () => {
|
|
43
|
+
const upgrade = { feature: 'Argo CD rollbacks', currentVersion: 'v1.16.3', latestVersion: 'v1.17.0' }
|
|
44
|
+
|
|
45
|
+
it('disables every Rollback and shows the upgrade note', () => {
|
|
46
|
+
expect(rollbackButtons(insight('Manual'), upgrade)).toEqual([{ disabled: true }, { disabled: true }, { disabled: true }])
|
|
47
|
+
// RadarUpgradeNote sets the feature in its own <span>.
|
|
48
|
+
expect(render(insight('Manual'), upgrade)).toContain('Argo CD rollbacks</span> need the latest Radar (v1.17) on this cluster. It runs v1.16.3.')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('shows no note when the Radar advertises the behavior', () => {
|
|
52
|
+
expect(render(insight('Manual'), null)).not.toContain('Argo CD rollbacks need')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -9,6 +9,8 @@ import { radarHealthNote } from '../health-provenance'
|
|
|
9
9
|
import { SEVERITY_BADGE, SEVERITY_TEXT } from '../../../utils/badge-colors'
|
|
10
10
|
import { formatRelativeAgeTime } from '../../../utils/format'
|
|
11
11
|
import { Tooltip } from '../../ui/Tooltip'
|
|
12
|
+
import { RadarUpgradeNote, radarUpgradeNoteText } from '../../ui/RadarUpgradeNote'
|
|
13
|
+
import type { RadarUpgradeRequirement } from '../../../types/fetch-error'
|
|
12
14
|
import { SearchBox } from '../../ui/SearchBox'
|
|
13
15
|
import { FilterPill, type FilterPillTone } from '../../ui/FilterPill'
|
|
14
16
|
import { Collapse, CollapseChevron } from '../../ui/Collapse'
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
entryTone,
|
|
23
25
|
gitopsToSeverity,
|
|
24
26
|
healthStatusRank,
|
|
27
|
+
historyRollbackBlockedReason,
|
|
25
28
|
isArgoResourceSyncEligible,
|
|
26
29
|
messageToPhase,
|
|
27
30
|
missingResourceMessage,
|
|
@@ -604,6 +607,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
|
|
|
604
607
|
const [expanded, setExpanded] = useState(false)
|
|
605
608
|
if (issues.length === 0) return null
|
|
606
609
|
const headline = issues[0]!
|
|
610
|
+
const wrapHeadline = headline.reason === 'RolledBack' || headline.reason === 'RollbackRevisionMismatch' || headline.reason === 'SyncWindowBlocked'
|
|
607
611
|
const remaining = issues.length - 1
|
|
608
612
|
const tone = severityTone(headline.severity)
|
|
609
613
|
const headlineRef = headline.refs?.[0]
|
|
@@ -631,7 +635,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
|
|
|
631
635
|
<div className="flex w-full items-center gap-2">
|
|
632
636
|
{tone.icon}
|
|
633
637
|
<span className={clsx('shrink-0 font-semibold', tone.text)}>{headline.reason}</span>
|
|
634
|
-
<span className=
|
|
638
|
+
<span className={clsx('min-w-0 flex-1 text-theme-text-secondary', wrapHeadline ? 'whitespace-normal break-words' : 'truncate')}>{headline.message}</span>
|
|
635
639
|
{/* Inline count when there are more issues behind the headline.
|
|
636
640
|
Lightweight text — pairs with the chevron as a single disclosure
|
|
637
641
|
unit instead of a separator-bordered count button. */}
|
|
@@ -902,6 +906,7 @@ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResour
|
|
|
902
906
|
// to a muted placeholder and explain the cause once. Health stays — it's a
|
|
903
907
|
// live signal that's still knowable and still worth reading.
|
|
904
908
|
const syncUnavailable = isArgoRoot && (insight.summary.sync ?? '').toLowerCase() === 'unknown'
|
|
909
|
+
const rolledBack = !!insight.summary.rollbackRevision
|
|
905
910
|
const toggleFacet = (facet: ResourceStatusFacet) => {
|
|
906
911
|
setStatusFilters((prev) => {
|
|
907
912
|
const next = new Set(prev)
|
|
@@ -1123,9 +1128,9 @@ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResour
|
|
|
1123
1128
|
const rowKey = refKey(change.ref)
|
|
1124
1129
|
const focused = focusKey === rowKey
|
|
1125
1130
|
const explanation = change.missing
|
|
1126
|
-
? missingResourceMessage(change.sync, { hazard: syncHazard })
|
|
1131
|
+
? missingResourceMessage(change.sync, { hazard: syncHazard, rolledBack })
|
|
1127
1132
|
: !change.syncError && !change.message
|
|
1128
|
-
? explainChangeStatus(change.sync, change.health, insight.summary, { hazard: syncHazard })
|
|
1133
|
+
? explainChangeStatus(change.sync, change.health, insight.summary, { hazard: syncHazard, rolledBack })
|
|
1129
1134
|
: ''
|
|
1130
1135
|
const hasInlineDetail = !!(
|
|
1131
1136
|
(change.drift && change.drift.entries.length > 0) ||
|
|
@@ -1317,6 +1322,7 @@ function explainChangeStatus(
|
|
|
1317
1322
|
if (sync === 'OutOfSync') {
|
|
1318
1323
|
if (parentFailed) return 'Sync failed for this resource — see the operation error above for the cause.'
|
|
1319
1324
|
if (inFlight) return 'Live state differs from Git. A sync is in progress — wait for it to finish.'
|
|
1325
|
+
if (context?.rolledBack) return 'Live state differs from Git after a rollback. Sync applies the desired state and can undo the rollback.'
|
|
1320
1326
|
if (isAuto) return 'Live state differs from Git. Auto-sync should reconcile this within a few minutes; click Sync to force it.'
|
|
1321
1327
|
return 'Live state differs from Git. Click Sync to apply the desired state.'
|
|
1322
1328
|
}
|
|
@@ -1636,12 +1642,17 @@ interface GitOpsActivityInsightViewProps {
|
|
|
1636
1642
|
// with the target entry. The consumer is responsible for the confirmation
|
|
1637
1643
|
// dialog + the actual mutation.
|
|
1638
1644
|
onRollback?: (item: GitOpsHistoryItem) => void
|
|
1645
|
+
// Set when the connected Radar is too old to roll back: every Rollback
|
|
1646
|
+
// renders disabled, with the upgrade note above the history.
|
|
1647
|
+
rollbackUpgrade?: RadarUpgradeRequirement | null
|
|
1648
|
+
rollbackBlockedReason?: string
|
|
1639
1649
|
}
|
|
1640
1650
|
|
|
1641
|
-
export function GitOpsActivityInsightView({ insight, error, onRollback }: GitOpsActivityInsightViewProps) {
|
|
1651
|
+
export function GitOpsActivityInsightView({ insight, error, onRollback, rollbackUpgrade, rollbackBlockedReason }: GitOpsActivityInsightViewProps) {
|
|
1642
1652
|
if (error && !insight) return <InsightErrorState error={error} />
|
|
1643
1653
|
if (!insight) return <PaneLoader label="Loading GitOps activity…" className="h-full" />
|
|
1644
1654
|
const canRollback = !!insight.capabilities?.rollback && !!onRollback
|
|
1655
|
+
const upgrade = canRollback ? rollbackUpgrade ?? null : null
|
|
1645
1656
|
// Auto-sync makes rollback futile — the controller would re-sync to HEAD
|
|
1646
1657
|
// immediately. Argo's own Web UI disables the button in this state. Detect
|
|
1647
1658
|
// by autoSyncMode prefix so "Auto · prune", "Auto · self-heal", etc. all match.
|
|
@@ -1655,12 +1666,18 @@ export function GitOpsActivityInsightView({ insight, error, onRollback }: GitOps
|
|
|
1655
1666
|
<SectionHeader
|
|
1656
1667
|
icon={Clock3}
|
|
1657
1668
|
title="History"
|
|
1658
|
-
hint={canRollback ? 'Each revision can be rolled back to.' : undefined}
|
|
1669
|
+
hint={canRollback && !upgrade && !rollbackBlockedReason ? 'Each revision can be rolled back to.' : undefined}
|
|
1659
1670
|
/>
|
|
1671
|
+
{upgrade && <RadarUpgradeNote requirement={upgrade} className="border-b border-theme-border px-4 py-2.5" />}
|
|
1672
|
+
{canRollback && rollbackBlockedReason && <p className="border-b border-theme-border px-4 py-2.5 text-xs text-theme-text-secondary">{rollbackBlockedReason}</p>}
|
|
1660
1673
|
<HistoryRows
|
|
1661
1674
|
items={insight.history ?? []}
|
|
1662
1675
|
canRollback={canRollback}
|
|
1663
|
-
rollbackBlockedReason={
|
|
1676
|
+
rollbackBlockedReason={rollbackBlockedReason || (upgrade
|
|
1677
|
+
? radarUpgradeNoteText(upgrade)
|
|
1678
|
+
: autoSyncBlocksRollback
|
|
1679
|
+
? 'Auto-sync is enabled. Disable it to enable rollback — otherwise the controller will sync forward to HEAD again.'
|
|
1680
|
+
: undefined)}
|
|
1664
1681
|
onRollback={onRollback}
|
|
1665
1682
|
/>
|
|
1666
1683
|
</section>
|
|
@@ -1700,6 +1717,7 @@ function HistoryRows({
|
|
|
1700
1717
|
// the in-flight current operation row has no ID and rolling "back"
|
|
1701
1718
|
// to it is meaningless.
|
|
1702
1719
|
const showRollback = canRollback && !!item.id && !!onRollback
|
|
1720
|
+
const blockedReason = historyRollbackBlockedReason(item, rollbackBlockedReason)
|
|
1703
1721
|
return (
|
|
1704
1722
|
// `group` enables the Rollback button's hover-reveal; baseline
|
|
1705
1723
|
// opacity-40 keeps it touch-discoverable.
|
|
@@ -1720,22 +1738,22 @@ function HistoryRows({
|
|
|
1720
1738
|
)}
|
|
1721
1739
|
{showRollback && (
|
|
1722
1740
|
<Tooltip
|
|
1723
|
-
content={
|
|
1741
|
+
content={blockedReason || `Roll back to revision ${item.revision || `#${item.id}`}`}
|
|
1724
1742
|
delay={200}
|
|
1725
1743
|
wrapperClassName="ml-auto"
|
|
1726
1744
|
>
|
|
1727
1745
|
{/* Two visually distinct states:
|
|
1728
1746
|
- Enabled: low-emphasis baseline (opacity-40), brightens on row hover
|
|
1729
|
-
- Disabled (auto-sync on): full opacity but desaturated, cursor-not-allowed,
|
|
1747
|
+
- Disabled (auto-sync on, or an entry with no source): full opacity but desaturated, cursor-not-allowed,
|
|
1730
1748
|
no hover affordance — reads unambiguously as "not actionable, here's why" */}
|
|
1731
1749
|
<button
|
|
1732
1750
|
type="button"
|
|
1733
|
-
onClick={() => !
|
|
1734
|
-
disabled={!!
|
|
1735
|
-
aria-disabled={!!
|
|
1751
|
+
onClick={() => !blockedReason && onRollback?.(item)}
|
|
1752
|
+
disabled={!!blockedReason}
|
|
1753
|
+
aria-disabled={!!blockedReason}
|
|
1736
1754
|
className={clsx(
|
|
1737
1755
|
'rounded border px-1.5 py-0.5 text-[10px] transition-opacity',
|
|
1738
|
-
|
|
1756
|
+
blockedReason
|
|
1739
1757
|
? 'cursor-not-allowed border-theme-border bg-theme-base text-theme-text-tertiary'
|
|
1740
1758
|
: 'border-theme-border bg-theme-elevated text-theme-text-secondary opacity-40 hover:bg-theme-hover hover:text-theme-text-primary hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100'
|
|
1741
1759
|
)}
|