@skyhook-io/k8s-ui 1.18.2 → 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 (152) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/charts/AreaChart.test.tsx +39 -1
  4. package/src/components/charts/AreaChart.tsx +32 -13
  5. package/src/components/charts/axis.test.ts +17 -1
  6. package/src/components/charts/axis.ts +13 -0
  7. package/src/components/charts/format.test.ts +159 -2
  8. package/src/components/charts/format.ts +118 -0
  9. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  11. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  12. package/src/components/compare/ResourceCompareView.tsx +35 -12
  13. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  14. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  15. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  17. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  18. package/src/components/gitops/RollbackDialog.tsx +25 -6
  19. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  21. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  22. package/src/components/gitops/applicationset-row.test.ts +12 -0
  23. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  24. package/src/components/gitops/detail-helpers.test.ts +8 -2
  25. package/src/components/gitops/detail-helpers.ts +9 -11
  26. package/src/components/gitops/index.ts +1 -1
  27. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  28. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  29. package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
  30. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  31. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  32. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  33. package/src/components/gitops/insights/insights-helpers.test.ts +65 -0
  34. package/src/components/gitops/insights/insights-helpers.ts +17 -3
  35. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  36. package/src/components/issues/IssueGuidance.tsx +48 -0
  37. package/src/components/issues/IssuesView.tsx +15 -3
  38. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  39. package/src/components/issues/diagnostic.ts +14 -1
  40. package/src/components/issues/index.ts +1 -0
  41. package/src/components/issues/severity.ts +15 -0
  42. package/src/components/issues/types.ts +10 -0
  43. package/src/components/kueue/AdmissionPath.tsx +223 -0
  44. package/src/components/kueue/QueueEvidence.tsx +148 -0
  45. package/src/components/kueue/constants.ts +1 -0
  46. package/src/components/kueue/eligibility.ts +43 -0
  47. package/src/components/kueue/flavors-tried.ts +79 -0
  48. package/src/components/kueue/helpers.test.ts +105 -0
  49. package/src/components/kueue/policy.ts +100 -0
  50. package/src/components/kueue/queue-detail.test.tsx +317 -0
  51. package/src/components/kueue/queue-status.ts +42 -0
  52. package/src/components/kueue/related-read.ts +48 -0
  53. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  54. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  55. package/src/components/logs/StructuredLogLine.tsx +8 -3
  56. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  57. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  58. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  59. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  60. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  61. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  62. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  63. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  64. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  65. package/src/components/resources/ResourcesView.tsx +50 -3
  66. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  67. package/src/components/resources/generic-status.ts +7 -0
  68. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  69. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  70. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  71. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  72. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  73. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  74. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  75. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  76. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  77. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  78. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  79. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  80. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  81. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  82. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  83. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  84. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  85. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  86. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  87. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  88. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  89. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  90. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  91. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  92. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  93. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  94. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  95. package/src/components/resources/renderers/index.ts +6 -0
  96. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  97. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  98. package/src/components/resources/resource-utils-argo.ts +84 -5
  99. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  100. package/src/components/resources/resource-utils-crd.ts +26 -0
  101. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  102. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  103. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  104. package/src/components/resources/resource-utils-kueue.ts +137 -4
  105. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  106. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  107. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  108. package/src/components/resources/resource-utils-ray.ts +38 -0
  109. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  110. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  111. package/src/components/resources/resource-utils.ts +45 -1
  112. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  113. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  114. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  115. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  116. package/src/components/topology/K8sResourceNode.tsx +18 -1
  117. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  118. package/src/components/topology/TopologyGraph.tsx +96 -94
  119. package/src/components/topology/layout.ts +3 -1
  120. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  121. package/src/components/ui/Badge.tsx +1 -0
  122. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  123. package/src/components/ui/drawer-components.tsx +1 -1
  124. package/src/components/ui/index.ts +1 -0
  125. package/src/components/workload/PodList.tsx +35 -9
  126. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  127. package/src/components/workload/WorkloadView.tsx +61 -8
  128. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  129. package/src/types/core.ts +41 -0
  130. package/src/types/gitops-insights.ts +19 -2
  131. package/src/types/gitops-tree.ts +2 -2
  132. package/src/types/kueue-queues.ts +84 -0
  133. package/src/types/read-coverage.ts +2 -1
  134. package/src/types/scheduling.ts +72 -0
  135. package/src/utils/animation.test.ts +38 -0
  136. package/src/utils/animation.ts +34 -0
  137. package/src/utils/api-resources-crd.test.ts +11 -0
  138. package/src/utils/api-resources.ts +5 -1
  139. package/src/utils/applications.test.ts +15 -1
  140. package/src/utils/applications.ts +26 -1
  141. package/src/utils/crd-schema.test.ts +107 -0
  142. package/src/utils/crd-schema.ts +201 -0
  143. package/src/utils/format.test.ts +37 -1
  144. package/src/utils/format.ts +28 -80
  145. package/src/utils/gitops-owner.test.ts +28 -2
  146. package/src/utils/gitops-owner.ts +19 -0
  147. package/src/utils/kueue-queue-workloads.ts +19 -0
  148. package/src/utils/resource-icons.ts +2 -0
  149. package/src/utils/topology-neighborhood.test.ts +90 -1
  150. package/src/utils/topology-neighborhood.ts +55 -3
  151. package/src/utils/workload-history-notes.test.ts +76 -0
  152. package/src/utils/workload-history-notes.ts +93 -0
