@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,4 +1,4 @@
1
- import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'
1
+ import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from 'react'
2
2
  import { DURATION_TOAST_EXIT } from '../../utils/animation'
3
3
  import { Check, Terminal, X, AlertTriangle, Info } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
@@ -15,15 +15,13 @@ interface Toast {
15
15
  onDetailClick?: () => void
16
16
  }
17
17
 
18
+ type ToastOptions = Omit<Toast, 'id' | 'message' | 'dismissing'> & {
19
+ id?: string
20
+ duration?: number
21
+ }
22
+
18
23
  interface ToastContextType {
19
- showToast: (message: string, options?: {
20
- detail?: string
21
- command?: string
22
- type?: Toast['type']
23
- position?: { x: number; y: number }
24
- action?: Toast['action']
25
- onDetailClick?: () => void
26
- }) => void
24
+ showToast: (message: string, options?: ToastOptions) => void
27
25
  showCopied: (command: string, label?: string, event?: React.MouseEvent) => void
28
26
  showError: (message: string, detail?: string) => void
29
27
  showSuccess: (message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) => void
@@ -94,34 +92,42 @@ export function useToast() {
94
92
 
95
93
  export function ToastProvider({ children }: { children: ReactNode }) {
96
94
  const [toasts, setToasts] = useState<Toast[]>([])
95
+ const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())
97
96
 
98
97
  const animateDismiss = useCallback((id: string) => {
98
+ clearTimeout(timers.current.get(id))
99
99
  setToasts(prev => prev.map(t => t.id === id ? { ...t, dismissing: true } : t))
100
- setTimeout(() => {
100
+ timers.current.set(id, setTimeout(() => {
101
101
  setToasts(prev => prev.filter(t => t.id !== id))
102
- }, DURATION_TOAST_EXIT)
102
+ timers.current.delete(id)
103
+ }, DURATION_TOAST_EXIT))
103
104
  }, [])
104
105
 
105
- const showToast = useCallback((message: string, options?: {
106
- detail?: string
107
- command?: string
108
- type?: Toast['type']
109
- position?: { x: number; y: number }
110
- action?: Toast['action']
111
- onDetailClick?: () => void
112
- }) => {
113
- const id = Math.random().toString(36).slice(2)
114
- const toast: Toast = { id, message, ...options }
106
+ const showToast = useCallback((message: string, options?: ToastOptions) => {
107
+ const { id = Math.random().toString(36).slice(2), duration, ...toastOptions } = options ?? {}
108
+ const toast: Toast = { id, message, ...toastOptions }
115
109
 
116
- setToasts(prev => [...prev, toast])
110
+ clearTimeout(timers.current.get(id))
111
+ timers.current.delete(id)
112
+ setToasts(prev => prev.some(t => t.id === id)
113
+ ? prev.map(t => t.id === id ? toast : t)
114
+ : [...prev, toast])
117
115
 
118
116
  // Auto-dismiss: errors stay longer (10s), others 7s
119
- const dismissTime = options?.type === 'error' ? 10000 : 7000
120
- setTimeout(() => {
121
- animateDismiss(id)
122
- }, dismissTime)
117
+ const dismissTime = duration ?? (toast.type === 'error' ? 10000 : 7000)
118
+ if (dismissTime > 0) {
119
+ timers.current.set(id, setTimeout(() => animateDismiss(id), dismissTime))
120
+ }
123
121
  }, [animateDismiss])
124
122
 
123
+ useEffect(() => {
124
+ const activeTimers = timers.current
125
+ return () => {
126
+ activeTimers.forEach(clearTimeout)
127
+ activeTimers.clear()
128
+ }
129
+ }, [])
130
+
125
131
  const showCopied = useCallback((command: string, label?: string, event?: React.MouseEvent) => {
126
132
  navigator.clipboard.writeText(command)
127
133
 
@@ -187,6 +193,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
187
193
 
188
194
  return (
189
195
  <div
196
+ role="status"
190
197
  className={clsx(
191
198
  'flex items-start gap-3 p-4 rounded-lg shadow-2xl border backdrop-blur-sm',
192
199
  'w-[480px] max-w-[calc(100vw-32px)]',
@@ -0,0 +1,55 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // A tap fires mouseenter and focus. On an enabled control the tooltip must not
4
+ // pop over what the tap opens; on a disabled control the tap is how a touch
5
+ // user reads why it's disabled.
6
+ import { act } from 'react'
7
+ import { createRoot, type Root } from 'react-dom/client'
8
+ import { afterEach, expect, it, vi } from 'vitest'
9
+ import { Tooltip } from './Tooltip'
10
+
11
+ let root: Root | undefined
12
+ let host: HTMLDivElement | undefined
13
+ afterEach(() => {
14
+ act(() => root?.unmount())
15
+ host?.remove()
16
+ vi.useRealTimers()
17
+ // The last touch time is module state; a key press clears it for the next file.
18
+ document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
19
+ })
20
+
21
+ function render(child: React.ReactNode) {
22
+ host = document.createElement('div')
23
+ document.body.appendChild(host)
24
+ root = createRoot(host)
25
+ act(() => root!.render(<Tooltip content="Why" delay={0}>{child}</Tooltip>))
26
+ return host.querySelector('span')!
27
+ }
28
+
29
+ function touchPress() {
30
+ const e = new Event('pointerdown', { bubbles: true }) as Event & { pointerType: string }
31
+ Object.defineProperty(e, 'pointerType', { value: 'touch' })
32
+ document.body.dispatchEvent(e)
33
+ }
34
+
35
+ function hover(trigger: Element) {
36
+ vi.useFakeTimers()
37
+ act(() => { trigger.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, relatedTarget: document.body })) })
38
+ act(() => { vi.advanceTimersByTime(10) })
39
+ }
40
+
41
+ const tooltipShown = () => document.body.textContent?.includes('Why') ?? false
42
+
43
+ it('stays closed when a tap activates an enabled control', () => {
44
+ const trigger = render(<button type="button">Open</button>)
45
+ touchPress()
46
+ hover(trigger)
47
+ expect(tooltipShown()).toBe(false)
48
+ })
49
+
50
+ it('still explains a disabled control on tap', () => {
51
+ const trigger = render(<button type="button" disabled>Restart</button>)
52
+ touchPress()
53
+ hover(trigger)
54
+ expect(tooltipShown()).toBe(true)
55
+ })
@@ -2,6 +2,9 @@ import { ReactNode, useState, useRef, useEffect, useCallback } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { clsx } from 'clsx'
4
4
  import { computeTooltipPosition } from './tooltip-position'
5
+ import { isTouchIntent } from '../../utils/touch-intent'
6
+
7
+ const ENABLED_CONTROL = 'button:not(:disabled), a[href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [role="button"]:not([aria-disabled="true"])'
5
8
 
6
9
  // Module-level singleton coordinator: only one Tooltip can be visible
7
10
  // at a time across the whole app. Without this, two Tooltip instances
@@ -221,6 +224,8 @@ export function Tooltip({
221
224
  }
222
225
  }, [isVisible])
223
226
 
227
+ const tapActivatesControl = () => isTouchIntent() && !!triggerRef.current?.querySelector(ENABLED_CONTROL)
228
+
224
229
  if ((disabled && !preserveWrapperWhenDisabled) || !content) {
225
230
  return <>{children}</>
226
231
  }
@@ -231,9 +236,13 @@ export function Tooltip({
231
236
  ref={triggerRef}
232
237
  className={clsx('inline-flex max-w-full', wrapperClassName)}
233
238
  style={wrapperStyle}
234
- onMouseEnter={showTooltip}
239
+ // A tap fires mouseenter and focus too. On an enabled control the
240
+ // tap has its own action (often opening a menu), and the tooltip
241
+ // would pop over it; on disabled or plain content the tap is the
242
+ // only way a touch user can read the explanation, so it still shows.
243
+ onMouseEnter={() => { if (!tapActivatesControl()) showTooltip() }}
235
244
  onMouseLeave={scheduleHideTooltip}
236
- onFocus={showTooltip}
245
+ onFocus={() => { if (!tapActivatesControl()) showTooltip() }}
237
246
  onBlur={hideTooltip}
238
247
  // pointerdown fires before click, so the tooltip is gone before
239
248
  // the trigger's action runs. Required when the child handles
@@ -38,3 +38,55 @@ describe('YamlReview after a failed apply', () => {
38
38
  expect(review()).not.toContain('changed after your review')
39
39
  })
40
40
  })
41
+
42
+
43
+ it('retains pause context when the prediction removes the annotation without claiming confirmed resumption', () => {
44
+ const baselineYaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: archive\n annotations:\n crossplane.io/paused: "true"\nspec:\n providerConfigRef:\n name: default\n'
45
+ const predictedYaml = baselineYaml.replace(' crossplane.io/paused: "true"', ' crossplane.io/paused: "false"')
46
+ const result = renderToString(<YamlReview submittedYaml={predictedYaml} documents={[{ index: 0, status: 'accepted', action: 'update', baselineYaml, predictedYaml }]} onBack={() => {}} onApply={() => {}} />)
47
+ expect(result).toContain('Pause affects reconciliation')
48
+ expect(result).toContain('requests resumption')
49
+ expect(result).not.toContain('will resume')
50
+ expect(result.replaceAll('<!-- -->', '')).toContain('Server preview clears the pause request on Bucket archive')
51
+ })
52
+
53
+
54
+ it('shows pause context for an unselected later document because Apply submits the whole batch', () => {
55
+ const first = 'apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: ordinary\n'
56
+ const second = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: later-paused\n annotations:\n crossplane.io/paused: "true"\nspec:\n providerConfigRef:\n name: default\n'
57
+ const result = renderToString(<YamlReview submittedYaml={first + '\n---\n' + second} documents={[{ index: 0, status: 'accepted', predictedYaml: first }, { index: 1, status: 'accepted', predictedYaml: second }]} onBack={() => {}} onApply={() => {}} />)
58
+ expect(result).toContain('Pause affects reconciliation')
59
+ expect(result).toContain('Bucket later-paused')
60
+ })
61
+
62
+
63
+ it('states the actual retained pause in the prediction without inferring who preserved it', () => {
64
+ const baselineYaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: archive\n annotations:\n crossplane.io/paused: "true"\nspec:\n providerConfigRef:\n name: default\n'
65
+ const submittedYaml = baselineYaml.replace(' crossplane.io/paused: "true"', '')
66
+ const result = renderToString(<YamlReview submittedYaml={submittedYaml} documents={[{ index: 0, status: 'accepted', action: 'update', baselineYaml, predictedYaml: baselineYaml }]} onBack={() => {}} onApply={() => {}} />)
67
+ expect(result.replaceAll('<!-- -->', '')).toContain('Server preview keeps the pause request on Bucket archive')
68
+ expect(result).not.toContain('another field manager owns')
69
+ expect(result).not.toContain('Server preview clears')
70
+ })
71
+
72
+ it.each(['apply', 'create'] as const)('handles a not-previewable Crossplane document in %s mode without pretending submitted YAML is live', mode => {
73
+ const submittedYaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: archive\nspec:\n providerConfigRef:\n name: default\n'
74
+ const result = renderToString(<YamlReview submittedYaml={submittedYaml} mode={mode} documents={[{ index: 0, status: 'unavailable', submittedYaml }]} onBack={() => {}} onApply={() => {}} />)
75
+ expect(result.includes('Existing pause state is unknown')).toBe(mode === 'apply')
76
+ expect(result.includes('Omitting the annotation from server-side apply YAML does not guarantee its removal')).toBe(mode === 'apply')
77
+ expect(result).not.toContain('Server preview clears')
78
+ })
79
+
80
+
81
+ it.each(['create', 'unavailable'] as const)('keeps per-document pause evidence paired when the first document has no baseline (%s)', firstState => {
82
+ const firstYaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: fresh\nspec:\n providerConfigRef:\n name: default\n'
83
+ const pausedYaml = firstYaml.replace('name: fresh', 'name: archive\n annotations:\n crossplane.io/paused: "true"')
84
+ const first = firstState === 'create'
85
+ ? { index: 0, status: 'accepted' as const, action: 'create' as const, name: 'fresh', predictedYaml: firstYaml }
86
+ : { index: 0, status: 'unavailable' as const, action: 'unknown' as const, name: 'fresh', submittedYaml: firstYaml }
87
+ const result = renderToString(<YamlReview submittedYaml={firstYaml + '\n---\n' + pausedYaml} documents={[first, { index: 1, status: 'accepted', action: 'update', name: 'archive', baselineYaml: pausedYaml, predictedYaml: pausedYaml }]} onBack={() => {}} onApply={() => {}} />).replaceAll('<!-- -->', '')
88
+ expect(result).toContain('Server preview keeps the pause request on Bucket archive')
89
+ expect(result).not.toContain('Server preview clears the pause request')
90
+ expect(result).not.toContain('Server preview requests a pause on Bucket archive')
91
+ expect(result.includes('Live pause state is unverified for Bucket fresh')).toBe(firstState === 'unavailable')
92
+ })
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { AlertTriangle, ArrowLeft, Check, Loader2, ShieldAlert, ShieldCheck, X } from 'lucide-react'
3
3
  import { normalizeYamlForReview, splitYamlDocuments } from '../../utils/yaml'
4
+ import { CrossplaneYamlApplyNotice } from './CrossplaneOperationNotice'
4
5
  import { Badge } from './Badge'
5
6
  import { StatusDot } from './status-tone'
6
7
  import { YamlDiffEditor } from './YamlEditor'
@@ -25,6 +26,9 @@ export interface YamlPreviewResult {
25
26
 
26
27
  export interface YamlReviewProps {
27
28
  submittedYaml: string
29
+ mode?: 'apply' | 'create' | 'update'
30
+ /** Review YAML omits status; raw evidence is bound to one editor target. */
31
+ resourceData?: unknown
28
32
  originalYaml?: string
29
33
  documents: YamlPreviewResult[]
30
34
  nonAtomic?: boolean
@@ -85,6 +89,8 @@ export function reviewedResourceVersionsForPreview(documents: YamlPreviewResult[
85
89
 
86
90
  export function YamlReview({
87
91
  submittedYaml,
92
+ mode = 'apply',
93
+ resourceData,
88
94
  originalYaml,
89
95
  documents,
90
96
  nonAtomic = false,
@@ -363,6 +369,7 @@ export function YamlReview({
363
369
  </div>
364
370
 
365
371
  <div className="shrink-0 border-t border-theme-border bg-theme-elevated/80 px-4 py-3">
372
+ <CrossplaneYamlApplyNotice yaml={submittedYaml} baselineYaml={originalYaml} previewDocuments={documents} mode={mode} resourceData={resourceData} />
366
373
  {changedSinceReview && (
367
374
  <AlertBanner
368
375
  variant="warning"
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
3
  import { AlertBanner, EventsSection, ProblemAlerts, OperationalIssuesShownContext, RelatedResourcesSection, defaultConditionTone } from './drawer-components'
4
+ import type { Relationships } from '../../types'
4
5
 
5
6
  const problems = [
6
7
  { color: 'red' as const, message: 'Application is Degraded' },
@@ -42,6 +43,271 @@ describe('ProblemAlerts', () => {
42
43
  })
43
44
  })
44
45
 
46
+ function RelationshipsWithTruncatedChildren() {
47
+ return <RelatedResourcesSection relationships={{ children: [{ kind: 'Pod', namespace: 'shop', name: 'web-0' }], ownership: { children: { complete: false, truncated: true } } }} />
48
+ }
49
+
50
+ describe('RelatedResourcesSection ownership', () => {
51
+ const rs = { kind: 'ReplicaSet', group: 'apps', namespace: 'shop', name: 'web-7d9f8' }
52
+ const cm = { kind: 'ConfigMap', namespace: 'shop', name: 'web-config' }
53
+ const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'shop' }
54
+
55
+ it('names further owners apart from the owner', () => {
56
+ const html = renderToString(<RelatedResourcesSection relationships={{ owner: rs, owners: [rs, cm] }} onNavigate={() => {}} />)
57
+ expect(html).toContain('Also Owned By')
58
+ expect(html).toContain('web-config')
59
+ expect(html.match(/web-7d9f8/g)).toHaveLength(1)
60
+ })
61
+
62
+ it('says how Managed By differs from Owner', () => {
63
+ const html = renderToString(<RelatedResourcesSection relationships={{ managers: [app] }} onNavigate={() => {}} />)
64
+ expect(html).toContain("Kubernetes&#x27; garbage collector follows owner references, not these links.")
65
+ })
66
+
67
+ it('puts a coverage hint on a button a keyboard can reach', () => {
68
+ const html = renderToString(
69
+ <RelatedResourcesSection relationships={{ children: [rs], ownership: { children: { complete: false, notChecked: [{ reason: 'read_error' }] } } }} />,
70
+ )
71
+ expect(html).toMatch(/<button type="button"[^>]*aria-label="Radar&#x27;s API discovery missed some API groups/)
72
+ })
73
+
74
+ it('shows management apart from ownership', () => {
75
+ const html = renderToString(<RelatedResourcesSection relationships={{ managers: [app], manages: [rs] }} onNavigate={() => {}} />)
76
+ expect(html).toContain('Managed By')
77
+ expect(html).toContain('Manages')
78
+ expect(html).not.toContain('Owner')
79
+ expect(html).not.toContain('Children')
80
+ })
81
+
82
+ it('says which kinds a children list did not check', () => {
83
+ const html = renderToString(
84
+ <RelatedResourcesSection
85
+ relationships={{
86
+ children: [rs],
87
+ ownership: { children: { complete: false, notChecked: [{ group: 'apps', kind: 'ControllerRevision', namespace: 'shop', reason: 'radar_kind_not_collected' }, { group: 'discovery.k8s.io', kind: 'EndpointSlice', namespace: 'shop', reason: 'radar_kind_not_collected' }] } },
88
+ }}
89
+ />,
90
+ )
91
+ expect(html).toContain('Radar doesn&#x27;t collect ControllerRevision or EndpointSlice objects, so none of those are listed.')
92
+ expect(html).not.toContain('More children than shown')
93
+ })
94
+
95
+ it('says when a children list was cut short', () => {
96
+ const html = renderToString(<RelationshipsWithTruncatedChildren />)
97
+ expect(html).toContain('More children than shown.')
98
+ })
99
+
100
+ it('adds nothing to a complete children list, or one from an older Radar', () => {
101
+ for (const relationships of [{ children: [rs], ownership: { children: { complete: true } } }, { children: [rs] }]) {
102
+ const html = renderToString(<RelatedResourcesSection relationships={relationships} />)
103
+ expect(html).toContain('Children')
104
+ expect(html).not.toContain('Not checked')
105
+ }
106
+ })
107
+
108
+ it('says why no owner is named', () => {
109
+ const cases: Array<[Relationships['ownership'], string]> = [
110
+ [{ primaryOwnerState: 'gone', ownerRef: rs, children: { complete: true } }, 'ReplicaSet web-7d9f8 no longer exists.'],
111
+ [{ primaryOwnerState: 'replaced', ownerRef: rs, children: { complete: true } }, 'ReplicaSet web-7d9f8 was deleted and recreated; this object names the earlier one.'],
112
+ [{ primaryOwnerState: 'unknown', ownerRef: rs, children: { complete: true } }, 'Couldn&#x27;t check ReplicaSet web-7d9f8.'],
113
+ [
114
+ { primaryOwnerState: 'unknown', ownerRef: { kind: 'Endpoints', namespace: 'shop', name: 'web' }, ownerReason: 'radar_kind_not_collected', children: { complete: true } },
115
+ 'Couldn&#x27;t check Endpoints web (Radar doesn&#x27;t collect it).',
116
+ ],
117
+ [{ primaryOwnerState: 'withheld', children: { complete: true } }, 'Not visible to you.'],
118
+ [{ primaryOwnerState: 'unknown', children: { complete: true } }, 'Couldn&#x27;t check the owner it names.'],
119
+ [{ primaryOwnerState: 'gone', ownerRef: { kind: '', namespace: '', name: '' }, children: { complete: true } }, 'The owner it names no longer exists.'],
120
+ [{ primaryOwnerState: 'unresolvable', children: { complete: true } }, 'Its owner reference can&#x27;t be resolved in this cluster.'],
121
+ [{ primaryOwnerState: 'excluded', children: { complete: true } }, 'Its owner reference names an Event'],
122
+ ]
123
+ for (const [ownership, text] of cases) {
124
+ const html = renderToString(<RelatedResourcesSection relationships={{ ownership }} />)
125
+ expect(html).toContain('Owner')
126
+ expect(html).toContain(text)
127
+ }
128
+ })
129
+
130
+ it('lists several owners without a controller as owners', () => {
131
+ const html = renderToString(<RelatedResourcesSection relationships={{ owners: [rs, cm], ownership: { primaryOwnerState: 'several', children: { complete: true } } }} />)
132
+ expect(html).toContain('<span>Owners<!-- --> (2)</span>')
133
+ expect(html).toContain('None of them is its controller.')
134
+ expect(html).not.toContain('<span>Owner</span>')
135
+ expect(html).not.toContain('Also Owned By')
136
+ })
137
+
138
+ it('lists other owners beside a missing controller as also owned by', () => {
139
+ const html = renderToString(<RelatedResourcesSection relationships={{ owners: [cm], ownership: { primaryOwnerState: 'gone', ownerRef: rs, children: { complete: true } } }} />)
140
+ expect(html).toContain('<span>Owner</span>')
141
+ expect(html).toContain('ReplicaSet web-7d9f8 no longer exists.')
142
+ expect(html).toContain('Also Owned By')
143
+ })
144
+
145
+ it('says why owner and children could not be read at all', () => {
146
+ expect(renderToString(<RelatedResourcesSection relationships={{ ownership: { unavailableReason: 'connection_changed', children: { complete: false } } }} />)).toContain(
147
+ 'Owner and children couldn&#x27;t be read: Radar&#x27;s connection changed meanwhile.',
148
+ )
149
+ expect(renderToString(<RelatedResourcesSection relationships={{ ownership: { unavailableReason: 'user_forbidden', children: { complete: false } } }} />)).toContain(
150
+ 'your access lets you open this object, but not list its kind here.',
151
+ )
152
+ for (const reason of ['object_not_found', 'read_error', 'cluster_disconnected']) {
153
+ const html = renderToString(<RelatedResourcesSection relationships={{ ownership: { unavailableReason: reason, children: { complete: false } } }} />)
154
+ expect(html).toContain('Owner and children couldn&#x27;t be read: ')
155
+ expect(html).not.toContain('read: couldn')
156
+ }
157
+ })
158
+
159
+ it('says outright when children could not be checked for an unusual reason, even with none found', () => {
160
+ const loading = renderToString(
161
+ <RelatedResourcesSection relationships={{ ownership: { children: { complete: false, notChecked: [{ kind: 'Pod', namespace: 'shop', reason: 'kind_sync_pending' }] } } }} />,
162
+ )
163
+ expect(loading).toContain('Children')
164
+ expect(loading).toContain('Couldn&#x27;t check Pod (still loading).')
165
+ const routine = renderToString(
166
+ <RelatedResourcesSection relationships={{ ownership: { children: { complete: false, notChecked: [{ kind: 'Lease', namespace: 'shop', reason: 'radar_kind_not_collected' }] } } }} />,
167
+ )
168
+ expect(routine).toBe('')
169
+ const leaf = renderToString(
170
+ <RelatedResourcesSection kind="configmaps" relationships={{ ownership: { children: { complete: false, notChecked: [{ kind: 'Lease', namespace: 'shop', reason: 'radar_kind_not_collected' }] } } }} />,
171
+ )
172
+ expect(leaf).toBe('')
173
+ const owner = renderToString(
174
+ <RelatedResourcesSection kind="services" relationships={{ ownership: { children: { complete: false, notChecked: [{ kind: 'EndpointSlice', namespace: 'shop', reason: 'radar_kind_not_collected' }] } } }} />,
175
+ )
176
+ expect(owner).toContain('Children')
177
+ expect(owner).toContain('No children among the kinds Radar checked.')
178
+ const ownerComplete = renderToString(<RelatedResourcesSection kind="services" relationships={{ ownership: { children: { complete: true } } }} />)
179
+ expect(ownerComplete).toBe('')
180
+ const elsewhere = renderToString(
181
+ <RelatedResourcesSection
182
+ relationships={{ children: [rs], ownership: { children: { complete: false, notChecked: [{ kind: 'Pod', namespace: 'other', reason: 'radar_namespace_not_collected' }] } } }}
183
+ />,
184
+ )
185
+ expect(elsewhere).toContain('Radar doesn&#x27;t collect every namespace, so children in the others aren&#x27;t listed.')
186
+ expect(elsewhere).not.toContain('Couldn&#x27;t check')
187
+ })
188
+
189
+ it('says why a GitOps manager\'s member list could not be read', () => {
190
+ const unread = renderToString(
191
+ <RelatedResourcesSection relationships={{ ownership: { children: { complete: true }, manages: { membership: 'unreadable', unavailableReason: 'kind_sync_pending' } } }} />,
192
+ )
193
+ expect(unread).toContain('Manages')
194
+ expect(unread).toContain('Couldn&#x27;t read the members its status records (still loading).')
195
+ const noList = renderToString(<RelatedResourcesSection relationships={{ manages: [rs], ownership: { children: { complete: true }, manages: { membership: 'unreadable' } } }} />)
196
+ expect(noList).not.toContain('Couldn&#x27;t read')
197
+ expect(noList).toContain('Its status has no member list Radar can read now, so these are the resources the topology links to it.')
198
+ const cut = renderToString(
199
+ <RelatedResourcesSection relationships={{ manages: [rs], ownership: { children: { complete: true }, manages: { membership: 'unreadable', truncated: true } } }} />,
200
+ )
201
+ expect(cut).toContain('More members than shown.')
202
+ const none = renderToString(<RelatedResourcesSection relationships={{ ownership: { children: { complete: true }, manages: { membership: 'unreadable' } } }} />)
203
+ expect(none).toContain('Its status has no member list Radar can read now.')
204
+ expect(none).not.toContain('these are the resources')
205
+ })
206
+
207
+ it('hints, without warning, at kinds API discovery missed', () => {
208
+ const html = renderToString(
209
+ <RelatedResourcesSection relationships={{ children: [rs], ownership: { children: { complete: false, notChecked: [{ reason: 'read_error' }] } } }} />,
210
+ )
211
+ expect(html).toContain('Radar&#x27;s API discovery missed some API groups, so children of their kinds aren&#x27;t listed.')
212
+ expect(html).not.toContain('Couldn&#x27;t check')
213
+ })
214
+
215
+ it("says a remote manager's members aren't listed", () => {
216
+ const html = renderToString(<RelatedResourcesSection relationships={{ ownership: { children: { complete: true }, manages: { membership: 'remote' } } }} />)
217
+ expect(html).toContain('Manages')
218
+ expect(html).toContain("Its destination isn&#x27;t this cluster as far as Radar can tell, so its members aren&#x27;t listed.")
219
+ })
220
+
221
+ describe('titles managers the way the chip does', () => {
222
+ const other = { ...app, name: 'other' }
223
+ const title = (relationships: Relationships) => {
224
+ const html = renderToString(<RelatedResourcesSection relationships={relationships} onNavigate={() => {}} />)
225
+ return html.includes('Listed By') ? 'Listed By' : html.includes('Managed By') ? 'Managed By' : 'none'
226
+ }
227
+ const answer = (state: 'listed' | 'shared' | 'disagree' | 'agree', recorders = [app]) => ({ state, direct: { object: rs, recorders } })
228
+ it.each(['listed', 'shared', 'disagree'] as const)('a %s answer lists its recorders', state => {
229
+ expect(title({ managers: [app], ownership: { children: { complete: true }, manager: answer(state) } })).toBe('Listed By')
230
+ })
231
+ it('an agree answer manages', () => {
232
+ expect(title({ managers: [app], ownership: { children: { complete: true }, manager: answer('agree') } })).toBe('Managed By')
233
+ })
234
+ it('a manager that is not a direct recorder keeps Managed By', () => {
235
+ expect(title({ managers: [app, other], ownership: { children: { complete: true }, manager: answer('listed') } })).toBe('Managed By')
236
+ })
237
+ it('an inherited answer keeps Managed By', () => {
238
+ expect(title({ managers: [app], ownership: { children: { complete: true }, manager: { ...answer('listed'), via: { object: cm, recorders: [app] } } } })).toBe('Managed By')
239
+ })
240
+ it('a note without managers keeps Managed By', () => {
241
+ expect(title({ ownership: { children: { complete: true }, manager: answer('listed', []), managers: { remote: 1 } } })).toBe('Managed By')
242
+ })
243
+ it('an older Radar without an answer keeps Managed By', () => {
244
+ expect(title({ managers: [app] })).toBe('Managed By')
245
+ })
246
+ })
247
+
248
+ it('says which direction Managed By comes from', () => {
249
+ const html = renderToString(<RelatedResourcesSection relationships={{ managers: [app] }} onNavigate={() => {}} />)
250
+ expect(html).toContain('Argo CD Applications or Flux objects whose status lists this resource, and other controllers that manage it without an owner reference.')
251
+ })
252
+
253
+ it("says outright what it couldn't check about this resource when it lists none", () => {
254
+ const html = renderToString(
255
+ <RelatedResourcesSection relationships={{ ownership: { children: { complete: true }, managers: { remote: 1, notChecked: [{ group: 'argoproj.io', kind: 'Application', reason: 'kind_sync_pending' }] } } }} />,
256
+ )
257
+ expect(html).toContain('Managed By')
258
+ expect(html).toContain('1 manager lists it for a cluster Radar can&#x27;t confirm is this one; couldn&#x27;t check Application (still loading).')
259
+ })
260
+
261
+ it("keeps it to the hint beside a listed manager", () => {
262
+ const html = renderToString(
263
+ <RelatedResourcesSection relationships={{ managers: [app], ownership: { children: { complete: true }, managers: { remote: 1 } } }} onNavigate={() => {}} />,
264
+ )
265
+ expect(html).toMatch(/aria-label="[^"]*1 manager lists it for a cluster Radar can&#x27;t confirm is this one/)
266
+ expect(html).not.toMatch(/>1 Manager lists it/)
267
+ })
268
+
269
+ it("keeps a manager kind discovery couldn't read to the hint, as it would show on every resource", () => {
270
+ const gaps = [{ group: 'argoproj.io', kind: 'Application', reason: 'read_error' }, { group: 'kustomize.toolkit.fluxcd.io', kind: 'Kustomization', reason: 'read_error' }]
271
+ const html = renderToString(<RelatedResourcesSection relationships={{ children: [rs], ownership: { children: { complete: true }, managers: { notChecked: gaps } } }} onNavigate={() => {}} />)
272
+ expect(html).not.toContain('Managed By')
273
+ const listed = renderToString(<RelatedResourcesSection relationships={{ managers: [app], ownership: { children: { complete: true }, managers: { notChecked: gaps } } }} onNavigate={() => {}} />)
274
+ expect(listed).toContain('Radar couldn&#x27;t search Application objects, so those managers aren&#x27;t checked.')
275
+ expect(listed).toContain('Radar couldn&#x27;t search Kustomization objects, so those managers aren&#x27;t checked.')
276
+ expect(listed).not.toContain('Radar couldn&#x27;t search Application objects, so those managers aren&#x27;t checked.</div><div>Radar couldn&#x27;t search Application')
277
+ })
278
+
279
+ it('keeps an unreadable list to the hint, since it may name any resource', () => {
280
+ const none = renderToString(<RelatedResourcesSection relationships={{ children: [rs], ownership: { children: { complete: true }, managers: { unreadable: 1 } } }} onNavigate={() => {}} />)
281
+ expect(none).not.toContain('Managed By')
282
+ const listed = renderToString(<RelatedResourcesSection relationships={{ managers: [app], ownership: { children: { complete: true }, managers: { unreadable: 2 } } }} onNavigate={() => {}} />)
283
+ expect(listed).toMatch(/aria-label="[^"]*2 managers&#x27; member lists can&#x27;t be read/)
284
+ })
285
+
286
+ it('says when more than one manager lists a resource', () => {
287
+ const other = { ...app, name: 'other' }
288
+ const html = renderToString(<RelatedResourcesSection relationships={{ managers: [app, other] }} onNavigate={() => {}} />)
289
+ expect(html).toContain('More than one manager lists or links to it.')
290
+ })
291
+
292
+ it('shows no Managed By when nothing lists the resource and the search was settled', () => {
293
+ const html = renderToString(<RelatedResourcesSection relationships={{ children: [rs], ownership: { children: { complete: true } } }} onNavigate={() => {}} />)
294
+ expect(html).not.toContain('Managed By')
295
+ })
296
+
297
+ it('says a recorded member list with nothing to show records none', () => {
298
+ const html = renderToString(<RelatedResourcesSection relationships={{ ownership: { children: { complete: true }, manages: { membership: 'recorded' } } }} />)
299
+ expect(html).toContain('Manages')
300
+ expect(html).toContain('Its status records no members to show.')
301
+ })
302
+
303
+ it('counts recorded members it does not list', () => {
304
+ const html = renderToString(
305
+ <RelatedResourcesSection relationships={{ manages: [rs], ownership: { children: { complete: true }, manages: { membership: 'recorded', missing: 1, withheld: 2 } } }} />,
306
+ )
307
+ expect(html).toContain('Its status also lists 1 not in the cluster, 2 you can&#x27;t view.')
308
+ })
309
+ })
310
+
45
311
  describe('RelatedResourcesSection', () => {
46
312
  it('renders an associated Node', () => {
47
313
  const html = renderToString(
@@ -236,3 +502,17 @@ describe('AlertBanner optional actions', () => {
236
502
  expect(html).toContain('flex-wrap'); if (message) expect(html).toContain(message)
237
503
  })
238
504
  })
505
+
506
+
507
+ describe('RelatedResourcesSection capped lists', () => {
508
+ it('marks a list the server cut short as a lower bound', () => {
509
+ const pods = Array.from({ length: 12 }, (_, i) => ({ kind: 'Pod', namespace: 'shop', name: `web-${i}` }))
510
+ const text = (relationships: Relationships) => renderToString(<RelatedResourcesSection relationships={relationships} onNavigate={() => {}} />).replace(/<!-- -->/g, '')
511
+ const capped = text({ children: pods, ownership: { children: { complete: false, truncated: true } } })
512
+ expect(capped).toContain('Children (12+)')
513
+ expect(capped).toContain('Show the 12 listed')
514
+ const whole = text({ children: pods, ownership: { children: { complete: true } } })
515
+ expect(whole).toContain('Children (12)')
516
+ expect(whole).toContain('Show all 12')
517
+ })
518
+ })