@skyhook-io/k8s-ui 1.8.10 → 1.8.12

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 (168) hide show
  1. package/package.json +7 -6
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +697 -0
  6. package/src/components/applications/ApplicationDetail.tsx +3269 -474
  7. package/src/components/applications/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +13 -6
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  21. package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
  22. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  23. package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
  24. package/src/components/gitops/insights/index.ts +1 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
  26. package/src/components/gitops/insights/insights-helpers.ts +74 -1
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
  28. package/src/components/issues/IssuesView.tsx +91 -52
  29. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
  30. package/src/components/issues/ResourceIssuesSection.tsx +46 -144
  31. package/src/components/issues/index.ts +3 -0
  32. package/src/components/issues/issue-timing.ts +53 -0
  33. package/src/components/issues/issues.test.ts +102 -1
  34. package/src/components/issues/severity.ts +3 -0
  35. package/src/components/logs/LogCore.tsx +14 -4
  36. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  37. package/src/components/logs/useLogStream.ts +8 -1
  38. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  39. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  40. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  41. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  42. package/src/components/resources/ResourcesView.tsx +233 -51
  43. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  44. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  54. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  55. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  56. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  57. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  58. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  59. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  60. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  61. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  62. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  63. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  64. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  65. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  66. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  67. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  68. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  69. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  70. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  71. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  72. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  73. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  75. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  76. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  77. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  78. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  79. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  80. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  81. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  82. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  83. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  84. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  85. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  86. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  87. package/src/components/resources/renderers/index.ts +1 -0
  88. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  89. package/src/components/resources/resource-utils-argo.ts +8 -0
  90. package/src/components/resources/resource-utils-capi.ts +1 -1
  91. package/src/components/resources/resource-utils.ts +31 -0
  92. package/src/components/shared/DetailShell.tsx +2 -2
  93. package/src/components/shared/ResourceActionsBar.tsx +142 -111
  94. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  95. package/src/components/shared/UnifiedDiff.tsx +51 -0
  96. package/src/components/shared/index.ts +1 -0
  97. package/src/components/timeline/TimelineList.tsx +375 -354
  98. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  99. package/src/components/timeline/TimelineStrip.tsx +836 -0
  100. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  101. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  102. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  103. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  104. package/src/components/timeline/TimelineToolbarState.test.tsx +88 -0
  105. package/src/components/timeline/index.ts +23 -0
  106. package/src/components/timeline/scrubber-math.test.ts +235 -0
  107. package/src/components/timeline/scrubber-math.ts +249 -0
  108. package/src/components/timeline/shared.test.tsx +219 -0
  109. package/src/components/timeline/shared.tsx +232 -447
  110. package/src/components/timeline/timeline-filters.test.ts +196 -0
  111. package/src/components/timeline/timeline-filters.ts +180 -0
  112. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  113. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  114. package/src/components/timeline/timeline-live.test.ts +131 -0
  115. package/src/components/timeline/timeline-live.ts +77 -0
  116. package/src/components/topology/GroupNode.tsx +9 -2
  117. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  118. package/src/components/topology/K8sResourceNode.tsx +387 -243
  119. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  120. package/src/components/topology/TopologyControls.tsx +3 -1
  121. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  122. package/src/components/topology/TopologyGraph.tsx +149 -90
  123. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  124. package/src/components/topology/TopologySearch.tsx +25 -17
  125. package/src/components/topology/index.ts +2 -0
  126. package/src/components/topology/layout.ts +10 -2
  127. package/src/components/topology/topology.css +0 -92
  128. package/src/components/ui/Badge.tsx +6 -1
  129. package/src/components/ui/CodeViewer.tsx +2 -2
  130. package/src/components/ui/Collapse.tsx +59 -0
  131. package/src/components/ui/ConfirmDialog.tsx +7 -3
  132. package/src/components/ui/DialogPortal.tsx +31 -9
  133. package/src/components/ui/Input.tsx +24 -0
  134. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  135. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  136. package/src/components/ui/PaneLoader.tsx +10 -2
  137. package/src/components/ui/ResourceBar.tsx +68 -25
  138. package/src/components/ui/SearchBox.tsx +18 -4
  139. package/src/components/ui/SearchPillInput.tsx +2 -2
  140. package/src/components/ui/SelectMenu.tsx +81 -0
  141. package/src/components/ui/drawer-components.tsx +48 -32
  142. package/src/components/ui/index.ts +6 -0
  143. package/src/components/ui/severity-tone.ts +11 -4
  144. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  145. package/src/components/workload/WorkloadView.tsx +2706 -475
  146. package/src/components/workload/index.ts +1 -1
  147. package/src/theme/tailwind-theme.css +1 -0
  148. package/src/theme/variables.css +2 -1
  149. package/src/types/core.ts +57 -4
  150. package/src/types/gitops-insights.ts +69 -1
  151. package/src/types/gitops.ts +3 -3
  152. package/src/utils/application-history.test.ts +538 -0
  153. package/src/utils/application-history.ts +257 -0
  154. package/src/utils/application-topology.test.ts +198 -0
  155. package/src/utils/application-topology.ts +261 -0
  156. package/src/utils/applications.test.ts +201 -1
  157. package/src/utils/applications.ts +1182 -393
  158. package/src/utils/index.ts +3 -0
  159. package/src/utils/navigation.test.ts +32 -1
  160. package/src/utils/navigation.ts +70 -0
  161. package/src/utils/resource-hierarchy.test.ts +1024 -1
  162. package/src/utils/resource-hierarchy.ts +925 -267
  163. package/src/utils/resource-icons.ts +2 -0
  164. package/src/utils/topology-neighborhood.test.ts +202 -1
  165. package/src/utils/topology-neighborhood.ts +194 -13
  166. package/src/utils/workflow-execution.test.ts +556 -0
  167. package/src/utils/workflow-execution.ts +814 -0
  168. package/src/utils/workload-colors.ts +2 -3
