@skyhook-io/k8s-ui 1.8.11 → 1.8.13

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 (87) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.test.tsx +136 -8
  3. package/src/components/applications/ApplicationDetail.tsx +1215 -487
  4. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  5. package/src/components/gitops/GitOpsTableView.tsx +10 -3
  6. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  7. package/src/components/gitops/SyncOptionsDialog.tsx +189 -45
  8. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  9. package/src/components/gitops/insights/GitOpsInsightViews.tsx +201 -21
  10. package/src/components/gitops/insights/index.ts +1 -0
  11. package/src/components/gitops/insights/insights-helpers.test.ts +16 -0
  12. package/src/components/gitops/insights/insights-helpers.ts +5 -0
  13. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -0
  14. package/src/components/issues/IssuesView.tsx +55 -29
  15. package/src/components/issues/ResourceIssuesSection.test.tsx +33 -1
  16. package/src/components/issues/ResourceIssuesSection.tsx +21 -1
  17. package/src/components/issues/severity.ts +1 -0
  18. package/src/components/resources/ResourcesView.tsx +109 -19
  19. package/src/components/resources/column-filter-serialization.test.ts +79 -1
  20. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  24. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  25. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  26. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  28. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  29. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  30. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  32. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  33. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  34. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  35. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  36. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  37. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  38. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  39. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  40. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  41. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  42. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  43. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  44. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  54. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  55. package/src/components/resources/resource-utils-argo.ts +8 -0
  56. package/src/components/resources/resource-utils.ts +94 -25
  57. package/src/components/shared/ResourceActionsBar.tsx +54 -22
  58. package/src/components/shared/UnifiedDiff.tsx +51 -0
  59. package/src/components/shared/index.ts +1 -0
  60. package/src/components/timeline/TimelineList.tsx +7 -3
  61. package/src/components/timeline/TimelineToolbarState.test.tsx +24 -0
  62. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  63. package/src/components/topology/TopologyControls.tsx +3 -1
  64. package/src/components/topology/TopologyGraph.tsx +76 -90
  65. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  66. package/src/components/topology/TopologySearch.tsx +23 -15
  67. package/src/components/topology/index.ts +2 -0
  68. package/src/components/ui/Badge.tsx +1 -1
  69. package/src/components/ui/SelectMenu.tsx +81 -0
  70. package/src/components/ui/Tooltip.test.tsx +21 -0
  71. package/src/components/ui/Tooltip.tsx +9 -12
  72. package/src/components/ui/drawer-components.tsx +9 -6
  73. package/src/components/ui/index.ts +2 -0
  74. package/src/components/workload/WorkloadView.tsx +24 -3
  75. package/src/theme/tailwind-theme.css +1 -0
  76. package/src/theme/variables.css +1 -0
  77. package/src/types/gitops-insights.ts +69 -1
  78. package/src/types/gitops.ts +3 -3
  79. package/src/utils/api-resources.test.ts +36 -1
  80. package/src/utils/api-resources.ts +16 -0
  81. package/src/utils/application-history.test.ts +538 -0
  82. package/src/utils/application-history.ts +257 -0
  83. package/src/utils/applications.test.ts +34 -0
  84. package/src/utils/applications.ts +25 -0
  85. package/src/utils/index.ts +1 -0
  86. package/src/utils/resource-hierarchy.test.ts +35 -1
  87. package/src/utils/resource-hierarchy.ts +20 -0
@@ -77,6 +77,7 @@ export interface ArgoActionHandlers {
77
77
  autoSyncEnabled: boolean
78
78
  // isRunning: caller derives from status.operationState.phase === 'Running'.
79
79
  isRunning: boolean
80
+ operationInProgress?: boolean
80
81
  }
81
82
 
