@skyhook-io/k8s-ui 1.18.0 → 1.18.2

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 (128) hide show
  1. package/README.md +21 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  5. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  6. package/src/components/compare/ResourceCompareView.tsx +6 -2
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  9. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  10. package/src/components/gitops/GitOpsTableView.tsx +46 -12
  11. package/src/components/gitops/detail-helpers.test.ts +58 -13
  12. package/src/components/gitops/detail-helpers.ts +37 -6
  13. package/src/components/gitops/index.ts +1 -0
  14. package/src/components/issues/IssuesView.tsx +16 -11
  15. package/src/components/issues/diagnostic.ts +2 -0
  16. package/src/components/issues/severity.ts +1 -0
  17. package/src/components/logs/LogCore.tsx +13 -11
  18. package/src/components/logs/LogsViewer.test.tsx +104 -59
  19. package/src/components/logs/LogsViewer.tsx +28 -8
  20. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  21. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  22. package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
  23. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  24. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  25. package/src/components/resources/ResourcesSidebar.tsx +62 -26
  26. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  27. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  28. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  29. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  30. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  31. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  32. package/src/components/resources/ResourcesView.tsx +342 -83
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/generic-status.ts +15 -0
  35. package/src/components/resources/printer-columns.test.ts +36 -0
  36. package/src/components/resources/printer-columns.ts +12 -1
  37. package/src/components/resources/readiness-gates.test.ts +95 -0
  38. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  39. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  40. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  41. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  42. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  43. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  44. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  45. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  46. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  56. package/src/components/resources/resource-utils-trivy.ts +122 -0
  57. package/src/components/resources/resource-utils.ts +80 -2
  58. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  59. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  60. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  61. package/src/components/shared/DetailShell.tsx +21 -8
  62. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  63. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  64. package/src/components/shared/EditableYamlView.tsx +18 -3
  65. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  66. package/src/components/shared/FilterRailPanel.tsx +62 -0
  67. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  68. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  69. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  70. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  71. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  72. package/src/components/shared/ManagedByChip.tsx +158 -5
  73. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  74. package/src/components/shared/ResourceActionsBar.tsx +31 -9
  75. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  76. package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
  77. package/src/components/shared/SetImageDialog.tsx +3 -0
  78. package/src/components/timeline/TimelineList.tsx +5 -1
  79. package/src/components/topology/TopologyControls.tsx +1 -1
  80. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  81. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  82. package/src/components/ui/FetchResult.test.tsx +19 -2
  83. package/src/components/ui/FetchResult.tsx +13 -1
  84. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  85. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  86. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  87. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  88. package/src/components/ui/PageHeader.tsx +7 -4
  89. package/src/components/ui/SummaryTile.tsx +13 -3
  90. package/src/components/ui/Toast.tsx +33 -26
  91. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  92. package/src/components/ui/Tooltip.tsx +11 -2
  93. package/src/components/ui/YamlReview.test.tsx +52 -0
  94. package/src/components/ui/YamlReview.tsx +7 -0
  95. package/src/components/ui/drawer-components.test.tsx +280 -0
  96. package/src/components/ui/drawer-components.tsx +302 -18
  97. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  98. package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
  99. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  100. package/src/components/workload/WorkloadView.tsx +105 -34
  101. package/src/hooks/index.ts +2 -0
  102. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  103. package/src/hooks/useKeepInViewport.test.ts +27 -0
  104. package/src/hooks/useKeepInViewport.ts +87 -0
  105. package/src/hooks/useMediaQuery.ts +21 -0
  106. package/src/hooks/usePhoneLayout.ts +12 -0
  107. package/src/theme/tailwind-theme.css +5 -0
  108. package/src/types/core.ts +119 -3
  109. package/src/types/gitops-flux-inventory.test.ts +24 -0
  110. package/src/types/gitops.ts +5 -5
  111. package/src/types/index.ts +3 -0
  112. package/src/types/read-coverage.ts +65 -0
  113. package/src/types/resource-coverage.ts +40 -0
  114. package/src/utils/api-resources.ts +1 -0
  115. package/src/utils/format.test.ts +7 -0
  116. package/src/utils/format.ts +6 -1
  117. package/src/utils/gitops-owner.test.ts +42 -1
  118. package/src/utils/gitops-owner.ts +158 -8
  119. package/src/utils/gitops-write-guard.test.ts +332 -0
  120. package/src/utils/gitops-write-guard.ts +175 -11
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/read-actions.test.ts +22 -0
  123. package/src/utils/read-actions.ts +90 -0
  124. package/src/utils/resource-coverage.test.ts +121 -0
  125. package/src/utils/resource-coverage.ts +117 -0
  126. package/src/utils/touch-intent.test.ts +39 -0
  127. package/src/utils/touch-intent.ts +35 -0
  128. package/vitest.config.ts +26 -0