@@ -93,23 +93,32 @@ function parseInterval(interval: string): number | null {
93
93
  if (!interval) return null
94
94
 
95
95
  let totalSeconds = 0
96
- const regex = /(\d+)(h|m|s)/g
97
- let match
98
-
99
- while ((match = regex.exec(interval)) !== null) {
100
- const value = parseInt(match[1], 10)
101
- const unit = match[2]
102
-
103
- switch (unit) {
104
- case 'h':
105
- totalSeconds += value * 3600
106
- break
107
- case 'm':
108
- totalSeconds += value * 60
109
- break
110
- case 's':
111
- totalSeconds += value
112
- break
96
+ let index = 0
97
+
98
+ while (index < interval.length) {
99
+ const start = index
100
+ while (index < interval.length) {
101
+ const code = interval.charCodeAt(index)
102
+ if (code < 48 || code > 57) break
103
+ index += 1
104
+ }
105
+ if (start === index) {
106
+ index += 1
107
+ continue
108
+ }
109
+ const value = parseInt(interval.slice(start, index), 10)
110
+ const unit = interval[index]
111
+ if (unit === 'h') {
112
+ totalSeconds += value * 3600
113
+ index += 1
114
+ } else if (unit === 'm') {
115
+ totalSeconds += value * 60
116
+ index += 1
117
+ } else if (unit === 's') {
118
+ totalSeconds += value
119
+ index += 1
120
+ } else if (index < interval.length) {
121
+ index += 1
113
122
  }
114
123
  }
115
124
 
@@ -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,7 +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
+ import { Input } from '../ui/Input'
6
+ import { Tooltip } from '../ui/Tooltip'
7
+ import type { GitOpsInsightRef } from '../../types/gitops-insights'
5
8
 
6
9
  // =============================================================================
7
10
  // SyncOptionsDialog — Argo CD Sync drawer, shared between per-cluster
@@ -10,9 +13,9 @@ import { DialogPortal } from '../ui/DialogPortal'
10
13
  // the onConfirm callback (POST to /api/argo/applications/{ns}/{name}/sync
11
14
  // or the hub-proxied equivalent /c/{ctrl}/api/argo/...).
12
15
  //
13
- // Defaults match Argo's most-common path (prune true, no dry-run, no
14
- // force) so the two-click sync stays cheap. Revision is optional — empty
15
- // 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.
16
19
  // =============================================================================
17
20
 
18
21
  // ArgoSyncOpts is the payload shape the dialog passes to its onConfirm
@@ -32,12 +35,29 @@ export interface ArgoSyncOpts {
32
35
  export interface SyncOptionsDialogProps {
33
36
  open: boolean
34
37
  appLabel: string
38
+ resource?: GitOpsInsightRef
35
39
  pending?: boolean
40
+ autoSyncEnabled?: boolean
41
+ validationPending?: boolean
42
+ operationInProgress?: boolean
43
+ validationResult?: ResourceValidationResult | null
44
+ validationError?: string | null
36
45
  onCancel: () => void
37
46
  onConfirm: (opts: ArgoSyncOpts) => void
47
+ onValidate?: (opts: ArgoSyncOpts) => void
48
+ onValidationReset?: () => void
38
49
  }
39
50
 
40
- 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) {
41
61
  const [revision, setRevision] = useState('')
42
62
  const [prune, setPrune] = useState(true)
43
63
  const [dryRun, setDryRun] = useState(false)
@@ -45,88 +65,212 @@ export function SyncOptionsDialog({ open, appLabel, pending, onCancel, onConfirm
45
65
  const [applyOnly, setApplyOnly] = useState(false)
46
66
  const [replace, setReplace] = useState(false)
47
67
  const [serverSideApply, setServerSideApply] = useState(false)
68
+ const richResourceValidation = !!resource && !!onValidate
69
+ const busy = !!pending || !!validationPending
70
+ const optionsDisabled = busy || !!operationInProgress
48
71
 
49
72
  // Reset on each open so a previous attempt's flags don't leak into the
50
73
  // next sync — easy footgun in modal-heavy flows.
51
74
  useEffect(() => {
52
75
  if (open) {
53
76
  setRevision('')
54
- setPrune(true)
77
+ setPrune(!resource)
55
78
  setDryRun(false)
56
79
  setForce(false)
57
80
  setApplyOnly(false)
58
81
  setReplace(false)
59
82
  setServerSideApply(false)
60
83
  }
61
- }, [open])
84
+ }, [open, resource])
62
85
 
63
86
  function submit() {
64
- const syncOptions: string[] = []
65
- if (replace) syncOptions.push('Replace=true')
66
- if (serverSideApply) syncOptions.push('ServerSideApply=true')
67
- onConfirm({
68
- revision: revision.trim() || undefined,
69
- prune,
70
- dryRun,
71
- force,
72
- applyOnly,
73
- syncOptions,
74
- })
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?.()
75
97
  }
76
98
 
77
99
  return (
78
- <DialogPortal open={open} onClose={pending ? () => {} : onCancel} className="w-[480px]" closable={!pending}>
100
+ <DialogPortal open={open} onClose={busy ? () => {} : onCancel} className="w-[480px]" closable={!busy}>
79
101
  <div className="border-b border-theme-border px-4 py-3">
80
- <h2 className="text-sm font-semibold text-theme-text-primary">Sync application</h2>
81
- <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>
82
104
  </div>
83
105
  <div className="space-y-4 px-4 py-4 text-sm">
84
- <label className="block">
85
- <span className="text-xs font-medium text-theme-text-secondary">Revision (optional)</span>
86
- <input
87
- type="text"
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
+ }