@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
@@ -4,6 +4,7 @@ import { AlertTriangle, CheckCircle2, CircleAlert, Loader2, RefreshCw, ShieldChe
4
4
  import { DialogPortal } from '../ui/DialogPortal'
5
5
  import { Input } from '../ui/Input'
6
6
  import { Tooltip } from '../ui/Tooltip'
7
+ import { SEVERITY_TEXT } from '../../utils/badge-colors'
7
8
  import type { GitOpsInsightRef } from '../../types/gitops-insights'
8
9
 
9
10
  // =============================================================================
@@ -13,9 +14,13 @@ import type { GitOpsInsightRef } from '../../types/gitops-insights'
13
14
  // the onConfirm callback (POST to /api/argo/applications/{ns}/{name}/sync
14
15
  // or the hub-proxied equivalent /c/{ctrl}/api/argo/...).
15
16
  //
16
- // Full sync defaults match Argo's most-common path (prune true, no dry-run,
17
- // no force). Resource sync fixes prune off and omits revision/apply-only so
18
- // selecting one object cannot opt into deletion behavior.
17
+ // Prune starts off, as in Argo CD's UI and CLI, and is always sent. Resource
18
+ // sync does not prune and omits revision/apply-only.
19
+ //
20
+ // Sync options start from the Application's own (applicationSyncOptions). A
21
+ // sync whose options the user leaves alone sends none, so Radar applies the
22
+ // Application's current ones; flipping Replace or Server-side apply sends the
23
+ // Application's options with that edit, keeping all other options.
19
24
  // =============================================================================
20
25
 
21
26
  // ArgoSyncOpts is the payload shape the dialog passes to its onConfirm
@@ -29,19 +34,35 @@ export interface ArgoSyncOpts {
29
34
  dryRun: boolean
30
35
  force: boolean
31
36
  applyOnly: boolean
32
- syncOptions: string[]
37
+ // Omitted: sync with the Application's own spec.syncPolicy.syncOptions.
38
+ // Present: the options for this sync, replacing the Application's ([] = none).
39
+ // Only a Radar advertising `argoSyncInheritance` reads an omission that
40
+ // way; see argoSyncOptionsForRequest.
41
+ syncOptions?: string[]
33
42
  }
34
43
 
35
44
  export interface SyncOptionsDialogProps {
36
45
  open: boolean
37
46
  appLabel: string
47
+ multiSource?: boolean
48
+ comparedRevision?: string
49
+ // An omitted revision will use the last compared one: the serving Radar
50
+ // pins (argoSyncInheritance) and the comparison matches the current
51
+ // source(s) (argoComparedRevisionCurrent). Otherwise the target is resolved
52
+ // when the sync runs.
53
+ pinsComparedRevision?: boolean
38
54
  resource?: GitOpsInsightRef
55
+ // The Application's spec.syncPolicy.syncOptions. Without it the toggles
56
+ // are disabled and the submitted syncOptions are omitted.
57
+ applicationSyncOptions?: string[]
39
58
  pending?: boolean
40
59
  autoSyncEnabled?: boolean
41
60
  validationPending?: boolean
42
61
  operationInProgress?: boolean
43
62
  validationResult?: ResourceValidationResult | null
44
63
  validationError?: string | null
64
+ // Why the last confirm failed, for a host that keeps the dialog open to retry.
65
+ syncError?: string | null
45
66
  onCancel: () => void
46
67
  onConfirm: (opts: ArgoSyncOpts) => void
47
68
  onValidate?: (opts: ArgoSyncOpts) => void
@@ -57,39 +78,94 @@ export interface ResourceValidationResult {
57
78
  }
58
79
  }
59
80
 
60
- export function SyncOptionsDialog({ open, appLabel, resource, pending, autoSyncEnabled, validationPending, operationInProgress, validationResult, validationError, onCancel, onConfirm, onValidate, onValidationReset }: SyncOptionsDialogProps) {
81
+ export function SyncOptionsDialog({ open, appLabel, multiSource, comparedRevision, pinsComparedRevision, resource, applicationSyncOptions, pending, autoSyncEnabled, validationPending, operationInProgress, validationResult, validationError, syncError, onCancel, onConfirm, onValidate, onValidationReset }: SyncOptionsDialogProps) {
61
82
  const titleId = useId()
83
+ const syncOptionsUnknown = applicationSyncOptions === undefined
84
+ const liveAppOptions = applicationSyncOptions ?? EMPTY_OPTIONS
85
+ // Joined so a caller passing a fresh array each render isn't a change.
86
+ const liveAppOptionsKey = liveAppOptions.join('\n')
87
+ // The options the toggles started from, which the request is built from.
88
+ // They follow the Application until the user edits an option, so a refetch
89
+ // while the dialog is open never replaces what the user sees or chose.
90
+ const [appOptions, setAppOptions] = useState(liveAppOptions)
91
+ const [optionsEdited, setOptionsEdited] = useState(false)
92
+ const optionsChangedWhileEditing = optionsEdited && liveAppOptionsKey !== appOptions.join('\n')
93
+ const appReplace = appOptions.includes(REPLACE_OPTION)
94
+ const appServerSideApply = appOptions.includes(SERVER_SIDE_APPLY_OPTION)
95
+ const otherAppOptions = appOptions.filter((option) => !isToggledOption(option))
96
+ const comparedRevisionLabel = comparedRevision && /^[0-9a-f]{40}$/i.test(comparedRevision) ? comparedRevision.slice(0, 7) : comparedRevision
62
97
  const [revision, setRevision] = useState('')
63
- const [prune, setPrune] = useState(true)
98
+ const [prune, setPrune] = useState(false)
64
99
  const [dryRun, setDryRun] = useState(false)
65
100
  const [force, setForce] = useState(false)
66
101
  const [applyOnly, setApplyOnly] = useState(false)
67
- const [replace, setReplace] = useState(false)
68
- const [serverSideApply, setServerSideApply] = useState(false)
102
+ const [replace, setReplace] = useState(appReplace)
103
+ const [serverSideApply, setServerSideApply] = useState(appServerSideApply)
69
104
  const richResourceValidation = !!resource && !!onValidate
70
105
  const busy = !!pending || !!validationPending
71
106
  const optionsDisabled = busy || !!operationInProgress
107
+ const revisionDisabled = !!autoSyncEnabled && !dryRun
108
+ const targetLabel = pinsComparedRevision
109
+ ? `the revision Argo last compared${comparedRevisionLabel ? ` (${comparedRevisionLabel})` : ''}`
110
+ : 'the target revision, resolved when the sync runs'
111
+
112
+ function startFromApplicationOptions(options: string[]) {
113
+ setAppOptions(options)
114
+ setReplace(options.includes(REPLACE_OPTION))
115
+ setServerSideApply(options.includes(SERVER_SIDE_APPLY_OPTION))
116
+ }
72
117
 
73
118
  // Reset on each open so a previous attempt's flags don't leak into the
74
119
  // next sync — easy footgun in modal-heavy flows.
75
120
  useEffect(() => {
76
121
  if (open) {
77
122
  setRevision('')
78
- setPrune(!resource)
123
+ setPrune(false)
79
124
  setDryRun(false)
80
125
  setForce(false)
81
126
  setApplyOnly(false)
82
- setReplace(false)
83
- setServerSideApply(false)
127
+ setOptionsEdited(false)
128
+ startFromApplicationOptions(liveAppOptions)
84
129
  }
130
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
131
  }, [open, resource])
86
132
 
133
+ // A revision that no longer applies is cleared rather than left on screen
134
+ // while the sync deploys something else.
135
+ useEffect(() => {
136
+ if (revisionDisabled || multiSource) setRevision('')
137
+ }, [revisionDisabled, multiSource])
138
+
139
+ // The Application's options changed while the dialog is open and the user
140
+ // hasn't edited one: start from the new ones, and drop a dry-run result
141
+ // that was computed with the old ones.
142
+ useEffect(() => {
143
+ if (!open || optionsEdited || liveAppOptionsKey === appOptions.join('\n')) return
144
+ startFromApplicationOptions(liveAppOptions)
145
+ onValidationReset?.()
146
+ // eslint-disable-next-line react-hooks/exhaustive-deps
147
+ }, [liveAppOptionsKey])
148
+
149
+ // Omitting the options means "the Application's current ones", which is
150
+ // what the toggles show only while the snapshot is still current.
151
+ const optionEdits = syncOptionsUnknown
152
+ ? { replace: false, serverSideApply: false }
153
+ : { replace, serverSideApply, applicationSyncOptions: appOptions, sendUnchanged: appOptions.join('\n') !== liveAppOptionsKey }
154
+
155
+ // Replace and Force can take workloads down, so a real sync using either says
156
+ // so beside the button and in its label: Argo CD's own panel asks for a
157
+ // confirmation on each. Replace counts when inherited from the Application.
158
+ const disruptive = !dryRun || richResourceValidation
159
+ ? [replace && 'replace', force && 'force'].filter((mode): mode is string => !!mode)
160
+ : []
161
+ const syncLabel = resource ? 'Sync resource' : 'Sync now'
162
+
87
163
  function submit() {
88
- onConfirm(buildArgoSyncOpts({ resourceMode: !!resource, revision, prune, dryRun: richResourceValidation ? false : dryRun, force, applyOnly, replace, serverSideApply }))
164
+ onConfirm(buildArgoSyncOpts({ resourceMode: !!resource, revision: multiSource || revisionDisabled ? '' : revision, prune, dryRun: richResourceValidation ? false : dryRun, force, applyOnly, ...optionEdits }))
89
165
  }
90
166
 
91
167
  function validate() {
92
- onValidate?.(buildArgoSyncOpts({ resourceMode: true, revision, prune, dryRun: true, force, applyOnly, replace, serverSideApply }))
168
+ onValidate?.(buildArgoSyncOpts({ resourceMode: true, revision, prune, dryRun: true, force, applyOnly, ...optionEdits }))
93
169
  }
94
170
 
95
171
  function updateValidationOption(setter: (value: boolean) => void, value: boolean) {
@@ -97,13 +173,18 @@ export function SyncOptionsDialog({ open, appLabel, resource, pending, autoSyncE
97
173
  onValidationReset?.()
98
174
  }
99
175
 
176
+ function updateSyncOption(setter: (value: boolean) => void, value: boolean) {
177
+ setOptionsEdited(true)
178
+ updateValidationOption(setter, value)
179
+ }
180
+
100
181
  return (
101
- <DialogPortal ariaLabelledBy={titleId} open={open} onClose={busy ? () => {} : onCancel} className="w-[480px]" closable={!busy}>
102
- <div className="border-b border-theme-border px-4 py-3">
182
+ <DialogPortal ariaLabelledBy={titleId} open={open} onClose={busy ? () => {} : onCancel} className="flex max-h-[calc(100vh-2rem)] w-[480px] flex-col" closable={!busy}>
183
+ <div className="shrink-0 border-b border-theme-border px-4 py-3">
103
184
  <h2 id={titleId} className="text-sm font-semibold text-theme-text-primary">{resource ? 'Sync resource' : 'Sync application'}</h2>
104
185
  <p className="mt-0.5 break-all text-xs text-theme-text-tertiary">{appLabel}</p>
105
186
  </div>
106
- <div className="space-y-4 px-4 py-4 text-sm">
187
+ <div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4 text-sm">
107
188
  {resource && (
108
189
  <div className="rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2.5">
109
190
  <div className="flex items-start gap-2">
@@ -113,32 +194,37 @@ export function SyncOptionsDialog({ open, appLabel, resource, pending, autoSyncE
113
194
  {resource.kind} / {resource.namespace ? `${resource.namespace}/` : ''}{resource.name}
114
195
  </div>
115
196
  <p className="mt-1 text-[11px] leading-relaxed text-theme-text-secondary">
116
- Only this resource will be applied. Selective sync bypasses hooks and normal sync-wave ordering; use a full application sync when this resource depends on them.
197
+ Syncs only this resource, without hooks. Resources it depends on aren't synced; namespace creation still applies. Uses {multiSource && pinsComparedRevision ? 'the revisions Argo last compared' : targetLabel}.
117
198
  </p>
118
199
  {autoSyncEnabled && (
119
- <p className="mt-2 border-t border-amber-500/20 pt-2 text-[11px] leading-relaxed text-theme-text-secondary">
120
- Auto-sync is enabled. Argo can reconcile this Application independently while the dry-run runs; a dry-run does not pause automation.
200
+ <p className="mt-1 text-[11px] leading-relaxed text-theme-text-secondary">
201
+ Auto-sync stays on during the dry run, so Argo can still sync the Application meanwhile.
121
202
  </p>
122
203
  )}
123
204
  </div>
124
205
  </div>
125
206
  </div>
126
207
  )}
127
- {!resource && (
208
+ {!resource && !multiSource && (
128
209
  <label className="block">
129
210
  <span className="text-xs font-medium text-theme-text-secondary">Revision (optional)</span>
130
211
  <Input
131
212
  value={revision}
132
213
  onChange={(e) => setRevision(e.target.value)}
133
- placeholder="HEAD"
134
- disabled={optionsDisabled}
214
+ placeholder={pinsComparedRevision ? 'Last compared revision' : 'Target revision'}
215
+ disabled={optionsDisabled || revisionDisabled}
135
216
  className="mt-1 w-full rounded-md border border-theme-border bg-theme-base px-2 py-1.5 font-mono text-xs text-theme-text-primary outline-none placeholder:text-theme-text-tertiary focus:border-sky-500"
136
217
  />
137
218
  <span className="mt-0.5 block text-[11px] text-theme-text-tertiary">
138
- Branch, tag, or commit SHA. Leave empty to use the Application's targetRevision.
219
+ {revisionDisabled
220
+ ? `Auto-sync is on, so this sync uses ${targetLabel}. Turn off auto-sync to deploy another revision; a dry run can preview one.`
221
+ : `Branch, tag, or commit SHA. Leave empty for ${targetLabel}.`}
139
222
  </span>
140
223
  </label>
141
224
  )}
225
+ {!resource && multiSource && (
226
+ <p className="text-xs text-theme-text-tertiary">{pinsComparedRevision ? 'Syncs each source at the revision Argo last compared.' : "Syncs each source's target revision, resolved when the sync runs."}</p>
227
+ )}
142
228
 
143
229
  {/* Common (Prune / Dry run) sit above a divider; Advanced toggles
144
230
  stay accessible but visually subordinate so the common-case user
@@ -154,9 +240,38 @@ export function SyncOptionsDialog({ open, appLabel, resource, pending, autoSyncE
154
240
  <legend className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Advanced</legend>
155
241
  {!resource && <Toggle label="Apply only" checked={applyOnly} onChange={setApplyOnly} disabled={optionsDisabled} hint="Skip PreSync / PostSync / SyncFail hooks." />}
156
242
  <Toggle label="Force" checked={force} onChange={(value) => updateValidationOption(setForce, value)} disabled={optionsDisabled} hint="Use kubectl --force; required for some immutable-field changes." />
157
- <Toggle label="Replace" checked={replace} onChange={(value) => updateValidationOption(setReplace, value)} disabled={optionsDisabled} hint="kubectl replace instead of apply (drops fields not in source)." />
158
- <Toggle label="Server-side apply" checked={serverSideApply} onChange={(value) => updateValidationOption(setServerSideApply, value)} disabled={optionsDisabled} hint="Use the K8s server-side apply mechanism for ownership tracking." />
243
+ <Toggle label="Replace" checked={replace} onChange={(value) => updateSyncOption(setReplace, value)} disabled={optionsDisabled || syncOptionsUnknown} hint={`kubectl replace instead of apply (drops fields not in source).${appReplace ? FROM_APPLICATION : ''}`} />
244
+ <Toggle label="Server-side apply" checked={serverSideApply} onChange={(value) => updateSyncOption(setServerSideApply, value)} disabled={optionsDisabled || syncOptionsUnknown} hint={`Use the K8s server-side apply mechanism for ownership tracking.${appServerSideApply ? FROM_APPLICATION : ''}`} />
245
+ {syncOptionsUnknown && (
246
+ <p className="text-xs text-theme-text-tertiary">This view can't show or change the Application's sync options, so Replace and Server-side apply are unavailable here.</p>
247
+ )}
248
+ {optionsChangedWhileEditing && (
249
+ <p className="text-xs text-theme-text-secondary">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.</p>
250
+ )}
251
+ {otherAppOptions.length > 0 && (
252
+ <div>
253
+ <div className="text-xs font-medium text-theme-text-secondary">Also used for this sync</div>
254
+ <ul className="mt-1 space-y-1 text-[11px] text-theme-text-tertiary">
255
+ {otherAppOptions.map((option, index) => <li key={index} className="break-all font-mono">{option}</li>)}
256
+ </ul>
257
+ </div>
258
+ )}
159
259
  </fieldset>
260
+ {syncError && !pending && (
261
+ <p role="alert" className={`flex items-start gap-1.5 text-xs ${SEVERITY_TEXT.error}`}>
262
+ <XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
263
+ <span className="max-h-20 min-w-0 overflow-auto break-words">Sync failed: {syncError}</span>
264
+ </p>
265
+ )}
266
+ {disruptive.length > 0 && (
267
+ <p className={`flex items-start gap-1.5 text-xs ${SEVERITY_TEXT.warning}`}>
268
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
269
+ <span>
270
+ {replace && 'Replace deletes and recreates resources instead of updating them, which can cause downtime. '}
271
+ {force && 'Force removes resources immediately, skipping graceful deletion, which can lose data.'}
272
+ </span>
273
+ </p>
274
+ )}
160
275
  {richResourceValidation && (validationPending || validationResult || validationError) && (
161
276
  <ValidationResult pending={!!validationPending} result={validationResult} error={validationError} />
162
277
  )}
@@ -188,7 +303,7 @@ export function SyncOptionsDialog({ open, appLabel, resource, pending, autoSyncE
188
303
  </button>
189
304
  </Tooltip>
190
305
  )}
191
- <PrimaryButton onClick={submit} disabled={optionsDisabled} icon={pending ? Loader2 : RefreshCw} loading={pending} label={dryRun && !richResourceValidation ? 'Run dry-run' : resource ? 'Sync resource' : 'Sync now'} />
306
+ <PrimaryButton onClick={submit} disabled={optionsDisabled} icon={pending ? Loader2 : RefreshCw} loading={pending} label={dryRun && !richResourceValidation ? 'Run dry-run' : disruptive.length > 0 ? `${syncLabel === 'Sync now' ? 'Sync' : syncLabel} with ${disruptive.join(' and ')}` : syncLabel} warning={disruptive.length > 0} />
192
307
  </div>
193
308
  </DialogPortal>
194
309
  )
@@ -249,7 +364,19 @@ function ValidationResult({ pending, result, error }: { pending: boolean; result
249
364
  )
250
365
  }
251
366
 
252
- export function buildArgoSyncOpts({ resourceMode, revision, prune, dryRun, force, applyOnly, replace, serverSideApply }: {
367
+ const EMPTY_OPTIONS: string[] = []
368
+ const REPLACE_OPTION = 'Replace=true'
369
+ const SERVER_SIDE_APPLY_OPTION = 'ServerSideApply=true'
370
+ const FROM_APPLICATION = " On in the Application's sync policy."
371
+
372
+ // The dialog's Replace and Server-side apply toggles own these keys, whatever
373
+ // value the Application gives them.
374
+ function isToggledOption(option: string): boolean {
375
+ const key = option.split('=')[0].trim()
376
+ return key === 'Replace' || key === 'ServerSideApply'
377
+ }
378
+
379
+ export function buildArgoSyncOpts({ resourceMode, revision, prune, dryRun, force, applyOnly, replace, serverSideApply, applicationSyncOptions = EMPTY_OPTIONS, sendUnchanged = false }: {
253
380
  resourceMode: boolean
254
381
  revision: string
255
382
  prune: boolean
@@ -258,18 +385,36 @@ export function buildArgoSyncOpts({ resourceMode, revision, prune, dryRun, force
258
385
  applyOnly: boolean
259
386
  replace: boolean
260
387
  serverSideApply: boolean
388
+ applicationSyncOptions?: string[]
389
+ // Send the options even when the toggles match applicationSyncOptions,
390
+ // because those are no longer the Application's current ones.
391
+ sendUnchanged?: boolean
261
392
  }): ArgoSyncOpts {
262
- const syncOptions: string[] = []
263
- if (replace) syncOptions.push('Replace=true')
264
- if (serverSideApply) syncOptions.push('ServerSideApply=true')
265
- return {
393
+ const opts: ArgoSyncOpts = {
266
394
  revision: resourceMode ? undefined : revision.trim() || undefined,
267
395
  prune: resourceMode ? false : prune,
268
396
  dryRun,
269
397
  force,
270
398
  applyOnly: resourceMode ? false : applyOnly,
271
- syncOptions,
272
399
  }
400
+ const unchanged = replace === applicationSyncOptions.includes(REPLACE_OPTION) &&
401
+ serverSideApply === applicationSyncOptions.includes(SERVER_SIDE_APPLY_OPTION)
402
+ if (unchanged && !sendUnchanged) return opts
403
+
404
+ const syncOptions = applicationSyncOptions.filter((option) => !isToggledOption(option))
405
+ if (replace) syncOptions.push(REPLACE_OPTION)
406
+ if (serverSideApply) syncOptions.push(SERVER_SIDE_APPLY_OPTION)
407
+ return { ...opts, syncOptions }
408
+ }
409
+
410
+ // argoSyncOptionsForRequest is the syncOptions to send for a sync whose
411
+ // options are `syncOptions` (undefined = the Application's own). Only a Radar
412
+ // advertising `argoSyncInheritance` reads an omitted list as the
413
+ // Application's own; an older one syncs with none, so it is sent the
414
+ // Application's options as the caller last read them.
415
+ export function argoSyncOptionsForRequest(syncOptions: string[] | undefined, applicationSyncOptions: string[] | undefined, serverInheritsOptions: boolean): string[] | undefined {
416
+ if (syncOptions !== undefined || serverInheritsOptions) return syncOptions
417
+ return applicationSyncOptions && applicationSyncOptions.length > 0 ? applicationSyncOptions : undefined
273
418
  }
274
419
 
275
420
  function Toggle({ label, checked, onChange, disabled, hint }: { label: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; hint?: string }) {
@@ -290,13 +435,13 @@ function Toggle({ label, checked, onChange, disabled, hint }: { label: string; c
290
435
  )
291
436
  }
292
437
 
293
- function PrimaryButton({ onClick, disabled, icon: Icon, loading, label }: { onClick: () => void; disabled?: boolean; icon: ComponentType<{ className?: string }>; loading?: boolean; label: string }) {
438
+ function PrimaryButton({ onClick, disabled, icon: Icon, loading, label, warning }: { onClick: () => void; disabled?: boolean; icon: ComponentType<{ className?: string }>; loading?: boolean; label: string; warning?: boolean }) {
294
439
  return (
295
440
  <button
296
441
  type="button"
297
442
  onClick={onClick}
298
443
  disabled={disabled}
299
- className="btn-brand inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
444
+ className={`${warning ? 'bg-amber-600 text-theme-text-primary hover:bg-amber-700' : 'btn-brand'} inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50`}
300
445
  >
301
446
  <Icon className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
302
447
  {label}
@@ -92,6 +92,18 @@ describe('hasSyncPolicy', () => {
92
92
  })
93
93
  expect(hasSyncPolicy(app)).toBe(true)
94
94
  })
95
+
96
+ // Argo CD 3.x switches automation off in place with enabled: false; the
97
+ // Automation facet must count that app as Manual, not Auto.
98
+ test('automated.enabled=false is manual', () => {
99
+ const row = (automated: Record<string, unknown>) => normalizeArgoApplication({
100
+ metadata: { name: 'guestbook', namespace: 'argocd' },
101
+ spec: { project: 'default', source: {}, destination: {}, syncPolicy: { automated } },
102
+ status: {},
103
+ })
104
+ expect(row({ prune: true, selfHeal: true }).autoSync).toBe(true)
105
+ expect(row({ enabled: false, prune: true, selfHeal: true }).autoSync).toBe(false)
106
+ })
95
107
  })
96
108
 
97
109
  describe('normalizeArgoApplicationSet destination', () => {