@@ -1,6 +1,11 @@
1
+ import { useEffect, useRef, useState } from 'react'
1
2
  import { GitBranch, Package } from 'lucide-react'
2
3
  import { clsx } from 'clsx'
3
- import type { GitOpsOwnerRef } from '../../utils/gitops-owner'
4
+ import { activeManagerClaims, gitOpsOwnerFromRef, managerCandidates, managerChipPresentation, managerChoices, managerRefLabel, type GitOpsOwnerRef, type ManagerChoice, type ManagerListing } from '../../utils/gitops-owner'
5
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
6
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
7
+ import type { ManagerAnswer, ResourceRef } from '../../types/core'
8
+ import { resourceRefKey } from '../../utils/resource-refs'
4
9
  import type { GitOpsStatus } from '../../types/gitops'
5
10
  import { StatusDot, type StatusTone } from '../ui/status-tone'
6
11
  import { Tooltip } from '../ui/Tooltip'
@@ -11,7 +16,10 @@ export type HelmOwnerRef = {
11
16
  }
12
17
 
13
18
  // ManagedByChip renders the "Managed by <ArgoCD/FluxCD app>" affordance for
14
- // resources detected (via labels/annotations) to be GitOps-managed. The chip
19
+ // resources detected (via labels/annotations) to be GitOps-managed. `listing`
20
+ // compares that claim with the managers whose status lists the resource: a
21
+ // manager that doesn't list it while another does is shown as "Tracked by"
22
+ // with a warning, since the claim is all that names it. The chip
15
23
  // is clickable when the host wires `onOpen`; integrators that don't surface
16
24
  // a GitOps tab can omit the handler and the chip degrades to a passive badge
17
25
  // so the relationship is still visible.
