@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.
Files changed (101) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/charts/AreaChart.test.tsx +39 -1
  5. package/src/components/charts/AreaChart.tsx +32 -13
  6. package/src/components/charts/axis.test.ts +17 -1
  7. package/src/components/charts/axis.ts +13 -0
  8. package/src/components/charts/format.test.ts +159 -2
  9. package/src/components/charts/format.ts +118 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  11. package/src/components/compare/ResourceCompareView.tsx +6 -2
  12. package/src/components/gitops/GitOpsTableView.tsx +13 -8
  13. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  14. package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
  15. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  16. package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
  17. package/src/components/gitops/insights/insights-helpers.ts +5 -3
  18. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  19. package/src/components/issues/IssueGuidance.tsx +48 -0
  20. package/src/components/issues/IssuesView.tsx +17 -5
  21. package/src/components/issues/diagnostic.ts +2 -0
  22. package/src/components/issues/index.ts +1 -0
  23. package/src/components/issues/severity.ts +2 -0
  24. package/src/components/issues/types.ts +10 -0
  25. package/src/components/logs/LogCore.tsx +13 -11
  26. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  27. package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
  28. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  29. package/src/components/resources/ResourcesSidebar.tsx +26 -5
  30. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  31. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  32. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  33. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  34. package/src/components/resources/ResourcesView.tsx +238 -66
  35. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  36. package/src/components/resources/generic-status.ts +15 -0
  37. package/src/components/resources/printer-columns.test.ts +36 -0
  38. package/src/components/resources/printer-columns.ts +12 -1
  39. package/src/components/resources/readiness-gates.test.ts +95 -0
  40. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  41. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  42. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  43. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  44. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  45. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  46. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  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.ts +80 -2
  56. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  57. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  58. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  59. package/src/components/shared/DetailShell.tsx +14 -3
  60. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  61. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  62. package/src/components/shared/EditableYamlView.tsx +18 -3
  63. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  64. package/src/components/shared/FilterRailPanel.tsx +62 -0
  65. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  66. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  67. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  68. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  69. package/src/components/shared/ManagedByChip.tsx +135 -2
  70. package/src/components/shared/ResourceActionsBar.tsx +22 -6
  71. package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
  72. package/src/components/shared/SetImageDialog.tsx +3 -0
  73. package/src/components/topology/TopologyControls.tsx +1 -1
  74. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  75. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  77. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  78. package/src/components/ui/PageHeader.tsx +3 -2
  79. package/src/components/ui/SummaryTile.tsx +13 -3
  80. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  81. package/src/components/ui/Tooltip.tsx +11 -2
  82. package/src/components/ui/YamlReview.test.tsx +52 -0
  83. package/src/components/ui/YamlReview.tsx +7 -0
  84. package/src/components/ui/drawer-components.test.tsx +27 -0
  85. package/src/components/ui/drawer-components.tsx +22 -42
  86. package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
  87. package/src/components/workload/WorkloadView.tsx +57 -34
  88. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  89. package/src/hooks/useKeepInViewport.test.ts +27 -0
  90. package/src/hooks/useKeepInViewport.ts +87 -0
  91. package/src/types/core.ts +54 -1
  92. package/src/types/gitops-insights.ts +8 -0
  93. package/src/utils/api-resources.ts +1 -0
  94. package/src/utils/gitops-owner.ts +136 -8
  95. package/src/utils/gitops-write-guard.test.ts +332 -0
  96. package/src/utils/gitops-write-guard.ts +175 -11
  97. package/src/utils/index.ts +1 -0
  98. package/src/utils/read-actions.ts +34 -0
  99. package/src/utils/touch-intent.test.ts +39 -0
  100. package/src/utils/touch-intent.ts +35 -0
  101. 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, ManagerListing } 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'
@@ -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: GitOpsOwnerRef
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
- setShowDeleteConfirm(false)
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="Delete resource">
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&#x27; 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
+ })