@@ -0,0 +1,54 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { ResourceCompareView, type ResourceCompareViewProps } from './ResourceCompareView'
5
+
6
+ vi.mock('../ui/YamlEditor', () => ({ YamlDiffEditor: () => null }))
7
+
8
+ const checkout = { kind: 'Deployment', namespace: 'shop', name: 'checkout' }
9
+
10
+ function header(props: Partial<ResourceCompareViewProps>) {
11
+ const html = renderToStaticMarkup(
12
+ <ResourceCompareView a={checkout} b={checkout} aData={{}} bData={{}} onSwap={() => {}} onClose={() => {}} {...props} />,
13
+ )
14
+ // Tags split the name from its namespace, so compare the text a reader sees.
15
+ const host = document.createElement('div')
16
+ host.innerHTML = html
17
+ return host.textContent ?? ''
18
+ }
19
+
20
+ describe('ResourceCompareView sides', () => {
21
+ it('names the resource once and tells the sides apart by where they run', () => {
22
+ const text = header({ aSubtitle: 'payments-eu', bSubtitle: 'payments-us' })
23
+ expect(text.split('shop/checkout')).toHaveLength(2)
24
+ expect(text).toContain('payments-eu')
25
+ expect(text).toContain('payments-us')
26
+ })
27
+
28
+ it('keeps a cut-off location readable on hover', () => {
29
+ const html = renderToStaticMarkup(
30
+ <ResourceCompareView a={checkout} b={checkout} aData={{}} bData={{}} onSwap={() => {}} onClose={() => {}} aSubtitle="payments-eu" bSubtitle="payments-us" />,
31
+ )
32
+ expect(html).toContain('title="payments-eu"')
33
+ expect(html).toContain('title="payments-us"')
34
+ })
35
+
36
+ it('names both sides when they are different resources', () => {
37
+ const text = header({ b: { ...checkout, name: 'billing' }, aSubtitle: 'payments-eu', bSubtitle: 'payments-eu' })
38
+ expect(text).toContain('shop/checkout')
39
+ expect(text).toContain('shop/billing')
40
+ })
41
+
42
+ it('keeps names when the same kind and name come from different API groups', () => {
43
+ const text = header({ a: { ...checkout, group: 'apps' }, b: { ...checkout, group: 'example.com' }, aSubtitle: 'eu', bSubtitle: 'us' })
44
+ expect(text.split('shop/checkout')).toHaveLength(3)
45
+ })
46
+
47
+ it('keeps names when both sides come from the same place', () => {
48
+ expect(header({ aSubtitle: 'payments-eu', bSubtitle: 'payments-eu' }).split('shop/checkout')).toHaveLength(3)
49
+ })
50
+
51
+ it('keeps names with no location to show', () => {
52
+ expect(header({}).split('shop/checkout')).toHaveLength(3)
53
+ })
54
+ })
@@ -59,12 +59,15 @@ function ResourcePill({
59
59
  error,
60
60
  onChange,
61
61
  subtitle,
62
+ subtitleOnly,
62
63
  }: {
63
64
  resource: CompareResourceRef
64
65
  side: CompareSide
65
66
  error?: string
66
67
  onChange?: () => void
67
68
  subtitle?: string
69
+ /** Both sides are the same resource, so where it runs is all that tells them apart. */
70
+ subtitleOnly?: boolean
68
71
  }) {
69
72
  const tones = SIDE_TONES[side]
70
73
  const errTone = 'border-red-400/50 bg-red-500/10'
@@ -82,13 +85,19 @@ function ResourcePill({
82
85
  {label}
83
86
  </span>
84
87
  {error && <AlertTriangle className="w-3 h-3 text-red-400 shrink-0" aria-hidden />}
85
- <span className={clsx('truncate min-w-0', error ? 'text-red-300' : 'text-theme-text-primary')}>
86
- {resource.namespace && <span className="opacity-60">{resource.namespace}/</span>}
87
- {resource.name}
88
- {subtitle && (
89
- <span className="opacity-60 ml-1" aria-label={`Source: ${subtitle}`}>
90
- · {subtitle}
91
- </span>
88
+ <span className={clsx('truncate min-w-0', error ? 'text-red-300' : 'text-theme-text-primary')} title={subtitleOnly ? subtitle : undefined}>
89
+ {subtitleOnly ? (
90
+ subtitle
91
+ ) : (
92
+ <>
93
+ {resource.namespace && <span className="opacity-60">{resource.namespace}/</span>}
94
+ {resource.name}
95
+ {subtitle && (
96
+ <span className="opacity-60 ml-1" aria-label={`Source: ${subtitle}`}>
97
+ · {subtitle}
98
+ </span>
99
+ )}
100
+ </>
92
101
  )}
93
102
  </span>
94
103
  {onChange && (
@@ -144,6 +153,10 @@ export function ResourceCompareView({
144
153
  // blank the editor. When the A/B identity changes (navigating to a
145
154
  // new pair on the same route), the latch resets so the loader shows
146
155
  // again until the new fetches settle.
156
+ // The same resource from two places: say what it is once, up top, so the pills
157
+ // can show the one thing that differs. Long names no longer push that out of view.
158
+ const sameResource = a.kind === b.kind && a.group === b.group && a.namespace === b.namespace && a.name === b.name &&
159
+ Boolean(aSubtitle && bSubtitle) && aSubtitle !== bSubtitle
147
160
  const settleKey = `${a.kind}|${a.namespace}|${a.name}|${a.group ?? ''}|${aSubtitle ?? ''}` +
148
161
  `|${b.kind}|${b.namespace}|${b.name}|${b.group ?? ''}|${bSubtitle ?? ''}`
149
162
  const settleKeyRef = useRef(settleKey)
@@ -160,15 +173,25 @@ export function ResourceCompareView({
160
173
  <div className="flex-1 min-w-0 flex flex-col h-full bg-theme-base">
161
174
  <div className="h-0.5 w-full bg-gradient-to-r from-blue-400/70 via-skyhook-400/40 to-emerald-400/70" />
162
175
 
163
- <div className="flex items-center gap-3 px-4 py-2.5 border-b border-theme-border bg-theme-surface">
176
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 border-b border-theme-border bg-theme-surface">
164
177
  <GitCompare className="w-5 h-5 text-skyhook-400 shrink-0" />
165
- <h2 className="text-sm font-semibold text-theme-text-primary shrink-0 whitespace-nowrap">
178
+ <h2 className={clsx('text-sm font-semibold text-theme-text-primary whitespace-nowrap', sameResource ? 'min-w-0 max-w-[30%] truncate' : 'shrink-0')}>
166
179
  Compare <span className="text-theme-text-tertiary mx-0.5">·</span>{' '}
167
180
  <span className="text-theme-text-secondary font-medium">{kindLabel}</span>
181
+ {sameResource && (
182
+ <>
183
+ <span className="text-theme-text-tertiary mx-0.5">·</span>{' '}
184
+ <span className="text-theme-text-secondary font-medium" title={`${a.namespace}/${a.name}`}>
185
+ {a.namespace && <span className="opacity-60">{a.namespace}/</span>}
186
+ {a.name}
187
+ </span>
188
+ </>
189
+ )}
168
190
  </h2>
169
191
 
170
- <div className="flex items-center gap-2 min-w-0 flex-1">
171
- <ResourcePill resource={a} side="a" error={aError} onChange={onChangeA} subtitle={aSubtitle} />
192
+ {/* The row wraps before the sides get too narrow to tell apart. */}
193
+ <div className="flex items-center gap-2 min-w-72 flex-1">
194
+ <ResourcePill resource={a} side="a" error={aError} onChange={onChangeA} subtitle={aSubtitle} subtitleOnly={sameResource} />
172
195
  <Tooltip content="Swap A and B">
173
196
  <button
174
197
  onClick={onSwap}
@@ -177,7 +200,7 @@ export function ResourceCompareView({
177
200
  <ArrowLeftRight className="w-3.5 h-3.5" />
178
201
  </button>
179
202
  </Tooltip>
180
- <ResourcePill resource={b} side="b" error={bError} onChange={onChangeB} subtitle={bSubtitle} />
203
+ <ResourcePill resource={b} side="b" error={bError} onChange={onChangeB} subtitle={bSubtitle} subtitleOnly={sameResource} />
181
204
  </div>
182
205
 
183
206
  <div className="flex items-center gap-1 shrink-0">
@@ -22,6 +22,21 @@ const shell: Omit<GitOpsDetailLayoutProps, 'identity' | 'isArgoApp'> = {
22
22
  }
23
23
 
24
24
  describe('GitOpsDetailLayout', () => {
25
+ it.each([false, true])('shows tree limits without insights, including fullscreen=%s', (fullscreen) => {
26
+ const warning = 'Some generated resources could not be checked; the resource tree may be incomplete.'
27
+ const html = renderToString(
28
+ <GitOpsDetailLayout
29
+ {...shell}
30
+ identity={{ kind: 'applications', group: 'argoproj.io', namespace: 'shop', name: 'wordpress', kindLabel: 'Application' }}
31
+ isArgoApp
32
+ fullscreen={fullscreen}
33
+ warnings={[warning, warning]}
34
+ />,
35
+ )
36
+ expect(html).toContain('Limited resource view')
37
+ expect(html.split(warning)).toHaveLength(2)
38
+ })
39
+
25
40
  it('shows the tool chip, facts and Argo operations for an Argo CD Application', () => {
26
41
  const html = renderToString(
27
42
  <GitOpsDetailLayout
@@ -39,6 +54,7 @@ describe('GitOpsDetailLayout', () => {
39
54
  expect(html).toContain('ArgoCD')
40
55
  expect(html).toContain('Project')
41
56
  expect(html).toContain('Sync…')
57
+ expect(html).not.toContain('Limited resource view')
42
58
  })
43
59
 
44
60
  // A root that is not a GitOps resource gets one state: its name and why
@@ -2,12 +2,28 @@ import { useEffect, type ComponentType, type ReactNode } from 'react'
2
2
  import { ArrowDownUp, Clock3, GitBranch, GitCommit, Loader2, Pause, Play, RefreshCw, Settings, Trash2, XCircle, Zap } from 'lucide-react'
3
3
  import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
4
4
  import { PaneLoader } from '../ui/PaneLoader'
5
+ import { AlertBanner } from '../ui/drawer-components'
5
6
 
6
7
  import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
7
8
  import { GitOpsIssuesBand, GitOpsStatusStrip } from './insights'
8
9
  import { GitOpsHealthSourceNotice } from './GitOpsHealthSourceNotice'
9
10
  import { Tooltip } from '../ui/Tooltip'
11
+ import { RadarUpgradeNote, radarUpgradeNoteText } from '../ui/RadarUpgradeNote'
10
12
  import type { GitOpsHealthStatus, GitOpsInsight, GitOpsIssue, GitOpsRemediation, SyncStatus } from '../../types'
13
+ import type { RadarUpgradeRequirement } from '../../types/fetch-error'
14
+
15
+ function argoAutoSyncControllerNote(controller: ArgoActionHandlers['autoSyncController']): string {
16
+ if (!controller) return ''
17
+ return controller.kind === 'ApplicationSet'
18
+ ? ` ApplicationSet ${controller.name} can turn it back on; to keep it off, change the ApplicationSet's template.`
19
+ : ` Application ${controller.name} manages this one from Git and can turn it back on when it syncs; to keep it off, change it in Git.`
20
+ }
21
+
22
+ function argoResumeDescription(suspended: boolean, settings: ArgoActionHandlers['resumeSettings']): string {
23
+ if (suspended) return 'Turn auto-sync back on with its current or saved settings.'
24
+ if (!settings) return 'Turn on automated sync, so Argo applies new commits from Git.'
25
+ return `Argo applies new commits${settings.selfHeal ? ' and reverts live edits (self-heal)' : ''}. Resources removed from Git are ${settings.prune ? 'deleted (prune on)' : 'kept (prune off)'}.`
26
+ }
11
27
 
12
28
  // ---------------------------------------------------------------------------
13
29
  // Props
@@ -76,11 +92,22 @@ export interface ArgoActionHandlers {
76
92
  terminating: boolean
77
93
  suspending: boolean
78
94
  resuming: boolean
79
- // autoSyncEnabled: caller derives from spec.syncPolicy.automated.
95
+ // autoSyncEnabled: caller derives via isArgoAutoSyncEnabled.
80
96
  autoSyncEnabled: boolean
81
97
  // isRunning: caller derives from status.operationState.phase === 'Running'.
82
98
  isRunning: boolean
83
99
  operationInProgress?: boolean
100
+ // Set when the connected Radar is too old to turn this app's auto-sync back
101
+ // on (isArgoAutoSyncSwitchedOff): Enable auto-sync renders disabled, with
102
+ // the upgrade note under the header.
103
+ resumeUpgrade?: RadarUpgradeRequirement | null
104
+ // What Enable auto-sync turns on when Radar didn't suspend the app
105
+ // (argoResumeSettings). Without it the tooltip makes no prune claim.
106
+ resumeSettings?: { prune: boolean; selfHeal: boolean } | null
107
+ resumeBlockedReason?: string
108
+ // The Argo CD object that can turn auto-sync back on after Disable
109
+ // (argoAutoSyncController); its tooltip names it.
110
+ autoSyncController?: { kind: 'ApplicationSet' | 'Application'; name: string } | null
84
111
  }
85
112
 
86
113
  export interface FluxActionHandlers {
@@ -125,6 +152,8 @@ export interface GitOpsDetailLayoutProps {
125
152
  // Insights — drives StatusStrip + IssuesBand
126
153
  insight: GitOpsInsight | null
127
154
  insightLoading: boolean
155
+ // Tree warnings are independent of the insights request's success.
156
+ warnings?: string[]
128
157
  // Issue interactions — caller wires onSelectIssue to jump to Changes tab
129
158
  // and highlight the affected resource.
130
159
  onSelectIssue?: (issue: GitOpsIssue) => void
@@ -229,6 +258,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
229
258
  detail,
230
259
  insight,
231
260
  insightLoading,
261
+ warnings,
232
262
  onSelectIssue,
233
263
  onRemediate,
234
264
  remediationPending,
@@ -280,6 +310,9 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
280
310
 
281
311
  const effectiveSuspended = status?.suspended ?? false
282
312
  const argoOperationInProgress = argo?.operationInProgress ?? argo?.isRunning ?? false
313
+ const argoSyncDisabled = effectiveSuspended || terminating || argoOperationInProgress
314
+ const resumeUpgrade = isArgoApp && argo && !argo.autoSyncEnabled ? argo.resumeUpgrade ?? null : null
315
+ const resumeBlockedReason = isArgoApp && argo && !argo.autoSyncEnabled ? argo.resumeBlockedReason : undefined
283
316
  // graphFullscreen hides everything chrome-side; the body region expands.
284
317
  // Mirrors the OSS GitOpsDetailView shell behavior so the visual chrome
285
318
  // class set carries through.
@@ -365,6 +398,8 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
365
398
  {detail.autoSyncMode && <AppFact label="Sync mode" value={detail.autoSyncMode} />}
366
399
  </div>
367
400
  )}
401
+ {resumeBlockedReason && <p className="mt-2 text-xs text-theme-text-secondary">{resumeBlockedReason}</p>}
402
+ {resumeUpgrade && <RadarUpgradeNote requirement={resumeUpgrade} className="mt-2" />}
368
403
  </div>
369
404
  {/* Action buttons */}
370
405
  <div className="flex flex-wrap items-center gap-2">
@@ -376,7 +411,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
376
411
  icon={ArrowDownUp}
377
412
  loading={argo.syncing}
378
413
  onClick={argo.onSyncRequested}
379
- disabled={effectiveSuspended || terminating || argoOperationInProgress}
414
+ disabled={argoSyncDisabled}
380
415
  disabledReason={terminating
381
416
  ? terminatingActionTooltip
382
417
  : argoOperationInProgress
@@ -411,7 +446,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
411
446
  {argo.autoSyncEnabled ? (
412
447
  <ActionButton
413
448
  label="Disable auto-sync"
414
- description="Stop Argo from automatically syncing Git changes. Manual Sync still works."
449
+ description={`Stop Argo from automatically syncing Git changes.${argoSyncDisabled ? '' : ' Manual Sync still works.'}${argoAutoSyncControllerNote(argo.autoSyncController)}`}
415
450
  icon={Pause}
416
451
  loading={argo.suspending}
417
452
  onClick={argo.onSuspend}
@@ -421,12 +456,16 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
421
456
  ) : (
422
457
  <ActionButton
423
458
  label="Enable auto-sync"
424
- description="Re-enable automatic syncing of Git changes to the cluster."
459
+ description={argoResumeDescription(effectiveSuspended, argo.resumeSettings)}
425
460
  icon={Play}
426
461
  loading={argo.resuming}
427
- onClick={argo.onResume}
428
- disabled={terminating}
429
- disabledReason={terminating ? terminatingActionTooltip : undefined}
462
+ onClick={() => !resumeBlockedReason && !resumeUpgrade && argo.onResume()}
463
+ disabled={terminating || !!resumeUpgrade || !!resumeBlockedReason}
464
+ disabledReason={terminating
465
+ ? terminatingActionTooltip
466
+ : resumeBlockedReason || (resumeUpgrade
467
+ ? radarUpgradeNoteText(resumeUpgrade)
468
+ : undefined)}
430
469
  />
431
470
  )}
432
471
  </>
@@ -528,6 +567,14 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
528
567
  <div className="p-4 text-sm text-red-500">Failed to load GitOps view: {resourceError.message}</div>
529
568
  ) : (
530
569
  <div className={bodyShellClass}>
570
+ {((warnings?.length ?? 0) > 0 || (insight?.warnings?.length ?? 0) > 0) && (
571
+ <AlertBanner
572
+ variant="warning"
573
+ title="Limited resource view"
574
+ items={[...new Set([...(warnings ?? []), ...(insight?.warnings ?? [])])]}
575
+ className="shrink-0 rounded-none border-x-0 border-t-0"
576
+ />
577
+ )}
531
578
  <div className="flex shrink-0 items-center justify-between gap-3 border-b border-theme-border bg-theme-base px-4 py-2">
532
579
  <div className="flex items-center gap-1 rounded-md border border-theme-border bg-theme-surface p-1">
533
580
  <ViewButton active={activeTab === 'topology'} icon={GitBranch} label="Topology" onClick={() => onTabChange('topology')} />
@@ -38,7 +38,7 @@ export const APP_TREE_API_ERROR_FINDINGS = 'Meanwhile, rows marked "Found by Rad
38
38
  export const APP_TREE_API_ERROR_NO_FINDINGS = "Radar didn't find a problem on its own."
39
39
 
40
40
  export const REMOTE_DESTINATION_NOTICE =
41
- "This application deploys to a different cluster, so its resources aren't visible from here."
41
+ "The recorded resources aren't confirmed to belong to this cluster, so local health and generated resources aren't shown."
42
42
  export const REMOTE_FLUX_TARGET_NOTICE =
43
43
  "This applies its resources to a different cluster, so they aren't visible from here."
44
44
 
@@ -34,9 +34,11 @@ import { FacetSection, FacetButton } from '../ui/Facet'
34
34
  import { SortableTh, TH_CLASS, type SortDir } from '../ui/SortableTh'
35
35
  import { DistributionBar } from '../ui/DistributionBar'
36
36
  import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
37
+ import { radarUpgradeNoteText } from '../ui/RadarUpgradeNote'
38
+ import type { RadarUpgradeRequirement } from '../../types/fetch-error'
37
39
  import { BoardTableSkeleton, BoardRailSkeleton } from '../ui/BoardSkeleton'
38
40
  import { getGitOpsResourceStatus } from './detail-helpers'
39
- import { isArgoOperationInProgress, isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
41
+ import { isArgoAutoSyncEnabled, isArgoAutoSyncSwitchedOff, isArgoOperationInProgress, isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
40
42
  import { toggleSet } from './GitOpsGraphFilterRail'
41
43
  import { useFilterState, defineFilterSchema } from '../../filter-state'
42
44
  import { parseContextName } from '../../utils/context-name'
@@ -288,6 +290,10 @@ export interface GitOpsTableViewProps {
288
290
  // In-flight action state, keyed by `row.id`. Drives the per-item
289
291
  // spinner so the user can tell which Sync/Refresh is still running.
290
292
  pendingRowActions?: Map<string, Set<GitOpsRowAction>>
293
+ // Set when the connected Radar is too old to turn auto-sync back on for an
294
+ // Application with automated.enabled: false; its Resume renders disabled.
295
+ argoResumeUpgrade?: RadarUpgradeRequirement | null
296
+ argoResumeBlockedReason?: string
291
297
  }
292
298
 
293
299
  // ----- Main component --------------------------------------------------------
@@ -330,6 +336,8 @@ export function GitOpsTableView({
330
336
  onClearNamespaces,
331
337
  onRowAction,
332
338
  pendingRowActions,
339
+ argoResumeUpgrade,
340
+ argoResumeBlockedReason,
333
341
  filtersSide = 'left',
334
342
  }: GitOpsTableViewProps) {
335
343
  const searchInputRef = useRef<HTMLInputElement>(null)
@@ -858,6 +866,8 @@ export function GitOpsTableView({
858
866
  destinationHrefFor={destinationHrefFor}
859
867
  onRowAction={onRowAction}
860
868
  pendingRowActions={pendingRowActions}
869
+ argoResumeUpgrade={argoResumeUpgrade}
870
+ argoResumeBlockedReason={argoResumeBlockedReason}
861
871
  />
862
872
  )}
863
873
  </div>
@@ -1292,6 +1302,8 @@ function GitOpsTable({
1292
1302
  destinationHrefFor,
1293
1303
  onRowAction,
1294
1304
  pendingRowActions,
1305
+ argoResumeUpgrade,
1306
+ argoResumeBlockedReason,
1295
1307
  }: {
1296
1308
  rows: GitOpsRow[]
1297
1309
  sort: { key: SortKey; dir: SortDir } | null
@@ -1303,6 +1315,8 @@ function GitOpsTable({
1303
1315
  destinationHrefFor?: (row: GitOpsRow, destination: FleetDestinationStamp) => string
1304
1316
  onRowAction?: (row: GitOpsRow, action: GitOpsRowAction) => void
1305
1317
  pendingRowActions?: Map<string, Set<GitOpsRowAction>>
1318
+ argoResumeUpgrade?: RadarUpgradeRequirement | null
1319
+ argoResumeBlockedReason?: string
1306
1320
  }) {
1307
1321
  const showActions = !!onRowAction
1308
1322
  return (
@@ -1409,7 +1423,7 @@ function GitOpsTable({
1409
1423
  className="overflow-visible border-b border-theme-border px-2 py-2 text-right align-middle"
1410
1424
  onClick={(e) => e.stopPropagation()}
1411
1425
  >
1412
- <RowActionMenu items={buildRowActionItems(row, onRowAction, pendingRowActions)} />
1426
+ <RowActionMenu items={buildRowActionItems(row, onRowAction, pendingRowActions, argoResumeUpgrade, argoResumeBlockedReason)} />
1413
1427
  </td>
1414
1428
  )}
1415
1429
  </tr>
@@ -1424,10 +1438,12 @@ function GitOpsTable({
1424
1438
  // the row's tool (Argo vs Flux), current suspend state, terminating state,
1425
1439
  // and Argo's operationState.phase (used to gate the Terminate entry — only
1426
1440
  // shown while a sync is mid-flight, mirroring the detail-page condition).
1427
- function buildRowActionItems(
1441
+ export function buildRowActionItems(
1428
1442
  row: GitOpsRow,
1429
1443
  onAction: (row: GitOpsRow, action: GitOpsRowAction) => void,
1430
1444
  pending?: Map<string, Set<GitOpsRowAction>>,
1445
+ argoResumeUpgrade?: RadarUpgradeRequirement | null,
1446
+ argoResumeBlockedReason?: string,
1431
1447
  ): RowActionItem[] {
1432
1448
  const inFlight = pending?.get(row.id)
1433
1449
  const isPending = (action: GitOpsRowAction) => inFlight?.has(action) ?? false
@@ -1480,13 +1496,16 @@ function buildRowActionItems(
1480
1496
  pending: isPending('hard-refresh'),
1481
1497
  })
1482
1498
  if (suspended) {
1499
+ const resumeBlockedReason = isArgoAutoSyncSwitchedOff(row.raw)
1500
+ ? argoResumeBlockedReason || (argoResumeUpgrade ? radarUpgradeNoteText(argoResumeUpgrade) : undefined)
1501
+ : undefined
1483
1502
  items.push({
1484
1503
  key: 'resume',
1485
1504
  label: 'Resume',
1486
1505
  icon: Play,
1487
- onClick: () => onAction(row, 'resume'),
1488
- disabled: terminating,
1489
- disabledReason: terminating ? terminatingReason : undefined,
1506
+ onClick: () => !resumeBlockedReason && onAction(row, 'resume'),
1507
+ disabled: terminating || !!resumeBlockedReason,
1508
+ disabledReason: terminating ? terminatingReason : resumeBlockedReason,
1490
1509
  pending: isPending('resume'),
1491
1510
  divider: true,
1492
1511
  })
@@ -2007,7 +2026,7 @@ export function normalizeArgoApplication(resource: any): GitOpsRow {
2007
2026
  destinationNamespace: resource.spec?.destination?.namespace ?? '',
2008
2027
  createdAt: resource.metadata?.creationTimestamp ?? '',
2009
2028
  lastSync: argoLastSync ?? '',
2010
- autoSync: Boolean(resource.spec?.syncPolicy?.automated),
2029
+ autoSync: isArgoAutoSyncEnabled(resource),
2011
2030
  terminating: isTerminating(resource),
2012
2031
  terminationStartedAt: terminationStartedAt(resource),
2013
2032
  raw: resource,
@@ -0,0 +1,80 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import { RollbackDialog } from './RollbackDialog'
6
+
7
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
8
+
9
+ let root: Root | undefined
10
+
11
+ afterEach(async () => {
12
+ if (root) await act(async () => root?.unmount())
13
+ root = undefined
14
+ document.body.replaceChildren()
15
+ })
16
+
17
+ describe('RollbackDialog confirmation', () => {
18
+ it('explains rollback risk and keeps dry-run copy distinct', async () => {
19
+ const host = document.createElement('div')
20
+ document.body.appendChild(host)
21
+ root = createRoot(host)
22
+ const onConfirm = vi.fn()
23
+ await act(async () => root?.render(
24
+ <RollbackDialog open appLabel="argocd/demo" revision="abc123" historyId="0" onCancel={() => {}} onConfirm={onConfirm} />,
25
+ ))
26
+ const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
27
+ expect(dialog.textContent).toContain('Auto-sync is off. Later changes can still cause drift. Syncing or enabling auto-sync deploys the current revision and can undo the rollback.')
28
+ expect(dialog.textContent).toContain("Delete tracked resources that aren't in the rollback target")
29
+ expect(dialog.textContent).toContain("Off by default. The target's resources are still applied; other tracked resources are kept.")
30
+ expect(dialog.textContent).toContain("Uses the Application's current sync options. Sync hooks don't run.")
31
+ const dryRun = dialog.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')[1]
32
+ await act(async () => dryRun.click())
33
+ expect(dialog.textContent).toContain('Argo will preview syncing this application to a previous revision without applying changes.')
34
+ expect(dialog.textContent).not.toContain('Later changes can still cause drift.')
35
+ const confirm = [...dialog.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Run dry-run')!
36
+ await act(async () => confirm.click())
37
+ expect(onConfirm).toHaveBeenCalledWith({ prune: false, dryRun: true })
38
+ })
39
+
40
+ it.each(['https://example.com/repo/very/long/path/to/guestbook', 'guestbook (+2 more)'])('shows the selected source with wrapping (%s)', async (source) => {
41
+ const host = document.createElement('div')
42
+ document.body.appendChild(host)
43
+ root = createRoot(host)
44
+ await act(async () => root?.render(
45
+ <RollbackDialog open appLabel="argocd/demo" revision="abc123" historyId="0" source={source} onCancel={() => {}} onConfirm={() => {}} />,
46
+ ))
47
+ const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
48
+ const label = [...dialog.querySelectorAll('dt')].find(dt => dt.textContent === 'Source')
49
+ expect(label).toBeDefined()
50
+ expect(label?.nextElementSibling?.textContent).toBe(source)
51
+ expect(label?.nextElementSibling?.classList.contains('break-words')).toBe(true)
52
+ })
53
+
54
+ it.each([true, false])('shows controller compatibility when either the current Application or entry uses multiple sources (%s)', async (multiSourceWarning) => {
55
+ const host = document.createElement('div')
56
+ document.body.appendChild(host)
57
+ root = createRoot(host)
58
+ await act(async () => root?.render(
59
+ <RollbackDialog open appLabel="argocd/demo" revision="aaa111, bbb222" multiSourceWarning={multiSourceWarning} onCancel={() => {}} onConfirm={() => {}} />,
60
+ ))
61
+ const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
62
+ const note = 'On Argo CD before 2.12, rolling back across a single-source/multi-source change or to a multi-source entry can deploy the wrong revisions or render nothing; with prune, it can delete resources.'
63
+ expect(dialog.textContent?.includes(note)).toBe(multiSourceWarning)
64
+ })
65
+
66
+ })
67
+
68
+ it('disables confirmation while support is being checked', async () => {
69
+ const host = document.createElement('div')
70
+ document.body.appendChild(host)
71
+ root = createRoot(host)
72
+ const confirm = vi.fn()
73
+ await act(async () => root?.render(<RollbackDialog open appLabel="argocd/demo" revision="old" blockedReason="Checking this cluster's Radar…" onCancel={() => {}} onConfirm={confirm} />))
74
+ const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
75
+ expect(dialog.textContent).toContain("Checking this cluster's Radar…")
76
+ const button = [...dialog.querySelectorAll<HTMLButtonElement>('button')].find(b => b.textContent === 'Roll back')!
77
+ expect(button.disabled).toBe(true)
78
+ await act(async () => button.click())
79
+ expect(confirm).not.toHaveBeenCalled()
80
+ })
@@ -24,12 +24,16 @@ export interface RollbackDialogProps {
24
24
  appLabel: string
25
25
  revision: string
26
26
  historyId?: string
27
+ source?: string
28
+ // Either the current Application or the selected entry uses plural sources.
29
+ multiSourceWarning?: boolean
27
30
  pending?: boolean
31
+ blockedReason?: string
28
32
  onCancel: () => void
29
33
  onConfirm: (opts: { prune: boolean; dryRun: boolean }) => void
30
34
  }
31
35
 
32
- export function RollbackDialog({ open, appLabel, revision, historyId, pending, onCancel, onConfirm }: RollbackDialogProps) {
36
+ export function RollbackDialog({ open, appLabel, revision, historyId, source, multiSourceWarning, pending, blockedReason, onCancel, onConfirm }: RollbackDialogProps) {
33
37
  const titleId = useId()
34
38
  const [prune, setPrune] = useState(false)
35
39
  const [dryRun, setDryRun] = useState(false)
@@ -49,8 +53,16 @@ export function RollbackDialog({ open, appLabel, revision, historyId, pending, o
49
53
  </div>
50
54
  <div className="space-y-4 px-4 py-4 text-sm">
51
55
  <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
52
- Argo will sync this application to a previous revision. This is a write operation on the cluster.
56
+ {dryRun
57
+ ? 'Argo will preview syncing this application to a previous revision without applying changes.'
58
+ : 'Argo will sync this application to a previous revision. Auto-sync is off. Later changes can still cause drift. Syncing or enabling auto-sync deploys the current revision and can undo the rollback.'}
53
59
  </div>
60
+ {multiSourceWarning && (
61
+ <p className="text-xs text-warning-text">
62
+ On Argo CD before 2.12, rolling back across a single-source/multi-source change or to a multi-source entry can deploy the wrong revisions or render nothing; with prune, it can delete resources.
63
+ </p>
64
+ )}
65
+ {blockedReason && <p className="text-xs text-theme-text-secondary">{blockedReason}</p>}
54
66
  <dl className="grid grid-cols-[80px_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
55
67
  <dt className="text-theme-text-tertiary">Revision</dt>
56
68
  <dd className="min-w-0">
@@ -58,6 +70,12 @@ export function RollbackDialog({ open, appLabel, revision, historyId, pending, o
58
70
  <span className="block truncate font-mono text-theme-text-primary">{revision || '-'}</span>
59
71
  </Tooltip>
60
72
  </dd>
73
+ {source && (
74
+ <>
75
+ <dt className="text-theme-text-tertiary">Source</dt>
76
+ <dd className="min-w-0 break-words text-theme-text-primary">{source}</dd>
77
+ </>
78
+ )}
61
79
  {historyId && (
62
80
  <>
63
81
  <dt className="text-theme-text-tertiary">History ID</dt>
@@ -66,6 +84,7 @@ export function RollbackDialog({ open, appLabel, revision, historyId, pending, o
66
84
  )}
67
85
  </dl>
68
86
  <div className="space-y-2">
87
+ <p className="text-xs text-theme-text-tertiary">Uses the Application's current sync options. Sync hooks don't run.</p>
69
88
  <label className="flex items-start gap-2">
70
89
  <input
71
90
  type="checkbox"
@@ -75,8 +94,8 @@ export function RollbackDialog({ open, appLabel, revision, historyId, pending, o
75
94
  className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-sky-500 disabled:cursor-not-allowed"
76
95
  />
77
96
  <div className="min-w-0">
78
- <div className="text-xs text-theme-text-primary">Prune resources added since this revision</div>
79
- <div className="text-[11px] text-theme-text-tertiary">Off by default — leaves any resources created after this revision untouched.</div>
97
+ <div className="text-xs text-theme-text-primary">Delete tracked resources that aren't in the rollback target</div>
98
+ <div className="text-[11px] text-theme-text-tertiary">Off by default. The target's resources are still applied; other tracked resources are kept.</div>
80
99
  </div>
81
100
  </label>
82
101
  <label className="flex items-start gap-2">
@@ -105,8 +124,8 @@ export function RollbackDialog({ open, appLabel, revision, historyId, pending, o
105
124
  </button>
106
125
  <button
107
126
  type="button"
108
- onClick={() => onConfirm({ prune, dryRun })}
109
- disabled={pending}
127
+ onClick={() => !blockedReason && onConfirm({ prune, dryRun })}
128
+ disabled={pending || !!blockedReason}
110
129
  className="inline-flex items-center gap-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-amber-400"
111
130
  >
112
131
  {pending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <History className="h-3.5 w-3.5" />}