82
83
  export interface FluxActionHandlers {
@@ -158,6 +159,9 @@ export interface GitOpsDetailLayoutProps {
158
159
  // hub-web and OSS can wire their own data sources without the layout
159
160
  // knowing.
160
161
  renderTabBody: (ctx: { tab: GitOpsDetailTab; fullscreen: boolean }) => ReactNode
162
+ // Optional host slot: inline Git commit metadata next to the status strip's
163
+ // latest revision (author, signature). Wired by the host to a data loader.
164
+ renderRevisionMeta?: (revision: string) => ReactNode
161
165
  // Optional: top-right tab-bar accessory (Clear filters + Fullscreen
162
166
  // buttons in OSS, can be anything else in hub-web).
163
167
  renderTabBarAccessory?: (ctx: { tab: GitOpsDetailTab; fullscreen: boolean }) => ReactNode
@@ -228,6 +232,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
228
232
  fullscreen,
229
233
  onToggleFullscreen,
230
234
  renderTabBody,
235
+ renderRevisionMeta,
231
236
  renderTabBarAccessory,
232
237
  renderTabBarCounts,
233
238
  resourceLoading,
@@ -252,6 +257,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
252
257
  }, [identity.name, manageDocumentTitle, documentTitleSuffix])
253
258
 
254
259
  const effectiveSuspended = status?.suspended ?? false
260
+ const argoOperationInProgress = argo?.operationInProgress ?? argo?.isRunning ?? false
255
261
  // graphFullscreen hides everything chrome-side; the body region expands.
256
262
  // Mirrors the OSS GitOpsDetailView shell behavior so the visual chrome
257
263
  // class set carries through.
@@ -344,8 +350,12 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
344
350
  icon={ArrowDownUp}
345
351
  loading={argo.syncing}
346
352
  onClick={argo.onSyncRequested}
347
- disabled={effectiveSuspended || terminating}
348
- disabledReason={terminating ? terminatingActionTooltip : undefined}
353
+ disabled={effectiveSuspended || terminating || argoOperationInProgress}
354
+ disabledReason={terminating
355
+ ? terminatingActionTooltip
356
+ : argoOperationInProgress
357
+ ? 'Wait for the current Argo operation to finish.'
358
+ : undefined}
349
359
  primary
350
360
  />
351
361
  <ActionButton
@@ -447,7 +457,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
447
457
  )}
