@skyhook-io/k8s-ui 1.18.1 → 1.19.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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/charts/AreaChart.test.tsx +39 -1
- package/src/components/charts/AreaChart.tsx +32 -13
- package/src/components/charts/axis.test.ts +17 -1
- package/src/components/charts/axis.ts +13 -0
- package/src/components/charts/format.test.ts +159 -2
- package/src/components/charts/format.ts +118 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsTableView.tsx +13 -8
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
- package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
- package/src/components/gitops/insights/insights-helpers.ts +5 -3
- package/src/components/issues/IssueGuidance.test.tsx +190 -0
- package/src/components/issues/IssueGuidance.tsx +48 -0
- package/src/components/issues/IssuesView.tsx +17 -5
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +1 -0
- package/src/components/issues/severity.ts +2 -0
- package/src/components/issues/types.ts +10 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.tsx +26 -5
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.tsx +238 -66
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +14 -3
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.tsx +135 -2
- package/src/components/shared/ResourceActionsBar.tsx +22 -6
- package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/PageHeader.tsx +3 -2
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +27 -0
- package/src/components/ui/drawer-components.tsx +22 -42
- package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
- package/src/components/workload/WorkloadView.tsx +57 -34
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/types/core.ts +54 -1
- package/src/types/gitops-insights.ts +8 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/gitops-owner.ts +136 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +1 -0
- package/src/utils/read-actions.ts +34 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- 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
|
|
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'
|
|
@@ -24,6 +29,7 @@ export type HelmOwnerRef = {
|
|
|
24
29
|
// - block: starts a new line with mt-1 spacing, used in WorkloadView title strip
|
|
25
30
|
export function ManagedByChip({
|
|
26
31
|
owner,
|
|
32
|
+
manager,
|
|
27
33
|
status,
|
|
28
34
|
onOpen,
|
|
29
35
|
verified = true,
|
|
@@ -32,7 +38,8 @@ export function ManagedByChip({
|
|
|
32
38
|
listing,
|
|
33
39
|
variant = 'inline',
|
|
34
40
|
}: {
|
|
35
|
-
owner
|
|
41
|
+
owner?: GitOpsOwnerRef | null
|
|
42
|
+
manager?: ManagerAnswer
|
|
36
43
|
status?: GitOpsStatus | null
|
|
37
44
|
onOpen?: (ref: GitOpsOwnerRef) => void
|
|
38
45
|
verified?: boolean
|
|
@@ -41,6 +48,8 @@ export function ManagedByChip({
|
|
|
41
48
|
listing?: ManagerListing
|
|
42
49
|
variant?: 'inline' | 'block'
|
|
43
50
|
}) {
|
|
51
|
+
if (manager) return <ManagerAnswerChip manager={manager} status={status} onOpen={onOpen} variant={variant} />
|
|
52
|
+
if (!owner) return null
|
|
44
53
|
const toolLabel = owner.tool === 'argocd' ? 'ArgoCD' : 'FluxCD'
|
|
45
54
|
const ownerKindLabel = gitOpsOwnerKindLabel(owner)
|
|
46
55
|
const label = owner.namespace ? `${owner.namespace}/${owner.name}` : owner.name
|
|
@@ -116,13 +125,136 @@ export function ManagedByChip({
|
|
|
116
125
|
)
|
|
117
126
|
}
|
|
118
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
|
+
|
|
119
249
|
export function HelmManagedByChip({
|
|
120
250
|
owner,
|
|
251
|
+
manager,
|
|
121
252
|
onOpen,
|
|
122
253
|
source,
|
|
123
254
|
variant = 'inline',
|
|
124
255
|
}: {
|
|
125
256
|
owner: HelmOwnerRef
|
|
257
|
+
manager?: ManagerAnswer
|
|
126
258
|
onOpen?: (ref: HelmOwnerRef) => void
|
|
127
259
|
source?: string | null
|
|
128
260
|
variant?: 'inline' | 'block'
|
|
@@ -133,6 +265,7 @@ export function HelmManagedByChip({
|
|
|
133
265
|
<span>
|
|
134
266
|
Managed by Helm release <TooltipCode>{label}</TooltipCode>.
|
|
135
267
|
</span>
|
|
268
|
+
{manager && <span>{managerChipPresentation(manager)?.tooltip}</span>}
|
|
136
269
|
{source && (
|
|
137
270
|
<span>
|
|
138
271
|
Inferred from <TooltipCode>{source}</TooltipCode>.
|
|
@@ -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'
|
|
@@ -85,6 +86,8 @@ interface ResourceActionsBarProps {
|
|
|
85
86
|
isDeleting?: boolean
|
|
86
87
|
cascadeDependents?: CascadeDependent[]
|
|
87
88
|
cascadeLoading?: boolean
|
|
89
|
+
onDeletePreviewOpenChange?: (open: boolean) => void
|
|
90
|
+
cascadeDetail?: CascadeDetail
|
|
88
91
|
cascadeRootResolved?: boolean
|
|
89
92
|
cascadePreviewFailed?: boolean
|
|
90
93
|
|
|
@@ -168,7 +171,7 @@ export function ResourceActionsBar({
|
|
|
168
171
|
canExec, canViewLogs, canPortForward,
|
|
169
172
|
onOpenTerminal, onOpenLogs: openLogs, onOpenWorkloadLogs: openWorkloadLogs, onCopyCommand,
|
|
170
173
|
renderPortForward,
|
|
171
|
-
onDelete, isDeleting, cascadeDependents, cascadeLoading, cascadeRootResolved, cascadePreviewFailed,
|
|
174
|
+
onDelete, isDeleting, onDeletePreviewOpenChange, cascadeDetail, cascadeDependents, cascadeLoading, cascadeRootResolved, cascadePreviewFailed,
|
|
172
175
|
onRestart, isRestarting,
|
|
173
176
|
onLoadImages, onSetImages, isSettingImages, imageOwnership,
|
|
174
177
|
revisions: revisionsList, revisionsLoading, revisionsError, onRollback, isRollingBack,
|
|
@@ -205,6 +208,14 @@ export function ResourceActionsBar({
|
|
|
205
208
|
|
|
206
209
|
// Delete confirmation state
|
|
207
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])
|
|
208
219
|
|
|
209
220
|
// Node operation confirmation state
|
|
210
221
|
const [showCordonConfirm, setShowCordonConfirm] = useState(false)
|
|
@@ -240,12 +251,13 @@ export function ResourceActionsBar({
|
|
|
240
251
|
const hasMultipleRevisions = (revisionsList?.length ?? 0) > 1
|
|
241
252
|
|
|
242
253
|
function handleDeleteConfirm(force: boolean) {
|
|
254
|
+
const target = currentDeleteTarget.current
|
|
255
|
+
setShowDeleteConfirm(false)
|
|
243
256
|
onDelete?.(
|
|
244
257
|
{ kind: resource.kind, group: resource.group, namespace: resource.namespace, name: resource.name, force },
|
|
245
258
|
{
|
|
246
259
|
onSuccess: () => {
|
|
247
|
-
|
|
248
|
-
onClose?.()
|
|
260
|
+
if (deleteMounted.current && currentDeleteTarget.current.key === target.key && currentDeleteTarget.current.uid === target.uid) onClose?.()
|
|
249
261
|
},
|
|
250
262
|
}
|
|
251
263
|
)
|
|
@@ -673,12 +685,14 @@ export function ResourceActionsBar({
|
|
|
673
685
|
)}
|
|
674
686
|
|
|
675
687
|
{onDelete && !((kind === 'node' || kind === 'nodes') && data?.metadata?.deletionTimestamp) && (
|
|
676
|
-
<Tooltip content=
|
|
688
|
+
<Tooltip content={isDeleting ? 'Deleting resource' : 'Delete resource'}>
|
|
677
689
|
<button
|
|
690
|
+
aria-label={isDeleting ? 'Deleting resource' : 'Delete resource'}
|
|
691
|
+
disabled={isDeleting}
|
|
678
692
|
onClick={() => setShowDeleteConfirm(true)}
|
|
679
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"
|
|
680
694
|
>
|
|
681
|
-
<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" />}
|
|
682
696
|
</button>
|
|
683
697
|
</Tooltip>
|
|
684
698
|
)}
|
|
@@ -690,9 +704,11 @@ export function ResourceActionsBar({
|
|
|
690
704
|
onClose={() => setShowDeleteConfirm(false)}
|
|
691
705
|
onConfirm={handleDeleteConfirm}
|
|
692
706
|
resourceName={resource.name}
|
|
707
|
+
resourceData={data}
|
|
693
708
|
resourceKind={displayKindName(resource.kind, data?.kind)}
|
|
694
709
|
namespaceName={resource.namespace}
|
|
695
710
|
isLoading={isDeleting ?? false}
|
|
711
|
+
cascadeDetail={cascadeDetail}
|
|
696
712
|
cascadeDependents={cascadeDependents}
|
|
697
713
|
cascadeLoading={cascadeLoading}
|
|
698
714
|
cascadeRootResolved={cascadeRootResolved}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { getOpenTelemetryCollectorStatus, getOpenTelemetryInstrumentationStatus } from '../resources/resource-utils-opentelemetry'
|
|
2
|
+
import { OpenTelemetryCollectorRenderer, OpenTelemetryInstrumentationRenderer } from '../resources/renderers/OpenTelemetryRenderers'
|
|
3
|
+
import { FlaggerCanaryRenderer, FlaggerMetricTemplateRenderer } from '../resources/renderers/FlaggerRenderers'
|
|
4
|
+
import { getFlaggerCanaryStatus } from '../resources/resource-utils-flagger'
|
|
1
5
|
import { getGitOpsTool } from '../gitops/detail-helpers'
|
|
2
6
|
import { apiVersionToGroup } from '../../utils/navigation'
|
|
3
7
|
import { hasReflectorDetails } from '../resources/renderers/ReflectorSection'
|
|
@@ -295,6 +299,7 @@ import {
|
|
|
295
299
|
import type { ComposedRefStatus } from '../resources/renderers/CompositeRenderer'
|
|
296
300
|
import {
|
|
297
301
|
getCrossplaneStatus,
|
|
302
|
+
getCrossplanePauseState,
|
|
298
303
|
getProviderStatus,
|
|
299
304
|
getProviderConfigStatus,
|
|
300
305
|
isManagedResource,
|
|
@@ -450,6 +455,8 @@ export interface RendererOverrides {
|
|
|
450
455
|
|
|
451
456
|
// Known resource types with specific renderers (module-level to avoid re-allocation)
|
|
452
457
|
const KNOWN_KINDS = new Set([
|
|
458
|
+
'opentelemetrycollectors', 'instrumentations',
|
|
459
|
+
'canaries', 'metrictemplates',
|
|
453
460
|
'pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets',
|
|
454
461
|
'services', 'endpointslices', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs', 'cronworkflows',
|
|
455
462
|
'jobsets',
|
|
@@ -709,6 +716,8 @@ export function ResourceRendererDispatch({
|
|
|
709
716
|
const isFluxKind = ['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'].includes(kind)
|
|
710
717
|
const isFluxResource = getGitOpsTool(kind, apiVersionToGroup(data?.apiVersion)) === 'flux'
|
|
711
718
|
const isGroupGatedKind =
|
|
719
|
+
['opentelemetrycollectors', 'instrumentations'].includes(kind) ||
|
|
720
|
+
['canaries', 'metrictemplates'].includes(kind) ||
|
|
712
721
|
isFluxKind || kind === 'applications' || kind === 'services' || kind === 'ingresses' || kind === 'certificates' || kind === 'gateways'
|
|
713
722
|
|| kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
|
|
714
723
|
|| kind === 'objectstores' || kind === 'databases' || kind === 'publications'
|
|
@@ -718,6 +727,8 @@ export function ResourceRendererDispatch({
|
|
|
718
727
|
|| kind === 'machines' || kind === 'machinesets' || kind === 'workloads' || kind === 'localqueues' || kind === 'clusterqueues'
|
|
719
728
|
const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
|
|
720
729
|
const groupGatedMatched =
|
|
730
|
+
(['opentelemetrycollectors', 'instrumentations'].includes(kind) && isApiGroup(data?.apiVersion, 'opentelemetry.io')) ||
|
|
731
|
+
(['canaries', 'metrictemplates'].includes(kind) && isApiGroup(data?.apiVersion, 'flagger.app')) ||
|
|
721
732
|
(isFluxKind && isFluxResource)
|
|
722
733
|
|| (kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io'))
|
|
723
734
|
|| (kind === 'services' && (data?.apiVersion === 'v1' || isApiGroup(data?.apiVersion, 'serving.knative.dev')))
|
|
@@ -1052,6 +1063,11 @@ export function ResourceRendererDispatch({
|
|
|
1052
1063
|
{isCrossplaneMR && <ManagedResourceRenderer data={data} onNavigate={onNavigate} />}
|
|
1053
1064
|
{(isCrossplaneXR || isCrossplaneClaim) && <CompositeComp data={data} onNavigate={onNavigate} />}
|
|
1054
1065
|
|
|
1066
|
+
{kind === 'opentelemetrycollectors' && isApiGroup(data?.apiVersion, 'opentelemetry.io') && <OpenTelemetryCollectorRenderer data={data} />}
|
|
1067
|
+
{kind === 'instrumentations' && isApiGroup(data?.apiVersion, 'opentelemetry.io') && <OpenTelemetryInstrumentationRenderer data={data} />}
|
|
1068
|
+
{kind === 'canaries' && isApiGroup(data?.apiVersion, 'flagger.app') && <FlaggerCanaryRenderer data={data} onNavigate={onNavigate} />}
|
|
1069
|
+
{kind === 'metrictemplates' && isApiGroup(data?.apiVersion, 'flagger.app') && <FlaggerMetricTemplateRenderer data={data} onNavigate={onNavigate} />}
|
|
1070
|
+
|
|
1055
1071
|
{/* Generic renderer for CRDs and unknown resource types — also fires
|
|
1056
1072
|
for known-plural collisions where no apiVersion-gated renderer
|
|
1057
1073
|
matched (e.g. a Knative Configuration sharing the `configurations`
|
|
@@ -1109,6 +1125,11 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
|
|
|
1109
1125
|
if (!data) return null
|
|
1110
1126
|
const k = kind.toLowerCase()
|
|
1111
1127
|
|
|
1128
|
+
if (isApiGroup(data.apiVersion, 'opentelemetry.io')) {
|
|
1129
|
+
if (k === 'opentelemetrycollectors') return getOpenTelemetryCollectorStatus(data)
|
|
1130
|
+
if (k === 'instrumentations') return getOpenTelemetryInstrumentationStatus(data)
|
|
1131
|
+
}
|
|
1132
|
+
if (k === 'canaries' && isApiGroup(data.apiVersion, 'flagger.app')) return getFlaggerCanaryStatus(data)
|
|
1112
1133
|
if (k === 'pods') return getPodStatus(data)
|
|
1113
1134
|
if (['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(k)) {
|
|
1114
1135
|
return getWorkloadDisplayStatus(data, k).status
|
|
@@ -1370,6 +1391,8 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
|
|
|
1370
1391
|
return { text: status.text, color: status.color }
|
|
1371
1392
|
}
|
|
1372
1393
|
|
|
1394
|
+
if (getCrossplanePauseState(data) === 'acknowledged') return getCrossplaneStatus(data)
|
|
1395
|
+
|
|
1373
1396
|
const derived = getGenericResourceStatus(data)
|
|
1374
1397
|
if (derived) return { text: derived.text, color: HEALTH_BADGE_COLORS[derived.tone] }
|
|
1375
1398
|
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type GitOpsWriteGuard,
|
|
18
18
|
} from '../../utils/gitops-write-guard'
|
|
19
19
|
import { GitOpsWriteWarning } from './GitOpsWriteWarning'
|
|
20
|
+
import type { GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
20
21
|
|
|
21
22
|
/** The GitOps write guard for the object whose Pod template the dialog
|
|
22
23
|
* writes, evaluated by the host: for {@link SET_IMAGE_WRITES} until the
|
|
@@ -25,6 +26,7 @@ import { GitOpsWriteWarning } from './GitOpsWriteWarning'
|
|
|
25
26
|
export interface SetImageOwnership {
|
|
26
27
|
guard: GitOpsWriteGuard
|
|
27
28
|
onOpenOwner?: () => void
|
|
29
|
+
onOpenManager?: (owner: GitOpsOwnerRef) => void
|
|
28
30
|
/** The containers the dialog lists and the ones being changed, so the
|
|
29
31
|
* guard can judge exactly those (null until the containers load). */
|
|
30
32
|
onSelectionChange?: (selection: SetImageSelection | null) => void
|
|
@@ -435,6 +437,7 @@ export function SetImageDialog({
|
|
|
435
437
|
acknowledged={acknowledged}
|
|
436
438
|
onAcknowledgedChange={(value) => setAckedKey(value ? guardKey : '')}
|
|
437
439
|
onOpenOwner={ownership?.onOpenOwner}
|
|
440
|
+
onOpenManager={ownership?.onOpenManager}
|
|
438
441
|
disabled={busy}
|
|
439
442
|
/>
|
|
440
443
|
)}
|
|
@@ -90,7 +90,7 @@ export function TopologyControls({
|
|
|
90
90
|
return (
|
|
91
91
|
// Layout-neutral (positioned by the host's overlay bar): `relative` anchors
|
|
92
92
|
// its own dropdowns; `pointer-events-auto` inside the pointer-events-none bar.
|
|
93
|
-
<div className="relative flex items-center gap-2 pointer-events-auto">
|
|
93
|
+
<div className="relative flex items-center gap-2 pointer-events-auto phone:flex-wrap phone:gap-1.5">
|
|
94
94
|
{/* Freshness/liveness status — backed for legibility over the canvas but
|
|
95
95
|
borderless + divided off, so it reads as a status, not another control. */}
|
|
96
96
|
{leadingSlot && (
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { stringify } from 'yaml'
|
|
4
|
+
import { CrossplaneApplyNotice, CrossplaneDeleteNotice, CrossplaneYamlApplyNotice } from './CrossplaneOperationNotice'
|
|
5
|
+
|
|
6
|
+
const mr = {
|
|
7
|
+
apiVersion: 's3.example.test/v1', kind: 'Bucket',
|
|
8
|
+
metadata: { name: 'archive', annotations: { 'crossplane.io/paused': 'true' }, finalizers: ['finalizer.managedresource.crossplane.io'] },
|
|
9
|
+
spec: { providerConfigRef: { name: 'default' } },
|
|
10
|
+
}
|
|
11
|
+
const xr = { ...mr, kind: 'Composite', spec: { resourceRefs: [] }, metadata: { ...mr.metadata, finalizers: ['composite.apiextensions.crossplane.io'] } }
|
|
12
|
+
const claim = { ...mr, kind: 'Claim', spec: { resourceRef: { name: 'xr' }, compositionRef: { name: 'composition' } }, metadata: { ...mr.metadata, finalizers: ['finalizer.apiextensions.crossplane.io'] } }
|
|
13
|
+
const html = (element: React.ReactNode) => renderToStaticMarkup(element)
|
|
14
|
+
|
|
15
|
+
describe('Crossplane operation context', () => {
|
|
16
|
+
it.each([mr, xr, claim])('identifies its own paused deletion cleanup for $kind', data => {
|
|
17
|
+
const result = html(<CrossplaneDeleteNotice resources={[data]} />)
|
|
18
|
+
expect(result).toContain('This resource holds a Crossplane finalizer')
|
|
19
|
+
expect(result).toContain('Kubernetes can accept the deletion request')
|
|
20
|
+
expect(result).toContain('defer finalizer cleanup')
|
|
21
|
+
expect(result).not.toContain('stuck in Terminating')
|
|
22
|
+
expect(result).toContain('requests resumption')
|
|
23
|
+
expect(result).not.toContain('will delete external')
|
|
24
|
+
})
|
|
25
|
+
it('distinguishes an already requested deletion', () => {
|
|
26
|
+
const result = html(<CrossplaneDeleteNotice resources={[{ ...mr, metadata: { ...mr.metadata, deletionTimestamp: '2026-10-26T00:00:00Z' } }]} />)
|
|
27
|
+
expect(result).toContain('Deletion requested while reconciliation is paused')
|
|
28
|
+
expect(result).toContain('Deletion has already been requested')
|
|
29
|
+
expect(result).not.toContain('Kubernetes can accept')
|
|
30
|
+
})
|
|
31
|
+
it('avoids claiming pause alone blocks deletion without a known finalizer', () => {
|
|
32
|
+
const result = html(<CrossplaneDeleteNotice resources={[{ ...mr, metadata: { ...mr.metadata, finalizers: ['other.example.test/cleanup'] } }]} />)
|
|
33
|
+
expect(result).toContain('pause annotation alone does not block Kubernetes deletion')
|
|
34
|
+
expect(result).not.toContain('cannot complete its finalizer cleanup')
|
|
35
|
+
})
|
|
36
|
+
it('explains the existing explicit force choice without promising a cascade', () => {
|
|
37
|
+
const result = html(<CrossplaneDeleteNotice resources={[mr]} force />)
|
|
38
|
+
expect(result).toContain('Force delete strips finalizers')
|
|
39
|
+
expect(result).toContain('can leave external or related resources behind')
|
|
40
|
+
expect(result).toContain('does not resume reconciliation')
|
|
41
|
+
expect(result).toContain('This resource has finalizers')
|
|
42
|
+
expect(result).not.toContain('These resources have finalizers')
|
|
43
|
+
})
|
|
44
|
+
it('names only the paused finalizer holders in a mixed blocker warning', () => {
|
|
45
|
+
const bare = { ...mr, metadata: { ...mr.metadata, name: 'bare', finalizers: [] } }
|
|
46
|
+
const active = { ...mr, metadata: { ...mr.metadata, name: 'active', annotations: {} } }
|
|
47
|
+
const result = html(<CrossplaneDeleteNotice resources={[mr, bare, active]} />)
|
|
48
|
+
expect(result).toContain('Of 3 selected resources, 1 has pause requested and holds')
|
|
49
|
+
expect(result).toContain('Bucket archive')
|
|
50
|
+
expect(result).not.toContain('Bucket bare')
|
|
51
|
+
expect(result).not.toContain('Bucket active')
|
|
52
|
+
const mixedForce = html(<CrossplaneDeleteNotice resources={[mr, bare, active]} force />)
|
|
53
|
+
expect(mixedForce).toContain('Of 3 selected resources, 1 has pause requested and finalizers')
|
|
54
|
+
const forced = html(<CrossplaneDeleteNotice resources={[bare]} force />)
|
|
55
|
+
expect(forced).toContain('Bucket bare')
|
|
56
|
+
expect(forced).not.toContain('0 of 1')
|
|
57
|
+
expect(forced).not.toContain('Force delete bypasses')
|
|
58
|
+
expect(forced).toContain('pause annotation alone does not block')
|
|
59
|
+
})
|
|
60
|
+
it('counts a mixed selection and caps names', () => {
|
|
61
|
+
const resources = Array.from({ length: 7 }, (_, i) => ({ ...mr, metadata: { ...mr.metadata, name: `paused-${i}` } }))
|
|
62
|
+
const active = { ...mr, metadata: { ...mr.metadata, name: 'active', annotations: {} } }
|
|
63
|
+
const result = html(<CrossplaneDeleteNotice resources={[...resources, active]} />)
|
|
64
|
+
expect(result).toContain('Of 8 selected resources, 7 have pause requested and hold')
|
|
65
|
+
expect(result).toContain('and 2 more')
|
|
66
|
+
expect(result).not.toContain('paused-6')
|
|
67
|
+
expect(result).not.toContain('Bucket active')
|
|
68
|
+
})
|
|
69
|
+
it.each([undefined, { metadata: mr.metadata }, { ...mr, metadata: { annotations: { 'crossplane.io/paused': 'false' } } }, { ...mr, metadata: { annotations: { 'crossplane.io/paused': true } } }])('omits context for unrelated or unannotated input %#', data => {
|
|
70
|
+
expect(html(<CrossplaneDeleteNotice resources={[data]} />)).toBe('')
|
|
71
|
+
expect(html(<CrossplaneApplyNotice resources={[data]} />)).toBe('')
|
|
72
|
+
})
|
|
73
|
+
it('retains own-controller context while independent failures exist', () => {
|
|
74
|
+
const data = { ...mr, status: { conditions: [{ type: 'Ready', status: 'False', reason: 'Unavailable' }] } }
|
|
75
|
+
expect(html(<CrossplaneApplyNotice resources={[data]} />)).toContain('Other resources' controllers may continue independently')
|
|
76
|
+
expect(data.status.conditions[0].reason).toBe('Unavailable')
|
|
77
|
+
})
|
|
78
|
+
it('uses conditional wording when an apply draft omits a pause owned by another manager', () => {
|
|
79
|
+
const after = { ...mr, metadata: { ...mr.metadata, annotations: {} } }
|
|
80
|
+
const result = html(<CrossplaneYamlApplyNotice yaml={stringify(after)} baselineYaml={stringify(mr)} />)
|
|
81
|
+
expect(result).toContain('Kubernetes can accept edits')
|
|
82
|
+
expect(result).toContain('requests resumption')
|
|
83
|
+
expect(result).toContain('Omitting the annotation from server-side apply YAML does not guarantee its removal')
|
|
84
|
+
expect(result).not.toContain('will resume')
|
|
85
|
+
})
|
|
86
|
+
it('detects pause added by the submitted or predicted manifest', () => {
|
|
87
|
+
expect(html(<CrossplaneYamlApplyNotice yaml={stringify(mr)} />)).toContain('Pause affects reconciliation')
|
|
88
|
+
})
|
|
89
|
+
it('names unknown live pause state only for apply without a baseline', () => {
|
|
90
|
+
const unpausedDraft = { ...mr, metadata: { ...mr.metadata, annotations: {} } }
|
|
91
|
+
const yaml = stringify(unpausedDraft)
|
|
92
|
+
const result = html(<CrossplaneYamlApplyNotice yaml={yaml} baselineUnavailable />)
|
|
93
|
+
expect(result).toContain('Existing pause state is unknown')
|
|
94
|
+
expect(result).toContain('Omitting the annotation from server-side apply YAML does not guarantee its removal')
|
|
95
|
+
expect(result).not.toContain('Pause affects reconciliation')
|
|
96
|
+
expect(html(<CrossplaneYamlApplyNotice yaml={yaml} />)).toBe('')
|
|
97
|
+
expect(html(<CrossplaneYamlApplyNotice yaml="kind: ConfigMap" baselineUnavailable />)).toBe('')
|
|
98
|
+
})
|
|
99
|
+
it('retains explicit submitted pause context alongside unknown live state', () => {
|
|
100
|
+
const result = html(<CrossplaneYamlApplyNotice yaml={stringify(mr)} baselineUnavailable />)
|
|
101
|
+
expect(result).toContain('Pause affects reconciliation')
|
|
102
|
+
expect(result).toContain('Live pause state is unverified')
|
|
103
|
+
expect(result).toContain('requests resumption')
|
|
104
|
+
})
|
|
105
|
+
it('recognizes acknowledged ref-less objects without broadening annotation-only detection', () => {
|
|
106
|
+
const raw = { ...mr, kind: 'Claim', spec: {}, status: { conditions: [{ type: 'Synced', status: 'False', reason: 'ReconcilePaused' }] } }
|
|
107
|
+
expect(html(<CrossplaneDeleteNotice resources={[raw]} />)).toContain('This resource holds a Crossplane finalizer')
|
|
108
|
+
const { status: _status, ...clean } = raw
|
|
109
|
+
expect(html(<CrossplaneYamlApplyNotice yaml={stringify(clean)} baselineUnavailable />)).toBe('')
|
|
110
|
+
expect(html(<CrossplaneApplyNotice resources={[{ ...raw, metadata: { ...raw.metadata, annotations: {} } }]} />)).toBe('')
|
|
111
|
+
})
|
|
112
|
+
it('binds raw acknowledgement to identity across versions without borrowing it for another API group', () => {
|
|
113
|
+
const raw = { ...mr, kind: 'Claim', spec: {}, status: { conditions: [{ type: 'Synced', status: 'False', reason: 'ReconcilePaused' }] } }
|
|
114
|
+
const check = (apiVersion: string | number) => {
|
|
115
|
+
const yaml = stringify({ ...raw, apiVersion, status: undefined })
|
|
116
|
+
return html(<CrossplaneYamlApplyNotice yaml={yaml} baselineYaml={yaml} resourceData={raw} previewDocuments={[{ index: 0, status: 'accepted', baselineYaml: yaml, predictedYaml: yaml }]} />)
|
|
117
|
+
}
|
|
118
|
+
expect(check('another.example.test/v1')).toBe('')
|
|
119
|
+
expect(check('s3.example.test/v2')).toContain('Server preview keeps the pause request')
|
|
120
|
+
expect(() => check(42)).not.toThrow()
|
|
121
|
+
})
|
|
122
|
+
it('does not borrow old raw acknowledgement through an old baseline when the effective target changes group', () => {
|
|
123
|
+
const raw = { ...mr, kind: 'Claim', spec: {}, status: { conditions: [{ type: 'Synced', status: 'False', reason: 'ReconcilePaused' }] } }
|
|
124
|
+
const baselineYaml = stringify({ ...raw, status: undefined })
|
|
125
|
+
const predictedYaml = stringify({ ...raw, apiVersion: 'another.example.test/v1', status: undefined })
|
|
126
|
+
const result = html(<CrossplaneYamlApplyNotice yaml={predictedYaml} baselineYaml={baselineYaml} resourceData={raw} previewDocuments={[{ index: 0, status: 'accepted', action: 'update', baselineYaml, predictedYaml }]} />)
|
|
127
|
+
expect(result).toBe('')
|
|
128
|
+
})
|
|
129
|
+
it.each(['direct', 'unavailable'] as const)('does not borrow a paused shape from another group through the original baseline in %s review', mode => {
|
|
130
|
+
const baselineYaml = stringify(mr)
|
|
131
|
+
const submittedYaml = stringify({ apiVersion: 'another.example.test/v1', kind: mr.kind, metadata: { name: mr.metadata.name }, spec: {} })
|
|
132
|
+
const result = html(<CrossplaneYamlApplyNotice yaml={submittedYaml} baselineYaml={baselineYaml} resourceData={mr} previewDocuments={mode === 'unavailable' ? [{ index: 0, status: 'unavailable', action: 'unknown', submittedYaml }] : undefined} />)
|
|
133
|
+
expect(result).toBe('')
|
|
134
|
+
})
|
|
135
|
+
it('retains same-target pause clearing across served versions when the submitted shape changes', () => {
|
|
136
|
+
const baselineYaml = stringify(mr)
|
|
137
|
+
const predictedYaml = stringify({ ...mr, apiVersion: 's3.example.test/v2', metadata: { ...mr.metadata, annotations: { 'crossplane.io/paused': 'false' } }, spec: {} })
|
|
138
|
+
const result = html(<CrossplaneYamlApplyNotice yaml={predictedYaml} baselineYaml={baselineYaml} previewDocuments={[{ index: 0, status: 'accepted', action: 'update', baselineYaml, predictedYaml }]} />)
|
|
139
|
+
expect(result).toContain('Server preview clears the pause request on Bucket archive')
|
|
140
|
+
expect(result).toContain('requests resumption')
|
|
141
|
+
})
|
|
142
|
+
it('does not use one raw target to classify unrelated documents in a batch', () => {
|
|
143
|
+
const raw = { ...mr, kind: 'Claim', spec: {}, status: { conditions: [{ type: 'Synced', status: 'False', reason: 'ReconcilePaused' }] } }
|
|
144
|
+
const clean = { ...raw, status: undefined }
|
|
145
|
+
const yaml = stringify(clean)
|
|
146
|
+
const result = html(<CrossplaneYamlApplyNotice yaml={yaml + '\n---\n' + yaml} resourceData={raw} previewDocuments={[{ index: 0, status: 'accepted', baselineYaml: yaml, predictedYaml: yaml }, { index: 1, status: 'accepted', baselineYaml: yaml, predictedYaml: yaml }]} />)
|
|
147
|
+
expect(result).toBe('')
|
|
148
|
+
})
|
|
149
|
+
it('handles invalid or empty YAML without disturbing the editor validation', () => {
|
|
150
|
+
expect(html(<CrossplaneYamlApplyNotice yaml={'spec: [\n'} />)).toBe('')
|
|
151
|
+
expect(html(<CrossplaneYamlApplyNotice yaml="" />)).toBe('')
|
|
152
|
+
})
|
|
153
|
+
})
|