@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,80 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import { RollbackDialog } from './RollbackDialog'
6
+
7
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
8
+
9
+ let root: Root | undefined
10
+
11
+ afterEach(async () => {
12
+ if (root) await act(async () => root?.unmount())
13
+ root = undefined
14
+ document.body.replaceChildren()
15
+ })
16
+
17
+ describe('RollbackDialog confirmation', () => {
18
+ it('explains rollback risk and keeps dry-run copy distinct', async () => {
19
+ const host = document.createElement('div')
20
+ document.body.appendChild(host)
21
+ root = createRoot(host)
22
+ const onConfirm = vi.fn()
23
+ await act(async () => root?.render(
24
+ <RollbackDialog open appLabel="argocd/demo" revision="abc123" historyId="0" onCancel={() => {}} onConfirm={onConfirm} />,
25
+ ))
26
+ const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
27
+ expect(dialog.textContent).toContain('Auto-sync is off. Later changes can still cause drift. Syncing or enabling auto-sync deploys the current revision and can undo the rollback.')
28
+ expect(dialog.textContent).toContain("Delete tracked resources that aren't in the rollback target")
29
+ expect(dialog.textContent).toContain("Off by default. The target's resources are still applied; other tracked resources are kept.")
30
+ expect(dialog.textContent).toContain("Uses the Application's current sync options. Sync hooks don't run.")
31
+ const dryRun = dialog.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')[1]
32
+ await act(async () => dryRun.click())
33
+ expect(dialog.textContent).toContain('Argo will preview syncing this application to a previous revision without applying changes.')
34
+ expect(dialog.textContent).not.toContain('Later changes can still cause drift.')
35
+ const confirm = [...dialog.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Run dry-run')!
36
+ await act(async () => confirm.click())
37
+ expect(onConfirm).toHaveBeenCalledWith({ prune: false, dryRun: true })
38
+ })
39
+
40
+ it.each(['https://example.com/repo/very/long/path/to/guestbook', 'guestbook (+2 more)'])('shows the selected source with wrapping (%s)', async (source) => {
41
+ const host = document.createElement('div')
42
+ document.body.appendChild(host)
43
+ root = createRoot(host)
44
+ await act(async () => root?.render(
45
+ <RollbackDialog open appLabel="argocd/demo" revision="abc123" historyId="0" source={source} onCancel={() => {}} onConfirm={() => {}} />,
46
+ ))
47
+ const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
48
+ const label = [...dialog.querySelectorAll('dt')].find(dt => dt.textContent === 'Source')
49
+ expect(label).toBeDefined()
50
+ expect(label?.nextElementSibling?.textContent).toBe(source)
51
+ expect(label?.nextElementSibling?.classList.contains('break-words')).toBe(true)
52
+ })
53
+
54
+ it.each([true, false])('shows controller compatibility when either the current Application or entry uses multiple sources (%s)', async (multiSourceWarning) => {
55
+ const host = document.createElement('div')
56
+ document.body.appendChild(host)
57
+ root = createRoot(host)
58
+ await act(async () => root?.render(
59
+ <RollbackDialog open appLabel="argocd/demo" revision="aaa111, bbb222" multiSourceWarning={multiSourceWarning} onCancel={() => {}} onConfirm={() => {}} />,
60
+ ))
61
+ const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
62
+ const note = 'On Argo CD before 2.12, rolling back across a single-source/multi-source change or to a multi-source entry can deploy the wrong revisions or render nothing; with prune, it can delete resources.'
63
+ expect(dialog.textContent?.includes(note)).toBe(multiSourceWarning)
64
+ })
65
+
66
+ })
67
+
68
+ it('disables confirmation while support is being checked', async () => {
69
+ const host = document.createElement('div')
70
+ document.body.appendChild(host)
71
+ root = createRoot(host)
72
+ const confirm = vi.fn()
73
+ await act(async () => root?.render(<RollbackDialog open appLabel="argocd/demo" revision="old" blockedReason="Checking this cluster's Radar…" onCancel={() => {}} onConfirm={confirm} />))
74
+ const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
75
+ expect(dialog.textContent).toContain("Checking this cluster's Radar…")
76
+ const button = [...dialog.querySelectorAll<HTMLButtonElement>('button')].find(b => b.textContent === 'Roll back')!
77
+ expect(button.disabled).toBe(true)
78
+ await act(async () => button.click())
79
+ expect(confirm).not.toHaveBeenCalled()
80
+ })
@@ -24,12 +24,16 @@ export interface RollbackDialogProps {
24
24
  appLabel: string
25
25
  revision: string
26
26
  historyId?: string
27
+ source?: string
28
+ // Either the current Application or the selected entry uses plural sources.
29
+ multiSourceWarning?: boolean
27
30
  pending?: boolean
31
+ blockedReason?: string
28
32
  onCancel: () => void
29
33
  onConfirm: (opts: { prune: boolean; dryRun: boolean }) => void
30
34
  }
31
35
 
32
- export function RollbackDialog({ open, appLabel, revision, historyId, pending, onCancel, onConfirm }: RollbackDialogProps) {
36
+ export function RollbackDialog({ open, appLabel, revision, historyId, source, multiSourceWarning, pending, blockedReason, onCancel, onConfirm }: RollbackDialogProps) {
33
37
  const titleId = useId()
34
38
  const [prune, setPrune] = useState(false)
35
39
  const [dryRun, setDryRun] = useState(false)
@@ -49,8 +53,16 @@ export function RollbackDialog({ open, appLabel, revision, historyId, pending, o
49
53
  </div>
50
54
  <div className="space-y-4 px-4 py-4 text-sm">
51
55
  <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
52
- Argo will sync this application to a previous revision. This is a write operation on the cluster.
56
+ {dryRun
57
+ ? 'Argo will preview syncing this application to a previous revision without applying changes.'
58
+ : 'Argo will sync this application to a previous revision. Auto-sync is off. Later changes can still cause drift. Syncing or enabling auto-sync deploys the current revision and can undo the rollback.'}
53
59
  </div>
60
+ {multiSourceWarning && (
61
+ <p className="text-xs text-warning-text">
62
+ On Argo CD before 2.12, rolling back across a single-source/multi-source change or to a multi-source entry can deploy the wrong revisions or render nothing; with prune, it can delete resources.
63
+ </p>
64
+ )}
65
+ {blockedReason && <p className="text-xs text-theme-text-secondary">{blockedReason}</p>}
54
66
  <dl className="grid grid-cols-[80px_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
55
67
  <dt className="text-theme-text-tertiary">Revision</dt>
56
68
  <dd className="min-w-0">
@@ -58,6 +70,12 @@ export function RollbackDialog({ open, appLabel, revision, historyId, pending, o
58
70
  <span className="block truncate font-mono text-theme-text-primary">{revision || '-'}</span>
59
71
  </Tooltip>
60
72
  </dd>
73
+ {source && (
74
+ <>
75
+ <dt className="text-theme-text-tertiary">Source</dt>
76
+ <dd className="min-w-0 break-words text-theme-text-primary">{source}</dd>
77
+ </>
78
+ )}
61
79
  {historyId && (
62
80
  <>
63
81
  <dt className="text-theme-text-tertiary">History ID</dt>
@@ -66,6 +84,7 @@ export function RollbackDialog({ open, appLabel, revision, historyId, pending, o
66
84
  )}
67
85
  </dl>
68
86
  <div className="space-y-2">
87
+ <p className="text-xs text-theme-text-tertiary">Uses the Application's current sync options. Sync hooks don't run.</p>
69
88
  <label className="flex items-start gap-2">
70
89
  <input
71
90
  type="checkbox"
@@ -75,8 +94,8 @@ export function RollbackDialog({ open, appLabel, revision, historyId, pending, o
75
94
  className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-sky-500 disabled:cursor-not-allowed"
76
95
  />
77
96
  <div className="min-w-0">
78
- <div className="text-xs text-theme-text-primary">Prune resources added since this revision</div>
79
- <div className="text-[11px] text-theme-text-tertiary">Off by default — leaves any resources created after this revision untouched.</div>
97
+ <div className="text-xs text-theme-text-primary">Delete tracked resources that aren't in the rollback target</div>
98
+ <div className="text-[11px] text-theme-text-tertiary">Off by default. The target's resources are still applied; other tracked resources are kept.</div>
80
99
  </div>
81
100
  </label>
82
101
  <label className="flex items-start gap-2">
@@ -105,8 +124,8 @@ export function RollbackDialog({ open, appLabel, revision, historyId, pending, o
105
124
  </button>
106
125
  <button
107
126
  type="button"
108
- onClick={() => onConfirm({ prune, dryRun })}
109
- disabled={pending}
127
+ onClick={() => !blockedReason && onConfirm({ prune, dryRun })}
128
+ disabled={pending || !!blockedReason}
110
129
  className="inline-flex items-center gap-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-amber-400"
111
130
  >
112
131
  {pending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <History className="h-3.5 w-3.5" />}
@@ -0,0 +1,374 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { SyncOptionsDialog, type ArgoSyncOpts } from './SyncOptionsDialog'
6
+
7
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
8
+
9
+ const APP_OPTIONS = ['CreateNamespace=true', 'ServerSideApply=true']
10
+ const STALE_OPTIONS_NOTICE = "The Application's sync options changed while this dialog was open. This sync uses the options shown here. Cancel and reopen to load the latest."
11
+ let host: HTMLDivElement
12
+ let root: ReturnType<typeof createRoot>
13
+ let confirmed: ArgoSyncOpts[]
14
+ let validated: ArgoSyncOpts[]
15
+
16
+ beforeEach(() => {
17
+ confirmed = []
18
+ validated = []
19
+ host = document.createElement('div')
20
+ document.body.append(host)
21
+ root = createRoot(host)
22
+ })
23
+
24
+ afterEach(async () => {
25
+ await act(async () => root.unmount())
26
+ host.remove()
27
+ document.body.innerHTML = ''
28
+ })
29
+
30
+ async function render(props: Partial<Parameters<typeof SyncOptionsDialog>[0]> = {}) {
31
+ await act(async () => root.render(
32
+ <SyncOptionsDialog
33
+ open
34
+ appLabel="argocd/guestbook"
35
+ applicationSyncOptions={APP_OPTIONS}
36
+ onCancel={() => {}}
37
+ onConfirm={(opts) => confirmed.push(opts)}
38
+ {...props}
39
+ />,
40
+ ))
41
+ }
42
+
43
+ // A sync using Replace or Force is labelled "Sync with replace" etc.; tests
44
+ // about other behavior find it by its plain label.
45
+ const button = (label: string) => [...document.querySelectorAll('button')].find((b) => {
46
+ const text = b.textContent?.trim() ?? ''
47
+ return text === label || text.startsWith(`${label === 'Sync now' ? 'Sync' : label} with `)
48
+ })!
49
+ const toggle = (label: string) => [...document.querySelectorAll('label')].find((l) => l.textContent?.startsWith(label))!.querySelector('input')!
50
+ const click = async (el: HTMLElement) => { await act(async () => el.click()) }
51
+
52
+ it('starts with prune off and submits nothing for the options, so the Application keeps its own', async () => {
53
+ await render()
54
+ expect(toggle('Prune').checked).toBe(false)
55
+ expect(toggle('Server-side apply').checked).toBe(true)
56
+ expect(toggle('Server-side apply').disabled).toBe(false)
57
+ expect(toggle('Replace').checked).toBe(false)
58
+ expect(toggle('Replace').disabled).toBe(false)
59
+ expect(document.body.textContent).not.toContain(STALE_OPTIONS_NOTICE)
60
+ await click(button('Sync now'))
61
+ expect(confirmed).toEqual([{ revision: undefined, prune: false, dryRun: false, force: false, applyOnly: false }])
62
+ })
63
+
64
+ it('hides the revision for multi-source Applications and explains comparison pinning', async () => {
65
+ await render({ multiSource: true, pinsComparedRevision: true })
66
+ expect(document.body.textContent).not.toContain('Revision (optional)')
67
+ expect(document.body.textContent).toContain('Syncs each source at the revision Argo last compared.')
68
+ await click(button('Sync now'))
69
+ expect(confirmed[0].revision).toBeUndefined()
70
+ })
71
+
72
+ // A Radar without argoSyncInheritance resolves the target when the sync runs,
73
+ // so the dialog must not promise the compared revision.
74
+ it('promises no compared revision when the serving Radar does not pin it', async () => {
75
+ await render({ comparedRevision: 'abcdef0123456789abcdef0123456789abcdef01' })
76
+ expect(document.body.textContent).toContain('Leave empty for the target revision, resolved when the sync runs.')
77
+ expect(document.body.textContent).not.toContain('last compared')
78
+ await render({ multiSource: true })
79
+ expect(document.body.textContent).toContain("Syncs each source's target revision, resolved when the sync runs.")
80
+ await render({ resource: { kind: 'Deployment', group: 'apps', namespace: 'guestbook', name: 'web' } })
81
+ expect(document.body.textContent).toContain('Uses the target revision, resolved when the sync runs.')
82
+ expect(document.body.textContent).not.toContain('last compared')
83
+ })
84
+
85
+ it('omits an entered revision if the Application becomes multi-source while open', async () => {
86
+ await render()
87
+ const revision = document.querySelector('input:not([type="checkbox"])') as HTMLInputElement
88
+ await act(async () => {
89
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(revision, 'release-1')
90
+ revision.dispatchEvent(new Event('input', { bubbles: true }))
91
+ })
92
+ await render({ multiSource: true })
93
+ await click(button('Sync now'))
94
+ expect(confirmed[0].revision).toBeUndefined()
95
+ })
96
+
97
+ it('explains selective sync hooks, dependencies and namespace management without denying wave ordering', async () => {
98
+ await render({ resource: { kind: 'Deployment', group: 'apps', namespace: 'guestbook', name: 'web' }, pinsComparedRevision: true })
99
+ expect(document.body.textContent).toContain("Syncs only this resource, without hooks. Resources it depends on aren't synced; namespace creation still applies. Uses the revision Argo last compared.")
100
+ expect(document.body.textContent).not.toContain('Only this resource will be applied')
101
+ expect(document.body.textContent).not.toContain('bypasses hooks and normal sync-wave ordering')
102
+ })
103
+
104
+ it('disables unknown sync options and omits them when confirming', async () => {
105
+ await render({ applicationSyncOptions: undefined })
106
+ expect(toggle('Replace').disabled).toBe(true)
107
+ expect(toggle('Server-side apply').disabled).toBe(true)
108
+ expect(document.body.textContent).toContain("This view can't show or change the Application's sync options, so Replace and Server-side apply are unavailable here.")
109
+ await click(toggle('Replace'))
110
+ await click(toggle('Server-side apply'))
111
+ await click(toggle('Prune'))
112
+ await click(button('Sync now'))
113
+ expect(confirmed).toEqual([{ revision: undefined, prune: true, dryRun: false, force: false, applyOnly: false }])
114
+ })
115
+
116
+ it('omits unknown sync options for selective sync and its dry-run, even after earlier edits', async () => {
117
+ const props = {
118
+ resource: { kind: 'Deployment', group: 'apps', namespace: 'guestbook', name: 'web' },
119
+ onValidate: (opts: ArgoSyncOpts) => validated.push(opts),
120
+ }
121
+ await render(props)
122
+ await click(toggle('Replace'))
123
+ await render({ ...props, applicationSyncOptions: undefined })
124
+ expect(toggle('Replace').disabled).toBe(true)
125
+ expect(toggle('Server-side apply').disabled).toBe(true)
126
+ await click(button('Dry-run'))
127
+ await click(button('Sync resource'))
128
+ expect(validated).toEqual([{ revision: undefined, prune: false, dryRun: true, force: false, applyOnly: false }])
129
+ expect(confirmed).toEqual([{ revision: undefined, prune: false, dryRun: false, force: false, applyOnly: false }])
130
+ })
131
+
132
+ it('keeps toggles editable when the Application has an explicitly empty option list', async () => {
133
+ await render({ applicationSyncOptions: [] })
134
+ expect(toggle('Replace').disabled).toBe(false)
135
+ expect(toggle('Replace').checked).toBe(false)
136
+ expect(toggle('Server-side apply').disabled).toBe(false)
137
+ expect(toggle('Server-side apply').checked).toBe(false)
138
+ expect(document.body.textContent).not.toContain("This view can't show or change")
139
+ await click(button('Sync now'))
140
+ expect(confirmed[0]).not.toHaveProperty('syncOptions')
141
+ await click(toggle('Replace'))
142
+ await click(toggle('Server-side apply'))
143
+ await click(button('Sync now'))
144
+ expect(confirmed[1].syncOptions).toEqual(['Replace=true', 'ServerSideApply=true'])
145
+ await click(toggle('Replace'))
146
+ await click(toggle('Server-side apply'))
147
+ await click(button('Sync now'))
148
+ expect(confirmed[2]).not.toHaveProperty('syncOptions')
149
+ })
150
+
151
+ it("sends the Application's options with one edit when a toggle changes", async () => {
152
+ await render()
153
+ await click(toggle('Server-side apply'))
154
+ await click(toggle('Prune'))
155
+ await click(button('Sync now'))
156
+ expect(confirmed[0]).toMatchObject({ prune: true, syncOptions: ['CreateNamespace=true'] })
157
+ })
158
+
159
+ it('lists all other options read-only and preserves them when either toggle changes', async () => {
160
+ const otherOptions = ['CreateNamespace=true', 'FailOnSharedResource=true', 'PrunePropagationPolicy=orphan', 'Validate=false', 'Custom=opaque']
161
+ await render({ applicationSyncOptions: [...otherOptions, 'Replace=false', 'ServerSideApply=true'] })
162
+ expect(document.body.textContent).toContain('Also used for this sync')
163
+ expect([...document.querySelectorAll('li')].map((li) => li.textContent)).toEqual(otherOptions)
164
+ expect(document.body.textContent).not.toContain("Keep the Application's other sync options")
165
+ expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(6)
166
+ await click(button('Sync now'))
167
+ expect(confirmed[0]).not.toHaveProperty('syncOptions')
168
+ await click(toggle('Replace'))
169
+ await click(button('Sync now'))
170
+ expect(confirmed[1].syncOptions).toEqual([...otherOptions, 'Replace=true', 'ServerSideApply=true'])
171
+ await click(toggle('Server-side apply'))
172
+ await click(button('Sync now'))
173
+ expect(confirmed[2].syncOptions).toEqual([...otherOptions, 'Replace=true'])
174
+ })
175
+
176
+ it('builds the selective sync and its dry-run from the same options', async () => {
177
+ await render({
178
+ resource: { kind: 'Deployment', group: 'apps', namespace: 'guestbook', name: 'web' },
179
+ onValidate: (opts) => validated.push(opts),
180
+ })
181
+ await click(button('Dry-run'))
182
+ await click(button('Sync resource'))
183
+ expect(validated).toEqual([{ revision: undefined, prune: false, dryRun: true, force: false, applyOnly: false }])
184
+ expect(confirmed).toEqual([{ revision: undefined, prune: false, dryRun: false, force: false, applyOnly: false }])
185
+
186
+ await click(toggle('Replace'))
187
+ await click(button('Dry-run'))
188
+ await click(button('Sync resource'))
189
+ expect(validated[1].syncOptions).toEqual(['CreateNamespace=true', 'Replace=true', 'ServerSideApply=true'])
190
+ expect(confirmed[1].syncOptions).toEqual(['CreateNamespace=true', 'Replace=true', 'ServerSideApply=true'])
191
+ })
192
+
193
+ it('reopening resets edits to the Application’s options', async () => {
194
+ await render()
195
+ await click(toggle('Server-side apply'))
196
+ await render({ open: false })
197
+ await render()
198
+ expect(toggle('Server-side apply').checked).toBe(true)
199
+ await click(button('Sync now'))
200
+ expect(confirmed[0]).not.toHaveProperty('syncOptions')
201
+ })
202
+
203
+ it("keeps the user's dry-run and revision when the Application's options change while open", async () => {
204
+ const onValidationReset = vi.fn()
205
+ await render({ onValidationReset })
206
+ await click(toggle('Dry run'))
207
+ const revision = document.querySelector('input:not([type="checkbox"])') as HTMLInputElement
208
+ await act(async () => {
209
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(revision, 'release-1')
210
+ revision.dispatchEvent(new Event('input', { bubbles: true }))
211
+ })
212
+ await render({ onValidationReset, applicationSyncOptions: ['CreateNamespace=true'] })
213
+ expect(toggle('Dry run').checked).toBe(true)
214
+ expect(toggle('Server-side apply').checked).toBe(false)
215
+ expect(onValidationReset).toHaveBeenCalledTimes(1)
216
+ expect(document.body.textContent).not.toContain(STALE_OPTIONS_NOTICE)
217
+ expect(document.body.textContent).toContain('Also used for this sync')
218
+ await click(button('Run dry-run'))
219
+ expect(confirmed[0]).toEqual({ revision: 'release-1', prune: false, dryRun: true, force: false, applyOnly: false })
220
+ })
221
+
222
+ it("keeps the user's option edits when the Application's options change while open", async () => {
223
+ await render()
224
+ await click(toggle('Replace'))
225
+ expect(document.body.textContent).not.toContain(STALE_OPTIONS_NOTICE)
226
+ await render({ applicationSyncOptions: [...APP_OPTIONS] })
227
+ expect(document.body.textContent).not.toContain(STALE_OPTIONS_NOTICE)
228
+ await render({ applicationSyncOptions: ['PruneLast=true'] })
229
+ expect(document.body.textContent).toContain(STALE_OPTIONS_NOTICE)
230
+ expect([...document.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['CreateNamespace=true'])
231
+ expect(document.body.textContent).not.toContain("Keep the Application's other sync options")
232
+ expect(toggle('Replace').checked).toBe(true)
233
+ expect(toggle('Server-side apply').checked).toBe(true)
234
+ await click(button('Sync now'))
235
+ expect(confirmed[0].syncOptions).toEqual(['CreateNamespace=true', 'Replace=true', 'ServerSideApply=true'])
236
+ })
237
+
238
+ it('sends the options it shows once they are no longer the Application’s current ones', async () => {
239
+ const onValidationReset = vi.fn()
240
+ await render({ onValidationReset })
241
+ await click(toggle('Replace'))
242
+ await click(toggle('Replace'))
243
+ await render({ onValidationReset, applicationSyncOptions: [] })
244
+ expect(toggle('Server-side apply').checked).toBe(true)
245
+ await click(button('Sync now'))
246
+ expect(confirmed[0].syncOptions).toEqual(APP_OPTIONS)
247
+ })
248
+
249
+ it('clears the stale-options notice when live options match again or the dialog reopens', async () => {
250
+ await render()
251
+ await click(toggle('Replace'))
252
+ await render({ applicationSyncOptions: [] })
253
+ expect(document.body.textContent).toContain(STALE_OPTIONS_NOTICE)
254
+ await render()
255
+ expect(document.body.textContent).not.toContain(STALE_OPTIONS_NOTICE)
256
+ expect(document.body.textContent).toContain('Also used for this sync')
257
+ await render({ applicationSyncOptions: ['PruneLast=true'] })
258
+ expect(document.body.textContent).toContain(STALE_OPTIONS_NOTICE)
259
+ await render({ open: false, applicationSyncOptions: ['PruneLast=true'] })
260
+ await render({ applicationSyncOptions: ['PruneLast=true'] })
261
+ expect(document.body.textContent).not.toContain(STALE_OPTIONS_NOTICE)
262
+ expect(toggle('Replace').checked).toBe(false)
263
+ expect(document.body.textContent).toContain('Also used for this sync')
264
+ await click(button('Sync now'))
265
+ expect(confirmed[0]).not.toHaveProperty('syncOptions')
266
+ })
267
+
268
+ it('shows the stale-options notice even when the snapshot has no other options', async () => {
269
+ await render({ applicationSyncOptions: [] })
270
+ await click(toggle('Replace'))
271
+ await render({ applicationSyncOptions: ['PruneLast=true'] })
272
+ expect(document.body.textContent).toContain(STALE_OPTIONS_NOTICE)
273
+ expect(document.body.textContent).not.toContain('Also used for this sync')
274
+ await click(button('Sync now'))
275
+ expect(confirmed[0].syncOptions).toEqual(['Replace=true'])
276
+ })
277
+
278
+ it('allows revision previews with auto-sync enabled and omits the override for a real sync', async () => {
279
+ await render({ autoSyncEnabled: true })
280
+ const revision = document.querySelector('input:not([type="checkbox"])') as HTMLInputElement
281
+ expect(revision.disabled).toBe(true)
282
+ expect(document.body.textContent).toContain('Turn off auto-sync to deploy another revision; a dry run can preview one.')
283
+ await click(toggle('Dry run'))
284
+ expect(revision.disabled).toBe(false)
285
+ await act(async () => {
286
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(revision, 'old-sha')
287
+ revision.dispatchEvent(new Event('input', { bubbles: true }))
288
+ })
289
+ await click(button('Run dry-run'))
290
+ expect(confirmed[0]).toMatchObject({ revision: 'old-sha', dryRun: true })
291
+ await click(toggle('Dry run'))
292
+ expect(revision.disabled).toBe(true)
293
+ // The override no longer applies, so it isn't left on screen while the sync deploys something else.
294
+ expect(revision.value).toBe('')
295
+ await click(button('Sync now'))
296
+ expect(confirmed[1].revision).toBeUndefined()
297
+ })
298
+
299
+ it('omits a previously entered revision when auto-sync becomes enabled while open', async () => {
300
+ await render()
301
+ const revision = document.querySelector('input:not([type="checkbox"])') as HTMLInputElement
302
+ await act(async () => {
303
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(revision, 'old-sha')
304
+ revision.dispatchEvent(new Event('input', { bubbles: true }))
305
+ })
306
+ await render({ autoSyncEnabled: true })
307
+ expect(revision.disabled).toBe(true)
308
+ expect(revision.value).toBe('')
309
+ await click(button('Sync now'))
310
+ expect(confirmed[0].revision).toBeUndefined()
311
+ })
312
+
313
+ it.each([
314
+ { comparedRevision: 'abcdef0123456789abcdef0123456789abcdef01', label: ' (abcdef0)' },
315
+ { comparedRevision: 'chart-version-1.2.3', label: ' (chart-version-1.2.3)' },
316
+ { comparedRevision: undefined, label: '' },
317
+ ])('explains empty revision pinning with comparison $comparedRevision', async ({ comparedRevision, label }) => {
318
+ await render({ comparedRevision, pinsComparedRevision: true })
319
+ expect(document.body.textContent).toContain(`Leave empty for the revision Argo last compared${label}.`)
320
+ await click(button('Sync now'))
321
+ expect(confirmed[0].revision).toBeUndefined()
322
+ })
323
+
324
+ // The backend pins the validation dry-run like the sync, so one target is named.
325
+ it('names one compared target for selective validation and sync', async () => {
326
+ await render({
327
+ resource: { kind: 'Deployment', group: 'apps', namespace: 'guestbook', name: 'web' },
328
+ comparedRevision: 'abcdef0123456789abcdef0123456789abcdef01',
329
+ pinsComparedRevision: true,
330
+ onValidate: (opts) => validated.push(opts),
331
+ })
332
+ expect(document.body.textContent).toContain('Uses the revision Argo last compared (abcdef0).')
333
+ expect(document.body.textContent).not.toContain('latest revision')
334
+ await click(button('Dry-run'))
335
+ await click(button('Sync resource'))
336
+ expect(validated[0].revision).toBeUndefined()
337
+ expect(confirmed[0].revision).toBeUndefined()
338
+ })
339
+
340
+ // Argo CD's sync panel confirms Replace and Force. Here a real sync using
341
+ // either names it on the button, with one line saying what it can do.
342
+ describe('disruptive sync modes', () => {
343
+ const primary = () => document.querySelector('button.bg-amber-600, button.btn-brand:last-child')?.textContent?.trim()
344
+
345
+ it('says nothing extra for an ordinary sync', async () => {
346
+ await render()
347
+ expect(document.body.textContent).not.toContain('can cause downtime')
348
+ expect(button('Sync now').textContent?.trim()).toBe('Sync now')
349
+ })
350
+
351
+ it('names Replace inherited from the Application before the sync', async () => {
352
+ await render({ applicationSyncOptions: ['Replace=true'] })
353
+ expect(document.body.textContent).toContain('Replace deletes and recreates resources instead of updating them, which can cause downtime.')
354
+ expect(primary()).toBe('Sync with replace')
355
+ })
356
+
357
+ it('names Force and both modes together, and drops the warning for a dry run', async () => {
358
+ await render()
359
+ await click(toggle('Force'))
360
+ expect(document.body.textContent).toContain('Force removes resources immediately, skipping graceful deletion, which can lose data.')
361
+ expect(primary()).toBe('Sync with force')
362
+ await click(toggle('Replace'))
363
+ expect(primary()).toBe('Sync with replace and force')
364
+ await click(toggle('Dry run'))
365
+ expect(document.body.textContent).not.toContain('can lose data')
366
+ expect(primary()).toBe('Run dry-run')
367
+ })
368
+
369
+ it('labels a selective sync the same way', async () => {
370
+ await render({ resource: { kind: 'Deployment', group: 'apps', namespace: 'guestbook', name: 'web' } })
371
+ await click(toggle('Force'))
372
+ expect(primary()).toBe('Sync resource with force')
373
+ })
374
+ })
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { buildArgoSyncOpts } from './SyncOptionsDialog'
2
+ import { argoSyncOptionsForRequest, buildArgoSyncOpts } from './SyncOptionsDialog'
3
+
4
+ const base = { revision: '', prune: false, dryRun: false, force: false, applyOnly: false, replace: false, serverSideApply: false }
3
5
 
4
6
  describe('buildArgoSyncOpts', () => {
5
7
  it('keeps full application sync controls', () => {
@@ -45,4 +47,55 @@ describe('buildArgoSyncOpts', () => {
45
47
  syncOptions: ['ServerSideApply=true'],
46
48
  })
47
49
  })
50
+
51
+ it('sends no options when the toggles match the Application, so its own apply', () => {
52
+ const opts = buildArgoSyncOpts({ ...base, resourceMode: false, serverSideApply: true, applicationSyncOptions: ['CreateNamespace=true', 'ServerSideApply=true'] })
53
+ expect(opts).not.toHaveProperty('syncOptions')
54
+ expect(buildArgoSyncOpts({ ...base, resourceMode: false })).not.toHaveProperty('syncOptions')
55
+ })
56
+
57
+ it("applies a toggle as an edit on top of the Application's options", () => {
58
+ const applicationSyncOptions = ['CreateNamespace=true', 'ServerSideApply=true', 'PruneLast=true']
59
+ expect(buildArgoSyncOpts({ ...base, resourceMode: false, replace: true, serverSideApply: true, applicationSyncOptions }).syncOptions)
60
+ .toEqual(['CreateNamespace=true', 'PruneLast=true', 'Replace=true', 'ServerSideApply=true'])
61
+ expect(buildArgoSyncOpts({ ...base, resourceMode: false, applicationSyncOptions }).syncOptions)
62
+ .toEqual(['CreateNamespace=true', 'PruneLast=true'])
63
+ })
64
+
65
+ it("turning on an option the Application sets to false replaces that entry", () => {
66
+ expect(buildArgoSyncOpts({ ...base, resourceMode: true, serverSideApply: true, applicationSyncOptions: ['ServerSideApply=false', 'Validate=false'] }).syncOptions)
67
+ .toEqual(['Validate=false', 'ServerSideApply=true'])
68
+ })
69
+
70
+ it('preserves every other option string when editing either owned key', () => {
71
+ const applicationSyncOptions = ['FailOnSharedResource=true', 'PrunePropagationPolicy=orphan', 'Validate=false', 'Custom=opaque', 'Custom=opaque', 'Replace=false', 'ServerSideApply=false']
72
+ const otherOptions = applicationSyncOptions.slice(0, -2)
73
+ expect(buildArgoSyncOpts({ ...base, resourceMode: false, replace: true, applicationSyncOptions }).syncOptions)
74
+ .toEqual([...otherOptions, 'Replace=true'])
75
+ expect(buildArgoSyncOpts({ ...base, resourceMode: false, serverSideApply: true, applicationSyncOptions }).syncOptions)
76
+ .toEqual([...otherOptions, 'ServerSideApply=true'])
77
+ })
78
+ })
79
+
80
+ describe('argoSyncOptionsForRequest', () => {
81
+ const app = ['CreateNamespace=true']
82
+ it('omits the options for a Radar that inherits them', () => {
83
+ expect(argoSyncOptionsForRequest(undefined, app, true)).toBeUndefined()
84
+ })
85
+ it("sends the Application's options to an older Radar, which would drop them", () => {
86
+ expect(argoSyncOptionsForRequest(undefined, app, false)).toEqual(app)
87
+ expect(argoSyncOptionsForRequest(undefined, [], false)).toBeUndefined()
88
+ expect(argoSyncOptionsForRequest(undefined, undefined, false)).toBeUndefined()
89
+ })
90
+ it('sends explicit options unchanged either way', () => {
91
+ expect(argoSyncOptionsForRequest([], app, false)).toEqual([])
92
+ expect(argoSyncOptionsForRequest(['Replace=true'], app, true)).toEqual(['Replace=true'])
93
+ })
94
+ })
95
+
96
+ describe('buildArgoSyncOpts with options that are no longer current', () => {
97
+ it('sends the unchanged list instead of omitting it', () => {
98
+ expect(buildArgoSyncOpts({ ...base, resourceMode: false, serverSideApply: true, applicationSyncOptions: ['ServerSideApply=true'], sendUnchanged: true }).syncOptions)
99
+ .toEqual(['ServerSideApply=true'])
100
+ })
48
101
  })