448
458
  {!fullscreen && (
449
459
  <>
450
- <GitOpsStatusStrip insight={insight ?? undefined} loading={insightLoading} />
460
+ <GitOpsStatusStrip insight={insight ?? undefined} loading={insightLoading} renderRevisionMeta={renderRevisionMeta} />
451
461
  <GitOpsIssuesBand
452
462
  issues={insight?.issues}
453
463
  terminating={terminating}
@@ -35,7 +35,7 @@ import { DistributionBar } from '../ui/DistributionBar'
35
35
  import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
36
36
  import { BoardTableSkeleton, BoardRailSkeleton } from '../ui/BoardSkeleton'
37
37
  import { getGitOpsResourceStatus } from './detail-helpers'
38
- import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
38
+ import { isArgoOperationInProgress, isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
39
39
  import { toggleSet } from './GitOpsGraphFilterRail'
40
40
  import { useFilterState, defineFilterSchema } from '../../filter-state'
41
41
  import { parseContextName } from '../../utils/context-name'
@@ -1358,13 +1358,20 @@ function buildRowActionItems(
1358
1358
  const items: RowActionItem[] = []
1359
1359
 
1360
1360
  if (row.tool === 'argo') {
1361
+ const operationInProgress = isArgoOperationInProgress(row.raw)
1361
1362
  items.push({
1362
1363
  key: 'sync',
1363
1364
  label: 'Sync...',
1364
1365
  icon: ArrowDownUp,
1365
1366
  onClick: () => onAction(row, 'sync'),
1366
- disabled: suspended || terminating,
1367
- disabledReason: terminating ? terminatingReason : suspended ? suspendedReason : undefined,
1367
+ disabled: suspended || terminating || operationInProgress,
1368
+ disabledReason: terminating
1369
+ ? terminatingReason
1370
+ : suspended
1371
+ ? suspendedReason
1372
+ : operationInProgress
1373
+ ? 'Wait for the current Argo operation to finish.'
1374
+ : undefined,
1368
1375
  pending: isPending('sync'),
1369
1376
  })
1370
1377
  // Refresh / Hard refresh are read-style verbs — they re-read Git and
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildArgoSyncOpts } from './SyncOptionsDialog'
3
+
4
+ describe('buildArgoSyncOpts', () => {
5
+ it('keeps full application sync controls', () => {
6
+ expect(
7
+ buildArgoSyncOpts({
8
+ resourceMode: false,
9
+ revision: ' main ',
10
+ prune: true,
11
+ dryRun: true,
12
+ force: false,
13
+ applyOnly: true,
14
+ replace: true,
15
+ serverSideApply: false,
16
+ }),
17
+ ).toEqual({
18
+ revision: 'main',
19
+ prune: true,
20
+ dryRun: true,
21
+ force: false,
22
+ applyOnly: true,
23
+ syncOptions: ['Replace=true'],
24
+ })
25
+ })
26
+
27
+ it('removes revision, prune, and apply-only semantics from resource sync', () => {
28
+ expect(
29
+ buildArgoSyncOpts({
30
+ resourceMode: true,
31
+ revision: 'unsafe-revision',
32
+ prune: true,
33
+ dryRun: false,
34
+ force: true,
35
+ applyOnly: true,
36
+ replace: false,
37
+ serverSideApply: true,
38
+ }),
39
+ ).toEqual({
40
+ revision: undefined,
41
+ prune: false,
42
+ dryRun: false,
43
+ force: true,
44
+ applyOnly: false,
45
+ syncOptions: ['ServerSideApply=true'],
46
+ })
47
+ })
48
+ })
@@ -1,8 +1,10 @@
1
1
  import { useState, useEffect, type ComponentType } from 'react'
2
- import { Loader2, RefreshCw } from 'lucide-react'
2
+ import { AlertTriangle, CheckCircle2, CircleAlert, Loader2, RefreshCw, ShieldCheck, XCircle } from 'lucide-react'
3
3
 
4
4
  import { DialogPortal } from '../ui/DialogPortal'
5
5
  import { Input } from '../ui/Input'
6
+ import { Tooltip } from '../ui/Tooltip'
7
+ import type { GitOpsInsightRef } from '../../types/gitops-insights'
6
8
 
7
9
  // =============================================================================
8
10
  // SyncOptionsDialog — Argo CD Sync drawer, shared between per-cluster
@@ -11,9 +13,9 @@ import { Input } from '../ui/Input'
11
13
  // the onConfirm callback (POST to /api/argo/applications/{ns}/{name}/sync
12
14
  // or the hub-proxied equivalent /c/{ctrl}/api/argo/...).
13
15
  //
14
- // Defaults match Argo's most-common path (prune true, no dry-run, no
15
- // force) so the two-click sync stays cheap. Revision is optional empty
16
- // falls through to the Application's targetRevision.
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
19
  // =============================================================================
18
20
 
19
21
  // ArgoSyncOpts is the payload shape the dialog passes to its onConfirm
@@ -33,12 +35,29 @@ export interface ArgoSyncOpts {
33
35
  export interface SyncOptionsDialogProps {
34
36
  open: boolean
35
37
  appLabel: string
38
+ resource?: GitOpsInsightRef
36
39
  pending?: boolean
40
+ autoSyncEnabled?: boolean
41
+ validationPending?: boolean
42
+ operationInProgress?: boolean
43
+ validationResult?: ResourceValidationResult | null
44
+ validationError?: string | null
37
45
  onCancel: () => void
38
46
  onConfirm: (opts: ArgoSyncOpts) => void
47
+ onValidate?: (opts: ArgoSyncOpts) => void
48
+ onValidationReset?: () => void
39
49
  }
40
50
 
41
- export function SyncOptionsDialog({ open, appLabel, pending, onCancel, onConfirm }: SyncOptionsDialogProps) {
51
+ export interface ResourceValidationResult {
52
+ outcome: 'succeeded' | 'failed' | 'inconclusive'
53
+ message: string
54
+ resource?: {
55
+ status?: string
56
+ message?: string
57
+ }
58
+ }
59
+
60
+ export function SyncOptionsDialog({ open, appLabel, resource, pending, autoSyncEnabled, validationPending, operationInProgress, validationResult, validationError, onCancel, onConfirm, onValidate, onValidationReset }: SyncOptionsDialogProps) {
42
61
  const [revision, setRevision] = useState('')
43
62
  const [prune, setPrune] = useState(true)
44
63
  const [dryRun, setDryRun] = useState(false)
@@ -46,87 +65,212 @@ export function SyncOptionsDialog({ open, appLabel, pending, onCancel, onConfirm
46
65
  const [applyOnly, setApplyOnly] = useState(false)
47
66
  const [replace, setReplace] = useState(false)
48
67
  const [serverSideApply, setServerSideApply] = useState(false)
68
+ const richResourceValidation = !!resource && !!onValidate
69
+ const busy = !!pending || !!validationPending
70
+ const optionsDisabled = busy || !!operationInProgress
49
71
 
50
72
  // Reset on each open so a previous attempt's flags don't leak into the
51
73
  // next sync — easy footgun in modal-heavy flows.
52
74
  useEffect(() => {
53
75
  if (open) {
54
76
  setRevision('')
55
- setPrune(true)
77
+ setPrune(!resource)
56
78
  setDryRun(false)
57
79
  setForce(false)
58
80
  setApplyOnly(false)
59
81
  setReplace(false)
60
82
  setServerSideApply(false)
61
83
  }
62
- }, [open])
84
+ }, [open, resource])
63
85
 
64
86
  function submit() {
65
- const syncOptions: string[] = []
66
- if (replace) syncOptions.push('Replace=true')
67
- if (serverSideApply) syncOptions.push('ServerSideApply=true')
68
- onConfirm({
69
- revision: revision.trim() || undefined,
70
- prune,
71
- dryRun,
72
- force,
73
- applyOnly,
74
- syncOptions,
75
- })
87
+ onConfirm(buildArgoSyncOpts({ resourceMode: !!resource, revision, prune, dryRun: richResourceValidation ? false : dryRun, force, applyOnly, replace, serverSideApply }))
88
+ }
89
+
90
+ function validate() {
91
+ onValidate?.(buildArgoSyncOpts({ resourceMode: true, revision, prune, dryRun: true, force, applyOnly, replace, serverSideApply }))
92
+ }
93
+
94
+ function updateValidationOption(setter: (value: boolean) => void, value: boolean) {
95
+ setter(value)
96
+ onValidationReset?.()
76
97
  }
77
98
 
78
99
  return (
79
- <DialogPortal open={open} onClose={pending ? () => {} : onCancel} className="w-[480px]" closable={!pending}>
100
+ <DialogPortal open={open} onClose={busy ? () => {} : onCancel} className="w-[480px]" closable={!busy}>
80
101
  <div className="border-b border-theme-border px-4 py-3">
81
- <h2 className="text-sm font-semibold text-theme-text-primary">Sync application</h2>
82
- <p className="mt-0.5 text-xs text-theme-text-tertiary">{appLabel}</p>
102
+ <h2 className="text-sm font-semibold text-theme-text-primary">{resource ? 'Sync resource' : 'Sync application'}</h2>
103
+ <p className="mt-0.5 break-all text-xs text-theme-text-tertiary">{appLabel}</p>
83
104
  </div>
84
105
  <div className="space-y-4 px-4 py-4 text-sm">
85
- <label className="block">
86
- <span className="text-xs font-medium text-theme-text-secondary">Revision (optional)</span>
87
- <Input
88
- value={revision}
89
- onChange={(e) => setRevision(e.target.value)}
90
- placeholder="HEAD"
91
- disabled={pending}
92
- 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"
93
- />
94
- <span className="mt-0.5 block text-[11px] text-theme-text-tertiary">
95
- Branch, tag, or commit SHA. Leave empty to use the Application's targetRevision.
96
- </span>
97
- </label>
106
+ {resource && (
107
+ <div className="rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2.5">
108
+ <div className="flex items-start gap-2">
109
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
110
+ <div className="min-w-0">
111
+ <div className="break-all text-xs font-medium text-theme-text-primary">
112
+ {resource.kind} / {resource.namespace ? `${resource.namespace}/` : ''}{resource.name}
113
+ </div>
114
+ <p className="mt-1 text-[11px] leading-relaxed text-theme-text-secondary">
115
+ 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.
116
+ </p>
117
+ {autoSyncEnabled && (
118
+ <p className="mt-2 border-t border-amber-500/20 pt-2 text-[11px] leading-relaxed text-theme-text-secondary">
119
+ Auto-sync is enabled. Argo can reconcile this Application independently while the dry-run runs; a dry-run does not pause automation.
120
+ </p>
121
+ )}
122
+ </div>
123
+ </div>
124
+ </div>
125
+ )}
126
+ {!resource && (
127
+ <label className="block">
128
+ <span className="text-xs font-medium text-theme-text-secondary">Revision (optional)</span>
129
+ <Input
130
+ value={revision}
131
+ onChange={(e) => setRevision(e.target.value)}
132
+ placeholder="HEAD"
133
+ disabled={optionsDisabled}
134
+ 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"
135
+ />
136
+ <span className="mt-0.5 block text-[11px] text-theme-text-tertiary">
137
+ Branch, tag, or commit SHA. Leave empty to use the Application's targetRevision.
138
+ </span>
139
+ </label>
140
+ )}
98
141
 
99
142
  {/* Common (Prune / Dry run) sit above a divider; Advanced toggles
100
143
  stay accessible but visually subordinate so the common-case user
101
144
  can scan past them without parsing every helper line. */}
102
- <fieldset className="space-y-2">
103
- <legend className="mb-1 text-xs font-medium text-theme-text-secondary">Sync options</legend>
104
- <Toggle label="Prune" checked={prune} onChange={setPrune} disabled={pending} hint="Delete resources that are no longer in Git." />
105
- <Toggle label="Dry run" checked={dryRun} onChange={setDryRun} disabled={pending} hint="Preview only Argo computes the diff but applies nothing." />
106
- </fieldset>
145
+ {!richResourceValidation && (
146
+ <fieldset className="space-y-2">
147
+ <legend className="mb-1 text-xs font-medium text-theme-text-secondary">Sync options</legend>
148
+ {!resource && <Toggle label="Prune" checked={prune} onChange={setPrune} disabled={optionsDisabled} hint="Delete resources that are no longer in Git." />}
149
+ <Toggle label="Dry run" checked={dryRun} onChange={setDryRun} disabled={optionsDisabled} hint="Preview only — Argo computes the diff but applies nothing." />
150
+ </fieldset>
151
+ )}
107
152
  <fieldset className="space-y-2 border-t border-theme-border pt-3">
108
153
  <legend className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Advanced</legend>
109
- <Toggle label="Apply only" checked={applyOnly} onChange={setApplyOnly} disabled={pending} hint="Skip PreSync / PostSync / SyncFail hooks." />
110
- <Toggle label="Force" checked={force} onChange={setForce} disabled={pending} hint="Use kubectl --force; required for some immutable-field changes." />
111
- <Toggle label="Replace" checked={replace} onChange={setReplace} disabled={pending} hint="kubectl replace instead of apply (drops fields not in source)." />
112
- <Toggle label="Server-side apply" checked={serverSideApply} onChange={setServerSideApply} disabled={pending} hint="Use the K8s server-side apply mechanism for ownership tracking." />
154
+ {!resource && <Toggle label="Apply only" checked={applyOnly} onChange={setApplyOnly} disabled={optionsDisabled} hint="Skip PreSync / PostSync / SyncFail hooks." />}
155
+ <Toggle label="Force" checked={force} onChange={(value) => updateValidationOption(setForce, value)} disabled={optionsDisabled} hint="Use kubectl --force; required for some immutable-field changes." />
156
+ <Toggle label="Replace" checked={replace} onChange={(value) => updateValidationOption(setReplace, value)} disabled={optionsDisabled} hint="kubectl replace instead of apply (drops fields not in source)." />
157
+ <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." />
113
158
  </fieldset>
159
+ {richResourceValidation && (validationPending || validationResult || validationError) && (
160
+ <ValidationResult pending={!!validationPending} result={validationResult} error={validationError} />
161
+ )}
162
+ {richResourceValidation && !validationPending && operationInProgress && (
163
+ <p className="text-[11px] leading-relaxed text-theme-text-secondary">
164
+ Argo is finishing the current operation. Sync becomes available when it releases the operation slot.
165
+ </p>
166
+ )}
114
167
  </div>
115
168
  <div className="flex items-center justify-end gap-2 border-t border-theme-border bg-theme-base px-4 py-3">
116
169
  <button
117
170
  type="button"
118
171
  onClick={onCancel}
119
- disabled={pending}
172
+ disabled={busy}
120
173
  className="rounded-md border border-theme-border bg-theme-surface px-3 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:cursor-not-allowed disabled:opacity-50"
121
174
  >
122
175
  Cancel
123
176
  </button>
124
- <PrimaryButton onClick={submit} disabled={pending} icon={pending ? Loader2 : RefreshCw} loading={pending} label={dryRun ? 'Run dry-run' : 'Sync now'} />
177
+ {richResourceValidation && (
178
+ <Tooltip content="Runs an Argo selective dry-run. Applies nothing; API admission can still reject the real sync." wrapperClassName="inline-flex">
179
+ <button
180
+ type="button"
181
+ onClick={validate}
182
+ disabled={optionsDisabled}
183
+ className="inline-flex items-center gap-1.5 rounded-md border border-theme-border bg-theme-surface px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:cursor-not-allowed disabled:opacity-50"
184
+ >
185
+ {validationPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ShieldCheck className="h-3.5 w-3.5" />}
186
+ {validationPending ? 'Running dry-run…' : 'Dry-run'}
187
+ </button>
188
+ </Tooltip>
189
+ )}
190
+ <PrimaryButton onClick={submit} disabled={optionsDisabled} icon={pending ? Loader2 : RefreshCw} loading={pending} label={dryRun && !richResourceValidation ? 'Run dry-run' : resource ? 'Sync resource' : 'Sync now'} />
125
191
  </div>
126
192
  </DialogPortal>
127
193
  )
128
194
  }
129
195
 
196
+ function ValidationResult({ pending, result, error }: { pending: boolean; result?: ResourceValidationResult | null; error?: string | null }) {
197
+ if (pending) {
198
+ return (
199
+ <div className="card-inner flex items-start gap-2 px-3 py-2.5">
200
+ <Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-theme-text-secondary" />
201
+ <div>
202
+ <div className="text-xs font-medium text-theme-text-primary">Running selective dry-run</div>
203
+ <p className="mt-0.5 text-[11px] text-theme-text-secondary">Waiting for the selective dry-run result. No resource changes will be applied.</p>
204
+ </div>
205
+ </div>
206
+ )
207
+ }
208
+ if (error) {
209
+ return (
210
+ <div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2.5">
211
+ <div className="flex items-start gap-2">
212
+ <XCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
213
+ <div>
214
+ <div className="text-xs font-medium text-theme-text-primary">Dry-run could not start or complete</div>
215
+ <p className="mt-0.5 max-h-20 overflow-auto break-words text-[11px] text-theme-text-secondary">{error}</p>
216
+ </div>
217
+ </div>
218
+ </div>
219
+ )
220
+ }
221
+ if (!result) return null
222
+
223
+ const succeeded = result.outcome === 'succeeded'
224
+ const inconclusive = result.outcome === 'inconclusive'
225
+ const Icon = succeeded ? CheckCircle2 : inconclusive ? CircleAlert : XCircle
226
+ const title = succeeded ? 'Dry-run passed' : inconclusive ? 'Dry-run was inconclusive' : 'Dry-run failed'
227
+ const surface = succeeded
228
+ ? 'border-emerald-500/30 bg-emerald-500/5'
229
+ : inconclusive
230
+ ? 'border-amber-500/30 bg-amber-500/5'
231
+ : 'border-red-500/30 bg-red-500/5'
232
+ const iconTone = succeeded ? 'text-emerald-500' : inconclusive ? 'text-amber-500' : 'text-red-500'
233
+ return (
234
+ <div className={`rounded-md border px-3 py-2.5 ${surface}`}>
235
+ <div className="flex items-start gap-2">
236
+ <Icon className={`mt-0.5 h-4 w-4 shrink-0 ${iconTone}`} />
237
+ <div className="min-w-0">
238
+ <div className="text-xs font-medium text-theme-text-primary">{title}</div>
239
+ <p className="mt-0.5 max-h-20 overflow-auto break-words text-[11px] text-theme-text-secondary">{result.message}</p>
240
+ {(result.resource?.status || result.resource?.message) && (
241
+ <div className="mt-2 max-h-28 overflow-auto break-all font-mono text-[10px] text-theme-text-secondary">
242
+ {result.resource.status}{result.resource.status && result.resource.message ? ' — ' : ''}{result.resource.message}
243
+ </div>
244
+ )}
245
+ </div>
246
+ </div>
247
+ </div>
248
+ )
249
+ }
250
+
251
+ export function buildArgoSyncOpts({ resourceMode, revision, prune, dryRun, force, applyOnly, replace, serverSideApply }: {
252
+ resourceMode: boolean
253
+ revision: string
254
+ prune: boolean
255
+ dryRun: boolean
256
+ force: boolean
257
+ applyOnly: boolean
258
+ replace: boolean
259
+ serverSideApply: boolean
260
+ }): ArgoSyncOpts {
261
+ const syncOptions: string[] = []
262
+ if (replace) syncOptions.push('Replace=true')
263
+ if (serverSideApply) syncOptions.push('ServerSideApply=true')
264
+ return {
265
+ revision: resourceMode ? undefined : revision.trim() || undefined,
266
+ prune: resourceMode ? false : prune,
267
+ dryRun,
268
+ force,
269
+ applyOnly: resourceMode ? false : applyOnly,
270
+ syncOptions,
271
+ }
272
+ }
273
+
130
274
  function Toggle({ label, checked, onChange, disabled, hint }: { label: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; hint?: string }) {
131
275
  return (
132
276
  <label className="flex items-start gap-2">
@@ -0,0 +1,172 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { createTwoFilesPatch } from 'diff'
4
+ import { GitCompare, Maximize2, ShieldOff, X } from 'lucide-react'
5
+ import type { GitOpsResourceDiff } from '../../../types'
6
+ import { DiffLine, hasDiffBodyChange } from '../../shared/UnifiedDiff'
7
+
8
+ interface ArgoResourceDiffProps {
9
+ diff?: GitOpsResourceDiff | null
10
+ loading: boolean
11
+ // Server error string ({"error"} body), surfaced inline — not a toast.
12
+ error?: string | null
13
+ }
14
+
15
+ // ArgoResourceDiff renders the full Git-rendered desired-vs-live diff for a
16
+ // single Argo CD managed resource. Pure presentation: the host (web/) wires the
17
+ // fetch and passes data / loading / error. The inline view is a unified line
18
+ // diff; the maximize control opens the same content full-screen with side-by-
19
+ // side panes on wide viewports.
20
+ export function ArgoResourceDiff({ diff, loading, error }: ArgoResourceDiffProps) {
21
+ const [maximized, setMaximized] = useState(false)
22
+
23
+ if (loading) {
24
+ // An inline row expansion, not a full pane — a quiet skeleton of the diff's
25
+ // own shape reads as "content arriving here" without the page-level radar
26
+ // loader shouting for attention.
27
+ return (
28
+ <div className="rounded-md border border-theme-border bg-theme-base/50 p-3" aria-busy="true" aria-label="Loading diff">
29
+ <div className="mb-2 flex items-center gap-1.5 text-[11px] text-theme-text-tertiary">
30
+ <GitCompare className="h-3.5 w-3.5 shrink-0" />
31
+ <span>Loading diff…</span>
32
+ </div>
33
+ <div className="space-y-1.5">
34
+ {[92, 68, 81, 55, 74].map((w, i) => (
35
+ <div key={i} className="h-2.5 animate-pulse rounded bg-theme-hover" style={{ width: `${w}%` }} />
36
+ ))}
37
+ </div>
38
+ </div>
39
+ )
40
+ }
41
+ if (error) {
42
+ return (
43
+ <div className="rounded-md border border-red-500/40 bg-red-500/5 px-3 py-2 text-xs text-red-700 dark:text-red-400">
44
+ {error}
45
+ </div>
46
+ )
47
+ }
48
+ if (!diff) return null
49
+
50
+ const unchanged = !docsDiffer(diff.desired, diff.live)
51
+
52
+ return (
53
+ <div>
54
+ <div className="mb-2 flex items-center justify-between gap-2">
55
+ <div className="flex items-center gap-2">
56
+ <GitCompare className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
57
+ <span className="text-[11px] text-theme-text-tertiary">
58
+ desired (Git-rendered) <span className="mx-0.5">→</span> live (normalized)
59
+ </span>
60
+ {diff.redacted && <RedactedChip />}
61
+ </div>
62
+ <button
63
+ type="button"
64
+ onClick={() => setMaximized(true)}
65
+ className="flex shrink-0 items-center gap-1 rounded border border-theme-border bg-theme-base px-1.5 py-0.5 text-[10px] text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
66
+ >
67
+ <Maximize2 className="h-3 w-3" />
68
+ Expand
69
+ </button>
70
+ </div>
71
+ {unchanged ? (
72
+ <p className="text-[11px] text-theme-text-tertiary">No differences between the Git-rendered desired state and live cluster state.</p>
73
+ ) : (
74
+ <div className="max-h-80 overflow-auto rounded-md border border-theme-border bg-theme-base/50">
75
+ <UnifiedDiffBody desired={diff.desired} live={diff.live} />
76
+ </div>
77
+ )}
78
+
79
+ {maximized && <ArgoResourceDiffOverlay diff={diff} onClose={() => setMaximized(false)} />}
80
+ </div>
81
+ )
82
+ }
83
+
84
+ function RedactedChip() {
85
+ return (
86
+ <span className="inline-flex items-center gap-1 rounded border border-theme-border bg-theme-elevated px-1.5 py-0.5 text-[10px] text-theme-text-tertiary">
87
+ <ShieldOff className="h-3 w-3" />
88
+ Secret values masked
89
+ </span>
90
+ )
91
+ }
92
+
93
+ // Full-screen compare overlay. Side-by-side desired / live panes on wide
94
+ // viewports; unified diff when narrow. Portaled to body so it escapes the
95
+ // expanded row's overflow clipping.
96
+ function ArgoResourceDiffOverlay({ diff, onClose }: { diff: GitOpsResourceDiff; onClose: () => void }) {
97
+ useEffect(() => {
98
+ const onKey = (e: KeyboardEvent) => {
99
+ if (e.key === 'Escape') {
100
+ e.stopPropagation()
101
+ onClose()
102
+ }
103
+ }
104
+ document.addEventListener('keydown', onKey, true)
105
+ return () => document.removeEventListener('keydown', onKey, true)
106
+ }, [onClose])
107
+
108
+ const unchanged = !docsDiffer(diff.desired, diff.live)
109
+
110
+ return createPortal(
111
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
112
+ <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
113
+ <div className="dialog relative flex max-h-[90vh] w-full max-w-6xl flex-col">
114
+ <div className="flex items-center justify-between gap-2 border-b border-theme-border px-4 py-3">
115
+ <div className="flex min-w-0 items-center gap-2">
116
+ <GitCompare className="h-4 w-4 shrink-0 text-theme-text-secondary" />
117
+ <span className="truncate text-sm font-medium text-theme-text-primary">
118
+ desired (Git-rendered) <span className="mx-0.5 text-theme-text-tertiary">→</span> live (normalized)
119
+ </span>
120
+ {diff.redacted && <RedactedChip />}
121
+ </div>
122
+ <button
123
+ onClick={onClose}
124
+ className="flex shrink-0 items-center gap-1 rounded px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
125
+ >
126
+ <X className="h-3.5 w-3.5" />
127
+ Close
128
+ </button>
129
+ </div>
130
+ {unchanged ? (
131
+ <div className="p-6 text-sm text-theme-text-secondary">
132
+ No differences between the Git-rendered desired state and live cluster state.
133
+ </div>
134
+ ) : (
135
+ // Unified (not side-by-side) even full-screen: two unhighlighted
136
+ // panes make the reader hunt for the drifted line by eye across a
137
+ // whole manifest. The unified view marks every changed line — the
138
+ // same default Argo's own UI makes. An aligned split-diff with
139
+ // per-line highlighting can supersede this later.
140
+ <div className="min-h-0 flex-1 overflow-auto bg-theme-base/50">
141
+ <UnifiedDiffBody desired={diff.desired} live={diff.live} />
142
+ </div>
143
+ )}
144
+ </div>
145
+ </div>,
146
+ document.body,
147
+ )
148
+ }
149
+
150
+
151
+ // Renders a unified diff of two YAML documents using the `diff` package, via the
152
+ // shared DiffLine so this reads identically to the Helm manifest diff.
153
+ function UnifiedDiffBody({ desired, live }: { desired: string; live: string }) {
154
+ const patch = createTwoFilesPatch('desired', 'live', desired, live, '', '', { context: 3 })
155
+ const lines = patch.split('\n').filter((line) => !line.startsWith('===') && !line.startsWith('Index:'))
156
+ return (
157
+ <div className="p-3 font-mono text-[11px]">
158
+ {lines.map((line, index) => (
159
+ <DiffLine key={index} line={line} />
160
+ ))}
161
+ </div>
162
+ )
163
+ }
164
+
165
+ // True when the desired and live documents differ in a body line (ignoring the
166
+ // patch's own file headers). Cheaper than rendering for the "no differences"
167
+ // short-circuit.
168
+ function docsDiffer(desired: string, live: string): boolean {
169
+ if (desired === live) return false
170
+ const patch = createTwoFilesPatch('desired', 'live', desired, live, '', '', { context: 0 })
171
+ return hasDiffBodyChange(patch)
172
+ }