@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.
Files changed (139) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  5. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  6. package/src/components/compare/ResourceCompareView.tsx +35 -12
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  10. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  11. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  12. package/src/components/gitops/RollbackDialog.tsx +25 -6
  13. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  14. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  15. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  16. package/src/components/gitops/applicationset-row.test.ts +12 -0
  17. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  18. package/src/components/gitops/detail-helpers.test.ts +8 -2
  19. package/src/components/gitops/detail-helpers.ts +9 -11
  20. package/src/components/gitops/index.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  22. package/src/components/gitops/insights/GitOpsInsightViews.tsx +30 -12
  23. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  24. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +37 -0
  26. package/src/components/gitops/insights/insights-helpers.ts +12 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  28. package/src/components/issues/diagnostic.ts +14 -1
  29. package/src/components/issues/severity.ts +14 -0
  30. package/src/components/kueue/AdmissionPath.tsx +223 -0
  31. package/src/components/kueue/QueueEvidence.tsx +148 -0
  32. package/src/components/kueue/constants.ts +1 -0
  33. package/src/components/kueue/eligibility.ts +43 -0
  34. package/src/components/kueue/flavors-tried.ts +79 -0
  35. package/src/components/kueue/helpers.test.ts +105 -0
  36. package/src/components/kueue/policy.ts +100 -0
  37. package/src/components/kueue/queue-detail.test.tsx +317 -0
  38. package/src/components/kueue/queue-status.ts +42 -0
  39. package/src/components/kueue/related-read.ts +48 -0
  40. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  41. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  42. package/src/components/logs/StructuredLogLine.tsx +8 -3
  43. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  44. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  45. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  46. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  47. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  48. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  49. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  50. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  51. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  52. package/src/components/resources/ResourcesView.tsx +50 -3
  53. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  54. package/src/components/resources/generic-status.ts +7 -0
  55. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  56. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  57. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  58. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  59. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  60. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  61. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  62. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  63. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  64. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  65. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  66. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  67. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  70. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  71. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  72. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  73. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  74. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  75. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  76. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  77. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  78. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  79. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  80. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  81. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  82. package/src/components/resources/renderers/index.ts +6 -0
  83. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  84. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  85. package/src/components/resources/resource-utils-argo.ts +84 -5
  86. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  87. package/src/components/resources/resource-utils-crd.ts +26 -0
  88. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  89. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  90. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  91. package/src/components/resources/resource-utils-kueue.ts +137 -4
  92. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  93. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  94. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  95. package/src/components/resources/resource-utils-ray.ts +38 -0
  96. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  97. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  98. package/src/components/resources/resource-utils.ts +45 -1
  99. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  100. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  101. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  102. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  103. package/src/components/topology/K8sResourceNode.tsx +18 -1
  104. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  105. package/src/components/topology/TopologyGraph.tsx +96 -94
  106. package/src/components/topology/layout.ts +3 -1
  107. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  108. package/src/components/ui/Badge.tsx +1 -0
  109. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  110. package/src/components/ui/drawer-components.tsx +1 -1
  111. package/src/components/ui/index.ts +1 -0
  112. package/src/components/workload/PodList.tsx +35 -9
  113. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  114. package/src/components/workload/WorkloadView.tsx +61 -8
  115. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  116. package/src/types/core.ts +40 -0
  117. package/src/types/gitops-insights.ts +11 -2
  118. package/src/types/gitops-tree.ts +2 -2
  119. package/src/types/kueue-queues.ts +84 -0
  120. package/src/types/read-coverage.ts +2 -1
  121. package/src/types/scheduling.ts +72 -0
  122. package/src/utils/animation.test.ts +38 -0
  123. package/src/utils/animation.ts +34 -0
  124. package/src/utils/api-resources-crd.test.ts +11 -0
  125. package/src/utils/api-resources.ts +5 -1
  126. package/src/utils/applications.test.ts +15 -1
  127. package/src/utils/applications.ts +26 -1
  128. package/src/utils/crd-schema.test.ts +107 -0
  129. package/src/utils/crd-schema.ts +201 -0
  130. package/src/utils/format.test.ts +37 -1
  131. package/src/utils/format.ts +28 -80
  132. package/src/utils/gitops-owner.test.ts +28 -2
  133. package/src/utils/gitops-owner.ts +19 -0
  134. package/src/utils/kueue-queue-workloads.ts +19 -0
  135. package/src/utils/resource-icons.ts +2 -0
  136. package/src/utils/topology-neighborhood.test.ts +90 -1
  137. package/src/utils/topology-neighborhood.ts +55 -3
  138. package/src/utils/workload-history-notes.test.ts +76 -0
  139. 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, '&#x27;'))
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
- test('"0" returns null (non-positive, Argo history starts at 1)', () => {
24
- expect(parseArgoRollbackID('0')).toBeNull()
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. Returns null when:
56
- // - id is missing
57
- // - id is non-numeric (Flux condition rows reuse the same slot for
58
- // condition.type, which we never want to interpret as a rollback id)
59
- // - id is non-positive ("0" and negative strings parse fine via
60
- // Number() but aren't valid Argo history ids — Argo's id sequence
61
- // starts at 1; rolling back to id 0 would unhelpfully target the
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
- if (!Number.isFinite(n) || n <= 0) return null
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="min-w-0 flex-1 truncate text-theme-text-secondary">{headline.message}</span>
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={autoSyncBlocksRollback ? 'Auto-sync is enabled. Disable it to enable rollback — otherwise the controller will sync forward to HEAD again.' : undefined}
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={rollbackBlockedReason || `Roll back to revision ${item.revision || `#${item.id}`}`}
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={() => !rollbackBlockedReason && onRollback?.(item)}
1734
- disabled={!!rollbackBlockedReason}
1735
- aria-disabled={!!rollbackBlockedReason}
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
- rollbackBlockedReason
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
  )}