@@ -21,26 +29,33 @@ export type HelmOwnerRef = {
21
29
  // - block: starts a new line with mt-1 spacing, used in WorkloadView title strip
22
30
  export function ManagedByChip({
23
31
  owner,
32
+ manager,
24
33
  status,
25
34
  onOpen,
26
35
  verified = true,
27
36
  pending = false,
28
37
  source,
38
+ listing,
29
39
  variant = 'inline',
30
40
  }: {
31
- owner: GitOpsOwnerRef
41
+ owner?: GitOpsOwnerRef | null
42
+ manager?: ManagerAnswer
32
43
  status?: GitOpsStatus | null
33
44
  onOpen?: (ref: GitOpsOwnerRef) => void
34
45
  verified?: boolean
35
46
  pending?: boolean
36
47
  source?: string | null
48
+ listing?: ManagerListing
37
49
  variant?: 'inline' | 'block'
38
50
  }) {
51
+ if (manager) return <ManagerAnswerChip manager={manager} status={status} onOpen={onOpen} variant={variant} />
52
+ if (!owner) return null
39
53
  const toolLabel = owner.tool === 'argocd' ? 'ArgoCD' : 'FluxCD'
40
54
  const ownerKindLabel = gitOpsOwnerKindLabel(owner)
41
55
  const label = owner.namespace ? `${owner.namespace}/${owner.name}` : owner.name
42
56
  const statusLabel = status ? gitOpsStatusLabel(status) : null
43
- const prefix = pending ? 'Resolving' : verified ? 'Managed by' : 'Tracked by'
57
+ const conflict = listing?.state === 'conflict' ? listing.listedBy : null
58
+ const prefix = pending ? 'Resolving' : verified && !conflict ? 'Managed by' : 'Tracked by'
44
59
  const tooltipContent = (
45
60
  <span className="flex flex-col gap-1">
46
61
  <span>
@@ -48,10 +63,23 @@ export function ManagedByChip({
48
63
  <>
49
64
  Looking up matching {ownerKindLabel} <TooltipCode>{label}</TooltipCode> in this cluster.
50
65
  </>
66
+ ) : conflict ? (
67
+ <>
68
+ This resource's {toolLabel} tracking metadata names <TooltipCode>{label}</TooltipCode>, but that {ownerKindLabel}'s status doesn't list it.{' '}
69
+ {conflict.length === 1 ? 'The status of ' : 'The statuses of '}
70
+ {conflict.map((m, i) => (
71
+ <span key={`${m.namespace}/${m.name}`}>
72
+ {i > 0 && ', '}
73
+ <TooltipCode>{m.namespace ? `${m.namespace}/${m.name}` : m.name}</TooltipCode>
74
+ </span>
75
+ ))}{' '}
76
+ {conflict.length === 1 ? 'does.' : 'do.'}
77
+ </>
51
78
  ) : verified ? (
52
79
  <>
53
80
  Managed by {toolLabel} · <TooltipCode>{label}</TooltipCode>
54
81
  {statusLabel && <> · {statusLabel}{status?.message ? `: ${status.message}` : ''}</>}
82
+ {listing?.state === 'confirmed' && <>. Its status lists this resource too.</>}
55
83
  </>
56
84
  ) : (
57
85
  <>
@@ -80,6 +108,7 @@ export function ManagedByChip({
80
108
  )}
81
109
  >
82
110
  <GitBranch className="h-3 w-3 shrink-0" />
111
+ {conflict && <StatusDot tone="degraded" size="xs" />}
83
112
  <span className="shrink-0 text-theme-text-tertiary">{prefix}</span>
84
113
  <span className="truncate max-w-[180px]">{label}</span>
85
114
  {statusLabel && (
@@ -96,13 +125,136 @@ export function ManagedByChip({
96
125
  )
97
126
  }
98
127
 
128
+ function ManagerAnswerChip({ manager, status, onOpen, variant }: {
129
+ manager: ManagerAnswer
130
+ status?: GitOpsStatus | null
131
+ onOpen?: (ref: GitOpsOwnerRef) => void
132
+ variant: 'inline' | 'block'
133
+ }) {
134
+ const [open, setOpen] = useState(false)
135
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
136
+ const ref = useRef<HTMLDivElement>(null)
137
+ useEffect(() => {
138
+ if (!open) return
139
+ const onDown = (e: MouseEvent) => {
140
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
141
+ }
142
+ // Consumed, so the Escape that closes the menu doesn't also close the drawer.
143
+ const onKey = (e: KeyboardEvent) => {
144
+ if (e.key !== 'Escape') return
145
+ e.preventDefault()
146
+ e.stopPropagation()
147
+ setOpen(false)
148
+ }
149
+ document.addEventListener('mousedown', onDown)
150
+ document.addEventListener('keydown', onKey, true)
151
+ return () => {
152
+ document.removeEventListener('mousedown', onDown)
153
+ document.removeEventListener('keydown', onKey, true)
154
+ }
155
+ }, [open])
156
+
157
+ const presentation = managerChipPresentation(manager)
158
+ if (!presentation) return null
159
+ // Several candidates open a menu naming each, so none is reached only
160
+ // through the one shown first; one opens directly.
161
+ // Every candidate is named; only one Radar found links. A label that names
162
+ // no namespace stays unlinked: Radar doesn't guess it.
163
+ const choices = managerChoices(manager).map(c => {
164
+ const owner = gitOpsOwnerFromRef(c.ref)
165
+ return { ...c, owner: c.found && owner?.namespace ? owner : null }
166
+ })
167
+ // A label naming no namespace is unresolved only when its own check says
168
+ // so; otherwise Applications of that name list the object and it doesn't
169
+ // say which.
170
+ const evidence = manager.via ?? manager.direct
171
+ const unresolved = (ref: ResourceRef) => activeManagerClaims(evidence).some(c => c.check === 'unresolved' && resourceRefKey(c.manager) === resourceRefKey(ref))
172
+ const several = managerCandidates(manager).length > 1
173
+ const lead = manager.lead ? gitOpsOwnerFromRef(manager.lead) : null
174
+ const direct = !several ? choices.find(c => c.owner && lead && c.owner.name === lead.name && c.owner.namespace === lead.namespace)?.owner ?? null : null
175
+ const menu = Boolean(onOpen) && several && choices.some(c => c.owner)
176
+ // The manager's own status, shown only when it is confirmed to manage this
177
+ // resource: beside a list alone, Synced reads as this resource's state.
178
+ const statusLabel = lead && status && !several && manager.state === 'agree' ? gitOpsStatusLabel(status) : null
179
+ const chipClass = clsx(
180
+ 'inline-flex min-w-0 items-center gap-1 rounded border border-theme-border bg-theme-elevated px-1.5 py-0.5 text-[11px] text-theme-text-secondary',
181
+ (menu || direct && onOpen) && 'hover:border-accent hover:text-accent transition-colors',
182
+ )
183
+ const content = (
184
+ <>
185
+ <GitBranch className="h-3 w-3 shrink-0" />
186
+ {presentation.warning && <StatusDot tone="degraded" size="xs" />}
187
+ <span className="shrink-0 text-theme-text-tertiary">{presentation.prefix}</span>
188
+ <span className="truncate max-w-[180px]">{presentation.label}</span>
189
+ {presentation.via && <span className="shrink-0 text-theme-text-tertiary">via {presentation.via}</span>}
190
+ {statusLabel && <><span className="mx-0.5 text-theme-text-disabled">·</span><StatusDot tone={gitOpsStatusTone(status!)} size="xs" /><span>{statusLabel}</span></>}
191
+ </>
192
+ )
193
+ const tooltip = `${presentation.tooltip}${statusLabel ? ` ${statusLabel}${status?.message ? `: ${status.message}` : ''}.` : ''}`
194
+ const chip = menu ? (
195
+ <button type="button" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)} className={chipClass}>{content}</button>
196
+ ) : direct && onOpen ? (
197
+ <button type="button" onClick={() => onOpen(direct)} className={chipClass}>{content}</button>
198
+ ) : (
199
+ // Focusable, so keyboard users reach the tooltip that explains it.
200
+ <span tabIndex={0} className={chipClass}>{content}</span>
201
+ )
202
+ return (
203
+ <div ref={ref} className={clsx('relative inline-flex min-w-0', variant === 'block' && 'mt-1')}>
204
+ <Tooltip content={tooltip} delay={500} position="bottom">{chip}</Tooltip>
205
+ {menu && shouldRender && (
206
+ <div
207
+ role="menu"
208
+ inert={!open}
209
+ className={clsx(
210
+ 'absolute left-0 top-full z-50 mt-1 min-w-[220px] origin-top-left rounded-lg border border-theme-border bg-theme-surface py-1 shadow-theme-lg',
211
+ TRANSITION_MENU,
212
+ isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
213
+ !open && 'pointer-events-none',
214
+ )}
215
+ style={overlayTransitionStyle(isOpen, 'menu')}
216
+ >
217
+ {choices.map(c => (
218
+ <button
219
+ key={`${c.ref.group}/${c.ref.kind}/${c.ref.namespace}/${c.ref.name}`}
220
+ type="button"
221
+ role="menuitem"
222
+ disabled={!c.owner}
223
+ onClick={() => {
224
+ if (!c.owner) return
225
+ setOpen(false)
226
+ onOpen?.(c.owner)
227
+ }}
228
+ className={clsx(
229
+ 'flex w-full flex-col items-start px-3 py-1.5 text-left text-xs transition-colors',
230
+ c.owner ? 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary' : 'cursor-default text-theme-text-tertiary',
231
+ )}
232
+ >
233
+ <span className="truncate">{managerRefLabel(c.ref)}</span>
234
+ <span className="text-[11px] text-theme-text-tertiary">{choiceRole(c)}{c.owner ? '' : !c.ref.namespace ? unresolved(c.ref) ? ` · Radar found no ${c.ref.kind} of this name listing it; it may be managed from another cluster` : ` · its label doesn't say which ${c.ref.kind} of this name` : !c.found ? ' · Radar found no such manager you can view' : ''}</span>
235
+ </button>
236
+ ))}
237
+ </div>
238
+ )}
239
+ </div>
240
+ )
241
+ }
242
+
243
+ function choiceRole(c: Pick<ManagerChoice, 'listed' | 'claimed' | 'found'>): string {
244
+ if (c.listed && c.claimed) return 'Its status lists this, and the metadata names it'
245
+ if (c.listed) return 'Its status lists this'
246
+ return 'This metadata names it'
247
+ }
248
+
99
249
  export function HelmManagedByChip({
100
250
  owner,
251
+ manager,
101
252
  onOpen,
102
253
  source,
103
254
  variant = 'inline',
104
255
  }: {
105
256
  owner: HelmOwnerRef
257
+ manager?: ManagerAnswer
106
258
  onOpen?: (ref: HelmOwnerRef) => void
107
259
  source?: string | null
108
260
  variant?: 'inline' | 'block'
@@ -113,6 +265,7 @@ export function HelmManagedByChip({
113
265
  <span>
114
266
  Managed by Helm release <TooltipCode>{label}</TooltipCode>.
115
267
  </span>
268
+ {manager && <span>{managerChipPresentation(manager)?.tooltip}</span>}
116
269
  {source && (
117
270
  <span>
118
271
  Inferred from <TooltipCode>{source}</TooltipCode>.
@@ -141,7 +294,7 @@ export function HelmManagedByChip({
141
294
  )
142
295
  }
143
296
 
144
- function gitOpsOwnerKindLabel(owner: GitOpsOwnerRef): string {
297
+ export function gitOpsOwnerKindLabel(owner: GitOpsOwnerRef): string {
145
298
  if (owner.tool === 'argocd') return 'Application'
146
299
  if (owner.kind === 'helmreleases') return 'HelmRelease'
147
300
  if (owner.kind === 'kustomizations') return 'Kustomization'
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { ResourceActionsBar } from './ResourceActionsBar'
4
+
5
+ const noop = () => {}
6
+
7
+ function actions(kind: string, apiVersion: string) {
8
+ return renderToString(
9
+ <ResourceActionsBar
10
+ resource={{ kind, name: 'example', namespace: 'test' }}
11
+ data={{ apiVersion, metadata: { name: 'example', namespace: 'test' } }}
12
+ onFluxReconcile={noop}
13
+ onFluxSuspend={noop}
14
+ onFluxResume={noop}
15
+ onArgoSync={noop}
16
+ onArgoRefresh={noop}
17
+ onArgoSuspend={noop}
18
+ onArgoResume={noop}
19
+ canPortForward
20
+ renderPortForward={() => <button>Port forward</button>}
21
+ />,
22
+ )
23
+ }
24
+
25
+ describe('resource actions use the loaded API group', () => {
26
+ it.each(['app.k8s.io/v1beta1', 'notargoproj.io/v1alpha1', ''])('does not offer Argo actions for Application in %s', (apiVersion) => {
27
+ const html = actions('applications', apiVersion)
28
+ expect(html).not.toContain('>Sync<')
29
+ expect(html).not.toContain('>Refresh<')
30
+ expect(html).not.toContain('Enable Auto-Sync')
31
+ })
32
+
33
+ it('retains Argo actions for its exact Application group', () => {
34
+ const html = actions('applications', 'argoproj.io/v1alpha1')
35
+ expect(html).toContain('>Sync<')
36
+ expect(html).toContain('>Refresh<')
37
+ expect(html).toContain('Enable Auto-Sync')
38
+ })
39
+
40
+ it.each(['serving.knative.dev/v1', 'vendor.serving.knative.dev/v1', 'vendor.io/v1', ''])('does not port-forward a non-core Service in %s', (apiVersion) => {
41
+ expect(actions('services', apiVersion)).not.toContain('Port forward')
42
+ })
43
+
44
+ it('retains port forwarding for core Service', () => {
45
+ expect(actions('services', 'v1')).toContain('Port forward')
46
+ })
47
+ it.each([
48
+ ['gitrepositories', 'source.toolkit.fluxcd.io'],
49
+ ['ocirepositories', 'source.toolkit.fluxcd.io'],
50
+ ['helmrepositories', 'source.toolkit.fluxcd.io'],
51
+ ['kustomizations', 'kustomize.toolkit.fluxcd.io'],
52
+ ['helmreleases', 'helm.toolkit.fluxcd.io'],
53
+ ['alerts', 'notification.toolkit.fluxcd.io'],
54
+ ])('only offers Flux actions for %s in its exact group', (kind, group) => {
55
+ expect(actions(kind, `${group}/v1`)).toContain('>Reconcile<')
56
+ for (const apiVersion of ['', 'vendor.io/v1', `foreign.${group}/v1`, 'argoproj.io/v1alpha1']) {
57
+ const html = actions(kind, apiVersion)
58
+ expect(html).not.toContain('>Reconcile<')
59
+ expect(html).not.toContain('>Suspend<')
60
+ expect(html).not.toContain('>Resume<')
61
+ }
62
+ })
63
+
64
+ })
@@ -1,4 +1,5 @@
1
1
  import { getNodeLifecycle } from '../../utils/node-lifecycle'
2
+ import { resourceRefKey } from '../../utils/resource-refs'
2
3
  import { useEffect, useState, useRef, useId } from 'react'
3
4
  import {
4
5
  RefreshCw,
@@ -21,7 +22,7 @@ import { createTwoFilesPatch } from 'diff'
21
22
  import { clsx } from 'clsx'
22
23
  import { classifyDiffLine } from './UnifiedDiff'
23
24
  import { Tooltip } from '../ui/Tooltip'
24
- import { ForceDeleteConfirmDialog, type CascadeDependent } from '../ui/ForceDeleteConfirmDialog'
25
+ import { ForceDeleteConfirmDialog, type CascadeDependent, type CascadeDetail } from '../ui/ForceDeleteConfirmDialog'
25
26
  import { ConfirmDialog } from '../ui/ConfirmDialog'
26
27
  import { AlertBanner } from '../ui/drawer-components'
27
28
  import { DialogPortal } from '../ui/DialogPortal'
@@ -32,6 +33,9 @@ import { getDefaultContainerName, getExecContainerNames } from '../resources/res
32
33
  import { SetImageDialog, type SetImageOwnership } from './SetImageDialog'
33
34
  import { DrainPlanDialog, DEFAULT_DRAIN_DIALOG_OPTIONS, type DrainDialogOptions, type DrainPlan } from './DrainPlanDialog'
34
35
  import type { WorkloadImageInventory, WorkloadImageUpdate } from '../../types/core'
36
+ import { getGitOpsTool } from '../gitops/detail-helpers'
37
+ import { apiVersionToGroup } from '../../utils/navigation'
38
+ import { isApiGroup } from '../resources/resource-utils-cnpg'
35
39
  import { isArgoRolloutResource } from '../../utils/workload-rollout'
36
40
  import { isCoreBatchJob } from '../../utils/api-resources'
37
41
  import { formatRelativeAgeTime } from '../../utils/format'
@@ -40,6 +44,8 @@ import { formatRelativeAgeTime } from '../../utils/format'
40
44
  // ACTIONS BAR - Interactive buttons that change based on resource kind
41
45
  // ============================================================================
42
46
 
47
+ const FLUX_ACTION_KINDS = new Set(['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'])
48
+
43
49
  interface ResourceActionsBarProps {
44
50
  resource: SelectedResource
45
51
  leadingActions?: React.ReactNode
@@ -80,6 +86,8 @@ interface ResourceActionsBarProps {
80
86
  isDeleting?: boolean
81
87
  cascadeDependents?: CascadeDependent[]
82
88
  cascadeLoading?: boolean
89
+ onDeletePreviewOpenChange?: (open: boolean) => void
90
+ cascadeDetail?: CascadeDetail
83
91
  cascadeRootResolved?: boolean
84
92
  cascadePreviewFailed?: boolean
85
93
 
@@ -163,7 +171,7 @@ export function ResourceActionsBar({
163
171
  canExec, canViewLogs, canPortForward,
164
172
  onOpenTerminal, onOpenLogs: openLogs, onOpenWorkloadLogs: openWorkloadLogs, onCopyCommand,
165
173
  renderPortForward,
166
- onDelete, isDeleting, cascadeDependents, cascadeLoading, cascadeRootResolved, cascadePreviewFailed,
174
+ onDelete, isDeleting, onDeletePreviewOpenChange, cascadeDetail, cascadeDependents, cascadeLoading, cascadeRootResolved, cascadePreviewFailed,
167
175
  onRestart, isRestarting,
168
176
  onLoadImages, onSetImages, isSettingImages, imageOwnership,
169
177
  revisions: revisionsList, revisionsLoading, revisionsError, onRollback, isRollingBack,
@@ -187,6 +195,7 @@ export function ResourceActionsBar({
187
195
  onPlanDrain, onPlanDrainReset, drainPlan, isPlanningDrain, drainPlanError, drainPlanUpgrade,
188
196
  }: ResourceActionsBarProps) {
189
197
  const kind = resource.kind.toLowerCase()
198
+ const isFluxAction = FLUX_ACTION_KINDS.has(kind) && getGitOpsTool(kind, apiVersionToGroup(data?.apiVersion)) === 'flux'
190
199
  const coreBatchJob = isCoreBatchJob(kind, resource.group)
191
200
  const supportsWorkloadActions = ['deployments', 'statefulsets', 'daemonsets'].includes(kind) ||
192
201
  (kind === 'rollouts' && isArgoRolloutResource(data))
@@ -199,6 +208,14 @@ export function ResourceActionsBar({
199
208
 
200
209
  // Delete confirmation state
201
210
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
211
+ const deleteMounted = useRef(true)
212
+ useEffect(() => {
213
+ deleteMounted.current = true
214
+ return () => { deleteMounted.current = false }
215
+ }, [])
216
+ const currentDeleteTarget = useRef({ key: resourceRefKey(resource), uid: data?.metadata?.uid })
217
+ currentDeleteTarget.current = { key: resourceRefKey(resource), uid: data?.metadata?.uid }
218
+ useEffect(() => { onDeletePreviewOpenChange?.(showDeleteConfirm) }, [showDeleteConfirm, onDeletePreviewOpenChange])
202
219
 
203
220
  // Node operation confirmation state
204
221
  const [showCordonConfirm, setShowCordonConfirm] = useState(false)
@@ -234,12 +251,13 @@ export function ResourceActionsBar({
234
251
  const hasMultipleRevisions = (revisionsList?.length ?? 0) > 1
235
252
 
236
253
  function handleDeleteConfirm(force: boolean) {
254
+ const target = currentDeleteTarget.current
255
+ setShowDeleteConfirm(false)
237
256
  onDelete?.(
238
257
  { kind: resource.kind, group: resource.group, namespace: resource.namespace, name: resource.name, force },
239
258
  {
240
259
  onSuccess: () => {
241
- setShowDeleteConfirm(false)
242
- onClose?.()
260
+ if (deleteMounted.current && currentDeleteTarget.current.key === target.key && currentDeleteTarget.current.uid === target.uid) onClose?.()
243
261
  },
244
262
  }
245
263
  )
@@ -406,7 +424,7 @@ export function ResourceActionsBar({
406
424
  )}
407
425
 
408
426
  {/* Service actions */}
409
- {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && canPortForward && resource.namespace && resource.name && renderPortForward && (
427
+ {kind === 'services' && data?.apiVersion === 'v1' && canPortForward && resource.namespace && resource.name && renderPortForward && (
410
428
  renderPortForward({
411
429
  type: 'service',
412
430
  namespace: resource.namespace,
@@ -516,7 +534,7 @@ export function ResourceActionsBar({
516
534
  )}
517
535
 
518
536
  {/* FluxCD actions */}
519
- {['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'].includes(kind) && (
537
+ {isFluxAction && (
520
538
  <FluxActions
521
539
  resource={resource}
522
540
  data={data}
@@ -532,7 +550,7 @@ export function ResourceActionsBar({
532
550
  )}
533
551
 
534
552
  {/* ArgoCD actions */}
535
- {kind === 'applications' && (
553
+ {kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io') && (
536
554
  <ArgoActions
537
555
  resource={resource}
538
556
  data={data}
@@ -667,12 +685,14 @@ export function ResourceActionsBar({
667
685
  )}
668
686
 
669
687
  {onDelete && !((kind === 'node' || kind === 'nodes') && data?.metadata?.deletionTimestamp) && (
670
- <Tooltip content="Delete resource">
688
+ <Tooltip content={isDeleting ? 'Deleting resource' : 'Delete resource'}>
671
689
  <button
690
+ aria-label={isDeleting ? 'Deleting resource' : 'Delete resource'}
691
+ disabled={isDeleting}
672
692
  onClick={() => setShowDeleteConfirm(true)}
673
693
  className="p-1.5 text-theme-text-secondary border border-theme-border-light rounded-lg hover:text-red-400 hover:border-red-400/50 hover:bg-red-500/10 transition-colors"
674
694
  >
675
- <Trash2 className="w-3.5 h-3.5" />
695
+ {isDeleting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
676
696
  </button>
677
697
  </Tooltip>
678
698
  )}
@@ -684,9 +704,11 @@ export function ResourceActionsBar({
684
704
  onClose={() => setShowDeleteConfirm(false)}
685
705
  onConfirm={handleDeleteConfirm}
686
706
  resourceName={resource.name}
707
+ resourceData={data}
687
708
  resourceKind={displayKindName(resource.kind, data?.kind)}
688
709
  namespaceName={resource.namespace}
689
710
  isLoading={isDeleting ?? false}
711
+ cascadeDetail={cascadeDetail}
690
712
  cascadeDependents={cascadeDependents}
691
713
  cascadeLoading={cascadeLoading}
692
714
  cascadeRootResolved={cascadeRootResolved}
@@ -1087,3 +1087,52 @@ it('uses RayJob native detail only for the supported exact API', () => {
1087
1087
  if (apiVersion !== 'ray.io/v1') { expect(html).toContain('native-fallback'); expect(getResourceStatus('rayjobs', data)).toBeNull() }
1088
1088
  }
1089
1089
  })
1090
+
1091
+ describe('API group identity', () => {
1092
+ it.each([
1093
+ ['applications', 'app.k8s.io/v1beta1', 'Application'],
1094
+ ['applications', 'notargoproj.io/v1alpha1', 'Application'],
1095
+ ['services', 'vendor.serving.knative.dev/v1', 'Service'],
1096
+ ['ingresses', 'vendor.networking.internal.knative.dev/v1alpha1', 'Ingress'],
1097
+ ['certificates', 'vendor.networking.internal.knative.dev/v1alpha1', 'Certificate'],
1098
+ ['gateways', 'vendor.networking.istio.io/v1', 'Gateway'],
1099
+ ])('keeps %s in %s generic', (plural, apiVersion, kind) => {
1100
+ const data = { apiVersion, kind, metadata: { name: 'foreign', namespace: 'test' }, spec: { vendorSetting: 'custom-value' }, status: { phase: 'VendorReady' } }
1101
+ expect(renderKind(plural, data, 'test')).toContain('custom-value')
1102
+ expect(getResourceStatus(plural, data)?.text).toBe('VendorReady')
1103
+ })
1104
+
1105
+ it('retains Argo CD status for its exact Application group', () => {
1106
+ const data = { apiVersion: 'argoproj.io/v1alpha1', kind: 'Application', metadata: { name: 'real' }, status: { sync: { status: 'Synced' }, health: { status: 'Healthy' } } }
1107
+ expect(renderKind('applications', data)).toContain('Synced')
1108
+ expect(getResourceStatus('applications', data)?.text).toBe('Healthy')
1109
+ })
1110
+
1111
+ it('retains Knative Service status and core Service presentation', () => {
1112
+ expect(getResourceStatus('services', { apiVersion: 'serving.knative.dev/v1', status: { conditions: [{ type: 'Ready', status: 'True' }] } })?.text).toBe('Ready')
1113
+ expect(renderKind('services', { apiVersion: 'v1', metadata: { name: 'core' }, spec: { type: 'ClusterIP', clusterIP: '10.0.0.1' } })).toContain('10.0.0.1')
1114
+ })
1115
+ })
1116
+
1117
+ describe('Flux kind collisions', () => {
1118
+ it.each([
1119
+ ['gitrepositories', 'source.toolkit.fluxcd.io'],
1120
+ ['ocirepositories', 'source.toolkit.fluxcd.io'],
1121
+ ['helmrepositories', 'source.toolkit.fluxcd.io'],
1122
+ ['kustomizations', 'kustomize.toolkit.fluxcd.io'],
1123
+ ['helmreleases', 'helm.toolkit.fluxcd.io'],
1124
+ ['alerts', 'notification.toolkit.fluxcd.io'],
1125
+ ])('keeps foreign %s generic and preserves its real Flux renderer', (kind, group) => {
1126
+ const resource = {
1127
+ apiVersion: `${group}/v1`, metadata: { name: 'example', namespace: 'test' },
1128
+ spec: { vendorSetting: 'custom-value' }, status: { phase: 'VendorReady' },
1129
+ }
1130
+ expect(renderKind(kind, resource, 'test')).not.toContain('custom-value')
1131
+ expect(getResourceStatus(kind, resource)?.text).not.toBe('VendorReady')
1132
+ for (const apiVersion of ['vendor.io/v1', `foreign.${group}/v1`, 'argoproj.io/v1alpha1']) {
1133
+ const foreign = { ...resource, apiVersion }
1134
+ expect(renderKind(kind, foreign, 'test')).toContain('custom-value')
1135
+ expect(getResourceStatus(kind, foreign)?.text).toBe('VendorReady')
1136
+ }
1137
+ })
1138
+ })