@skyhook-io/k8s-ui 1.18.1 → 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 (85) 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/cluster-switcher/ClusterSwitcher.tsx +4 -0
  5. package/src/components/compare/ResourceCompareView.tsx +6 -2
  6. package/src/components/gitops/GitOpsTableView.tsx +13 -8
  7. package/src/components/issues/IssuesView.tsx +2 -2
  8. package/src/components/issues/diagnostic.ts +2 -0
  9. package/src/components/issues/severity.ts +1 -0
  10. package/src/components/logs/LogCore.tsx +13 -11
  11. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  12. package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
  13. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  14. package/src/components/resources/ResourcesSidebar.tsx +26 -5
  15. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  16. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  17. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  18. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  19. package/src/components/resources/ResourcesView.tsx +238 -66
  20. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  21. package/src/components/resources/generic-status.ts +15 -0
  22. package/src/components/resources/printer-columns.test.ts +36 -0
  23. package/src/components/resources/printer-columns.ts +12 -1
  24. package/src/components/resources/readiness-gates.test.ts +95 -0
  25. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  26. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  27. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  28. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  29. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  30. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  31. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  32. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  33. package/src/components/resources/renderers/index.ts +3 -0
  34. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  35. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  36. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  37. package/src/components/resources/resource-utils-flagger.ts +76 -0
  38. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  39. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  40. package/src/components/resources/resource-utils.ts +80 -2
  41. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  42. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  43. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  44. package/src/components/shared/DetailShell.tsx +14 -3
  45. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  46. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  47. package/src/components/shared/EditableYamlView.tsx +18 -3
  48. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  49. package/src/components/shared/FilterRailPanel.tsx +62 -0
  50. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  51. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  52. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  53. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  54. package/src/components/shared/ManagedByChip.tsx +135 -2
  55. package/src/components/shared/ResourceActionsBar.tsx +22 -6
  56. package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
  57. package/src/components/shared/SetImageDialog.tsx +3 -0
  58. package/src/components/topology/TopologyControls.tsx +1 -1
  59. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  60. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  61. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  62. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  63. package/src/components/ui/PageHeader.tsx +3 -2
  64. package/src/components/ui/SummaryTile.tsx +13 -3
  65. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  66. package/src/components/ui/Tooltip.tsx +11 -2
  67. package/src/components/ui/YamlReview.test.tsx +52 -0
  68. package/src/components/ui/YamlReview.tsx +7 -0
  69. package/src/components/ui/drawer-components.test.tsx +27 -0
  70. package/src/components/ui/drawer-components.tsx +22 -42
  71. package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
  72. package/src/components/workload/WorkloadView.tsx +57 -34
  73. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  74. package/src/hooks/useKeepInViewport.test.ts +27 -0
  75. package/src/hooks/useKeepInViewport.ts +87 -0
  76. package/src/types/core.ts +53 -1
  77. package/src/utils/api-resources.ts +1 -0
  78. package/src/utils/gitops-owner.ts +136 -8
  79. package/src/utils/gitops-write-guard.test.ts +332 -0
  80. package/src/utils/gitops-write-guard.ts +175 -11
  81. package/src/utils/index.ts +1 -0
  82. package/src/utils/read-actions.ts +34 -0
  83. package/src/utils/touch-intent.test.ts +39 -0
  84. package/src/utils/touch-intent.ts +35 -0
  85. package/vitest.config.ts +26 -0
@@ -0,0 +1,139 @@
1
+ import { useMemo, type ReactNode } from 'react'
2
+ import type { YamlPreviewResult } from './YamlReview'
3
+ import { parseAllDocuments } from 'yaml'
4
+ import { splitYamlDocuments } from '../../utils/yaml'
5
+ import { apiVersionToGroup } from '../../utils/navigation'
6
+ import { resourceKey } from '../../utils/resource-refs'
7
+ import { getCrossplanePauseState, isComposite, isCrossplanePaused, isManagedResource } from '../resources/resource-utils-crossplane'
8
+ import { AlertBanner } from './drawer-components'
9
+
10
+ const crossplaneFinalizers = new Set([
11
+ 'finalizer.managedresource.crossplane.io',
12
+ 'composite.apiextensions.crossplane.io',
13
+ 'finalizer.apiextensions.crossplane.io',
14
+ ])
15
+
16
+ function isCrossplaneOperationResource(data: any): boolean {
17
+ return isManagedResource(data) || isComposite(data) || getCrossplanePauseState(data) === 'acknowledged'
18
+ }
19
+
20
+ function isAnnotatedCrossplanePause(data: any): boolean {
21
+ return isCrossplanePaused(data) && isCrossplaneOperationResource(data)
22
+ }
23
+
24
+ function resourceNames(resources: any[]): string {
25
+ const names = resources.slice(0, 5).map(data =>
26
+ `${data.kind ?? 'Resource'} ${data.metadata?.namespace ? `${data.metadata.namespace}/` : ''}${data.metadata?.name ?? 'Unnamed'}`,
27
+ )
28
+ if (resources.length > 5) names.push(`and ${resources.length - 5} more`)
29
+ return names.join(', ')
30
+ }
31
+
32
+ export function CrossplaneDeleteNotice({ resources, force = false }: { resources: any[]; force?: boolean }) {
33
+ const paused = resources.filter(isAnnotatedCrossplanePause)
34
+ if (paused.length === 0) return null
35
+ const blocked = paused.filter(data =>
36
+ data.metadata?.finalizers?.some((finalizer: string) => crossplaneFinalizers.has(finalizer)),
37
+ )
38
+ const forceTargets = paused.filter(data => data.metadata?.finalizers?.length > 0)
39
+ const alreadyDeleting = blocked.some(data => data.metadata?.deletionTimestamp)
40
+ if (force ? forceTargets.length === 0 : blocked.length === 0) {
41
+ return <p className="text-xs text-theme-text-secondary">
42
+ Reconciliation pause is requested for {resourceNames(paused)}. The pause annotation alone does not block Kubernetes deletion; remaining finalizers determine whether cleanup must finish first.
43
+ </p>
44
+ }
45
+ return <AlertBanner
46
+ variant="warning"
47
+ className="mb-2"
48
+ title={force ? 'Force delete bypasses controller cleanup' : alreadyDeleting ? 'Deletion requested while reconciliation is paused' : 'Pause can hold deletion in Terminating'}
49
+ message={<>
50
+ {force
51
+ ? resources.length === 1
52
+ ? <p>Reconciliation pause is requested for {resourceNames(forceTargets)}. This resource has finalizers.</p>
53
+ : <p>Of {resources.length} selected resources, {forceTargets.length} {forceTargets.length === 1 ? 'has' : 'have'} pause requested and finalizers: {resourceNames(forceTargets)}.</p>
54
+ : resources.length === 1
55
+ ? <p>Pause requested on {resourceNames(blocked)}. This resource holds a Crossplane finalizer.</p>
56
+ : <p>Of {resources.length} selected resources, {blocked.length} {blocked.length === 1 ? 'has' : 'have'} pause requested and {blocked.length === 1 ? 'holds' : 'hold'} a Crossplane finalizer: {resourceNames(blocked)}.</p>}
57
+ {force ? <p className="mt-1">Force delete strips finalizers and can leave external or related resources behind. It does not resume reconciliation or guarantee what other controllers will delete.</p> : <p className="mt-1">{alreadyDeleting ? resources.length === 1 ? 'Deletion has already been requested.' : 'Some deletions have already been requested.' : 'Kubernetes can accept the deletion request.'} The pause annotation tells {blocked.length === 1 ? 'this resource’s controller' : 'these resources’ controllers'} to defer finalizer cleanup. Removing the pause requests resumption and may allow pending work, including deletion under {blocked.length === 1 ? 'its policies' : 'their policies'}.</p>}
58
+ </>}
59
+ />
60
+ }
61
+
62
+ export function CrossplaneApplyNotice({ resources, unknownResources = [], children }: { resources: any[]; unknownResources?: any[]; children?: ReactNode }) {
63
+ const paused = resources.filter(isAnnotatedCrossplanePause)
64
+ if (paused.length === 0 && unknownResources.length === 0) return null
65
+ return <AlertBanner
66
+ variant="info"
67
+ className="mb-2"
68
+ title={paused.length ? 'Pause affects reconciliation' : 'Existing pause state is unknown'}
69
+ message={<>
70
+ {paused.length > 0 && <>
71
+ <p>{resourceNames(paused)}.</p>
72
+ <p className="mt-1">Kubernetes can accept edits. While <code>crossplane.io/paused</code> remains <code>&quot;true&quot;</code>, {paused.length === 1 ? 'this resource’s controller will' : 'these resources’ controllers will'} defer reconciliation. Removing the annotation requests resumption and may allow pending work, including deletion. Other resources&apos; controllers may continue independently.</p>
73
+ </>}
74
+ {unknownResources.length > 0 && <>
75
+ <p className="mt-1">Live pause state is unverified for {resourceNames(unknownResources)}.</p>
76
+ <p className="mt-1">If <code>crossplane.io/paused</code> remains <code>&quot;true&quot;</code> on an existing resource, its controller defers reconciliation. Omitting the annotation from server-side apply YAML does not guarantee its removal.</p>
77
+ </>}
78
+ {children}
79
+ </>}
80
+ />
81
+ }
82
+
83
+ function yamlObjects(yaml: string): any[] {
84
+ try {
85
+ return parseAllDocuments(yaml).map(document => document.errors.length ? null : document.toJS({ maxAliasCount: 100 }))
86
+ } catch {
87
+ return []
88
+ }
89
+ }
90
+
91
+ export function CrossplaneYamlApplyNotice({ yaml, baselineYaml = '', baselineUnavailable = false, previewDocuments, mode = 'apply', resourceData }: {
92
+ yaml: string
93
+ baselineYaml?: string
94
+ baselineUnavailable?: boolean
95
+ previewDocuments?: YamlPreviewResult[]
96
+ mode?: 'apply' | 'create' | 'update'
97
+ resourceData?: unknown
98
+ }) {
99
+ const context = useMemo(() => {
100
+ const submitted = splitYamlDocuments(yaml).map(document => document.content)
101
+ const original = splitYamlDocuments(baselineYaml).map(document => document.content)
102
+ const matchesTarget = (data: any, target: any) => Boolean(data && target
103
+ && typeof data.apiVersion === 'string' && typeof target.apiVersion === 'string'
104
+ && resourceKey(apiVersionToGroup(data.apiVersion), data.kind, data.metadata?.namespace ?? '', data.metadata?.name)
105
+ === resourceKey(apiVersionToGroup(target.apiVersion), target.kind, target.metadata?.namespace ?? '', target.metadata?.name))
106
+ const pairs = (previewDocuments
107
+ ? previewDocuments.map(preview => ({
108
+ after: yamlObjects(preview.predictedYaml || preview.submittedYaml || submitted[preview.index] || '')[0],
109
+ before: yamlObjects(preview.baselineYaml || original[preview.index] || '')[0],
110
+ preview,
111
+ }))
112
+ : Array.from({ length: Math.max(submitted.length, original.length) }, (_, index) => ({
113
+ after: yamlObjects(submitted[index] || '')[0],
114
+ before: yamlObjects(original[index] || '')[0],
115
+ preview: undefined,
116
+ }))).map(pair => matchesTarget(pair.before, pair.after) ? pair : { ...pair, before: undefined })
117
+ const rawSource = pairs.length === 1 && isAnnotatedCrossplanePause(resourceData) ? resourceData as any : undefined
118
+ const matchesRaw = (data: any) => matchesTarget(data, rawSource)
119
+ const rawPause = matchesRaw(pairs[0]?.after) ? rawSource : undefined
120
+ const hasPause = (data: any) => isCrossplanePaused(data) && (isCrossplaneOperationResource(data) || Boolean(rawPause && matchesRaw(data)))
121
+ const resources = pairs.flatMap(pair => {
122
+ const source = hasPause(pair.after) ? pair.after : hasPause(pair.before) ? pair.before : undefined
123
+ return source ? [isAnnotatedCrossplanePause(source) ? source : rawPause] : []
124
+ })
125
+ const unknownResources = pairs.filter(pair => mode !== 'create' && (baselineUnavailable || pair.preview?.status === 'unavailable' && !pair.preview.predictedYaml && !pair.preview.baselineYaml && !pair.before && pair.preview.action !== 'create'))
126
+ .map(pair => pair.after).filter(data => data && (isCrossplaneOperationResource(data) || Boolean(rawPause && matchesRaw(data))))
127
+ const kept = pairs.filter(pair => pair.preview?.predictedYaml && hasPause(pair.before) && hasPause(pair.after)).map(pair => pair.after)
128
+ const requested = pairs.filter(pair => pair.preview?.predictedYaml && !hasPause(pair.before) && hasPause(pair.after)).map(pair => pair.after)
129
+ const cleared = pairs.filter(pair => pair.preview?.predictedYaml && hasPause(pair.before) && !isCrossplanePaused(pair.after)).map(pair => pair.before)
130
+ const omissionUnverified = pairs.some(pair => !pair.preview?.predictedYaml && hasPause(pair.before) && pair.after?.metadata?.annotations?.['crossplane.io/paused'] === undefined)
131
+ return { resources, unknownResources, kept, requested, cleared, omissionUnverified }
132
+ }, [yaml, baselineYaml, baselineUnavailable, previewDocuments, mode, resourceData])
133
+ return <CrossplaneApplyNotice resources={context.resources} unknownResources={context.unknownResources}>
134
+ {context.kept.length > 0 && <p className="mt-1">Server preview keeps the pause request on {resourceNames(context.kept)}.</p>}
135
+ {context.requested.length > 0 && <p className="mt-1">Server preview requests a pause on {resourceNames(context.requested)}.</p>}
136
+ {context.cleared.length > 0 && <p className="mt-1">Server preview clears the pause request on {resourceNames(context.cleared)}.</p>}
137
+ {context.omissionUnverified && <p className="mt-1">Omitting the annotation from server-side apply YAML does not guarantee its removal.</p>}
138
+ </CrossplaneApplyNotice>
139
+ }
@@ -22,15 +22,15 @@ afterEach(async () => {
22
22
  describe('Deletion preview states', () => {
23
23
  it('states the namespace-wide deletion consequence without relying on topology', async () => {
24
24
  await act(async () => root.render(<ForceDeleteConfirmDialog {...base} resourceKind="Namespace" resourceName="demo" namespaceName="" cascadeRootResolved={false} />))
25
- expect(document.body.textContent).toContain('delete the namespace "demo" and the resources in it')
25
+ expect(document.body.textContent).toContain('delete the namespace "demo" and all resources in it')
26
26
  expect(document.body.textContent).not.toContain("not in Radar's topology")
27
27
  })
28
28
  it('shows one advisory statement for a resolved preview', async () => {
29
29
  await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved cascadeDependents={[{ kind: 'Pod', namespace: 'demo', name: 'web-pod' }]} />))
30
- const toggle = [...document.querySelectorAll('button')].find(b => b.textContent?.includes('May affect'))!
30
+ const toggle = [...document.querySelectorAll('button')].find(b => b.textContent?.includes('May also delete'))!
31
31
  await act(async () => toggle.click())
32
32
  expect(document.body.textContent).toContain('web-pod')
33
- expect(document.body.textContent?.match(/does not predict what will be deleted/g)).toHaveLength(1)
33
+ expect(document.body.textContent).toContain("Kubernetes deletes only those that name this one as their owner")
34
34
  expect(document.body.textContent).not.toContain('Other resources may be affected')
35
35
  })
36
36
  it.each([[], undefined])('omits the advisory statement for an empty resolved preview (%j)', async (dependents) => {
@@ -42,9 +42,10 @@ describe('Deletion preview states', () => {
42
42
  })
43
43
  it('distinguishes an observed unresolved root from a fetch failure', async () => {
44
44
  await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved={false} />))
45
- expect(document.body.textContent).toContain("This resource is not in Radar's topology")
45
+ expect(document.body.textContent).toContain("Radar couldn't verify which dependent resources")
46
46
  await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved={false} cascadePreviewFailed cascadeDependents={[{ kind: 'Pod', namespace: 'demo', name: 'stale' }]} />))
47
- expect(document.body.textContent).toContain('Related-resource preview unavailable.')
47
+ expect(document.body.textContent).toContain('Deletion preview unavailable')
48
+ expect(document.body.textContent).toContain('Additional resources may be deleted.')
48
49
  expect(document.body.textContent).not.toContain('This resource is not in')
49
50
  expect(document.body.textContent).not.toContain('Other resources may be affected')
50
51
  expect(document.body.textContent).not.toContain('May affect')
@@ -55,8 +56,56 @@ describe('Deletion preview states', () => {
55
56
  })
56
57
  it('shows loading without presenting stale failure or success as a result', async () => {
57
58
  await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeLoading cascadePreviewFailed cascadeRootResolved />))
58
- expect(document.body.textContent).toContain('Checking related resources')
59
+ expect(document.body.textContent).toContain('Checking for dependent resources')
60
+ const confirm = Array.from(document.body.querySelectorAll('button')).find(button => button.textContent === 'Delete')!
61
+ expect(confirm.disabled).toBe(true)
59
62
  expect(document.body.textContent).not.toContain('unavailable')
60
63
  expect(document.body.textContent).not.toContain('does not predict')
61
64
  })
62
65
  })
66
+
67
+ it('separates observed collection, uncertainty and partial coverage', async () => {
68
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved cascadeDependents={[{kind:'Pod', namespace:'demo', name:'certain'}]} cascadeDetail={{basis:'ownerReferences', possibleDependents:[{kind:'ConfigMap', namespace:'demo', name:'possible'}], coverage:{complete:false,narrowed:true}}} />))
69
+ expect(document.body.textContent).toContain('Garbage collection is expected to delete 1 dependent resource')
70
+ expect(document.body.textContent).toContain('May also delete 1 resource')
71
+ expect(document.body.textContent).toContain('This preview is partial')
72
+ })
73
+ it('does not promise force deletion can undo controller cleanup', async () => {
74
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeDetail={{controllerTeardown:{controller:'Argo CD', action:'prune', destination:'remote'}}} />))
75
+ const force = document.querySelector('input[type="checkbox"]') as HTMLInputElement
76
+ await act(async () => force.click())
77
+ expect(document.body.textContent).toContain('remaining cleanup may be skipped')
78
+ expect(document.body.textContent).toContain('cannot undo it')
79
+ expect(document.body.textContent).not.toContain('Resources stay')
80
+ })
81
+
82
+ it('changes the pending-orphan warning when force is selected', async () => {
83
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} cascadeRootResolved cascadeDependents={[{kind:'Pod', namespace:'demo', name:'child'}]} cascadeDetail={{basis:'ownerReferences', propagation:'Orphan'}} />))
84
+ expect(document.body.textContent).toContain('dependents are orphaned')
85
+ expect(document.body.textContent).not.toContain('Garbage collection is expected')
86
+ await act(async () => (document.querySelector('input[type="checkbox"]') as HTMLInputElement).click())
87
+ expect(document.body.textContent).not.toContain('dependents are orphaned')
88
+ expect(document.body.textContent).toContain('Force delete removes the orphan finalizer')
89
+ expect(document.body.textContent).toContain('Garbage collection is expected')
90
+ })
91
+
92
+ describe('paused Crossplane deletion confirmation', () => {
93
+ it('keeps context and permissions while changing only the explicit force selection', async () => {
94
+ const resourceData = { metadata: { name: 'bucket', annotations: { 'crossplane.io/paused': 'true' }, finalizers: ['finalizer.managedresource.crossplane.io'] }, spec: { providerConfigRef: { name: 'default' } } }
95
+ await act(async () => root.render(<ForceDeleteConfirmDialog {...base} resourceData={resourceData} />))
96
+ expect(document.body.textContent).toContain('Pause can hold deletion in Terminating')
97
+ const confirm = [...document.querySelectorAll('button')].find(b => b.textContent === 'Delete')!
98
+ expect(confirm.disabled).toBe(false)
99
+ await act(async () => confirm.click())
100
+ expect(base.onConfirm).toHaveBeenLastCalledWith(false)
101
+ expect(document.body.textContent).toContain('Kubernetes can accept the deletion request')
102
+ const force = document.querySelector('input[type="checkbox"]') as HTMLInputElement
103
+ await act(async () => force.click())
104
+ expect(document.body.textContent).toContain('Force delete bypasses controller cleanup')
105
+ const forceConfirm = [...document.querySelectorAll('button')].find(b => b.textContent === 'Force Delete')!
106
+ await act(async () => forceConfirm.click())
107
+ expect(base.onConfirm).toHaveBeenLastCalledWith(true)
108
+ expect(resourceData.metadata.finalizers).toEqual(['finalizer.managedresource.crossplane.io'])
109
+ expect(resourceData.metadata.annotations['crossplane.io/paused']).toBe('true')
110
+ })
111
+ })
@@ -2,6 +2,8 @@ import { useState, useMemo } from 'react'
2
2
  import { Loader2 } from 'lucide-react'
3
3
  import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
4
4
  import { ConfirmDialog } from './ConfirmDialog'
5
+ import { AlertBanner } from './drawer-components'
6
+ import { CrossplaneDeleteNotice } from './CrossplaneOperationNotice'
5
7
  import { pluralize } from '../../utils/pluralize'
6
8
 
7
9
  export interface CascadeDependent {
@@ -11,6 +13,27 @@ export interface CascadeDependent {
11
13
  group?: string
12
14
  }
13
15
 
16
+ /** Cached deletion evidence; all lists are observations, not completion guarantees. */
17
+ export interface CascadeDetail {
18
+ propagation?: string
19
+ coverage?: { complete: boolean; narrowed?: boolean; truncated?: boolean; notChecked?: unknown[] }
20
+ unavailableReason?: string
21
+ notEnumerated?: string[]
22
+ /** "ownerReferences" when dependents are garbage-collection results; absent from older servers. */
23
+ basis?: string
24
+ /** Deleted unless another owner Radar can't see is still live. */
25
+ possibleDependents?: CascadeDependent[]
26
+ /** What Argo CD or Flux deletes through its finalizer. */
27
+ controllerTeardown?: {
28
+ controller: string
29
+ action: 'prune' | 'uninstall' | string
30
+ destination: 'local' | 'remote' | 'unknown'
31
+ resources?: CascadeDependent[]
32
+ /** Already being deleted: the teardown may be underway. */
33
+ terminating?: boolean
34
+ }
35
+ }
36
+
14
37
  interface ForceDeleteConfirmDialogProps {
15
38
  open: boolean
16
39
  onClose: () => void
@@ -23,6 +46,8 @@ interface ForceDeleteConfirmDialogProps {
23
46
  cascadeLoading?: boolean
24
47
  cascadeRootResolved?: boolean
25
48
  cascadePreviewFailed?: boolean
49
+ cascadeDetail?: CascadeDetail
50
+ resourceData?: unknown
26
51
  }
27
52
 
28
53
  export function ForceDeleteConfirmDialog({
@@ -36,10 +61,14 @@ export function ForceDeleteConfirmDialog({
36
61
  cascadeDependents,
37
62
  cascadeLoading,
38
63
  cascadeRootResolved,
64
+ cascadeDetail,
39
65
  cascadePreviewFailed,
66
+ resourceData,
40
67
  }: ForceDeleteConfirmDialogProps) {
41
68
  const [forceDelete, setForceDelete] = useState(false)
69
+ const orphaning = cascadeDetail?.propagation === 'Orphan' && !forceDelete
42
70
  const isNamespace = resourceKind.toLowerCase() === 'namespace'
71
+ const isCRD = ['customresourcedefinition', 'customresourcedefinitions'].includes(resourceKind.toLowerCase())
43
72
 
44
73
  function handleClose() {
45
74
  onClose()
@@ -57,41 +86,69 @@ export function ForceDeleteConfirmDialog({
57
86
  onConfirm={handleConfirm}
58
87
  title="Delete Resource"
59
88
  message={`Are you sure you want to delete "${resourceName}"?`}
60
- details={isNamespace ? `This will permanently delete the namespace "${resourceName}" and the resources in it.` : `This will permanently delete the ${resourceKind} "${resourceName}" from the "${namespaceName}" namespace.`}
89
+ details={isNamespace ? `This will permanently delete the namespace "${resourceName}" and all resources in it.` : isCRD ? `This deletes the CRD and ALL its instances across the cluster, including those not shown here.` : namespaceName
90
+ ? `This will permanently delete the ${resourceKind} "${resourceName}" from the "${namespaceName}" namespace.`
91
+ : `This will permanently delete the cluster-scoped ${resourceKind} "${resourceName}".`}
61
92
  confirmLabel={forceDelete ? 'Force Delete' : 'Delete'}
62
93
  variant="danger"
63
94
  isLoading={isLoading}
95
+ confirmDisabled={cascadeLoading}
64
96
  >
65
97
  <div className="flex flex-col gap-3 pb-1">
66
98
  {cascadeLoading && (
67
99
  <div className="flex items-center gap-2 text-xs text-theme-text-tertiary">
68
100
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
69
- Checking related resources...
101
+ Checking for dependent resources...
70
102
  </div>
71
103
  )}
72
104
 
73
- {!cascadeLoading && !cascadePreviewFailed && cascadeDependents && cascadeDependents.length > 0 && (
74
- <CascadeDependentsList dependents={cascadeDependents} />
105
+ {!cascadeLoading && !cascadePreviewFailed && !orphaning && cascadeDependents && cascadeDependents.length > 0 && (
106
+ cascadeDetail?.basis === 'ownerReferences' ? (
107
+ <CascadeDependentsList
108
+ dependents={cascadeDependents}
109
+ title={`Garbage collection is expected to delete ${pluralize(cascadeDependents.length, 'dependent resource')}`}
110
+ note="Kubernetes deletes these through their owner references. Finalizers may delay deletion. This is an observation of the current cache, not a guarantee of completion."
111
+ />
112
+ ) : (
113
+ <CascadeDependentsList
114
+ dependents={cascadeDependents}
115
+ title={`May also delete ${pluralize(cascadeDependents.length, 'related resource')}`}
116
+ note="This Radar version can't tell owned resources from other links. Kubernetes deletes only those that name this one as their owner."
117
+ />
118
+ )
75
119
  )}
76
120
 
77
- {!cascadeLoading && cascadePreviewFailed && (
78
- <p className="text-xs text-theme-text-tertiary">
79
- Related-resource preview unavailable.
80
- </p>
121
+ {!cascadeLoading && !cascadePreviewFailed && !orphaning && cascadeDetail?.possibleDependents && cascadeDetail.possibleDependents.length > 0 && (
122
+ <CascadeDependentsList
123
+ dependents={cascadeDetail.possibleDependents}
124
+ title={`May also delete ${pluralize(cascadeDetail.possibleDependents.length, 'resource')}`}
125
+ note="Another owner or a chain of owners could not be resolved. Kubernetes deletes these only if no live owner remains."
126
+ />
81
127
  )}
82
128
 
83
- {!isNamespace && !cascadeLoading && !cascadePreviewFailed && cascadeRootResolved === false && (
84
- <p className="text-xs text-theme-text-tertiary">
85
- This resource is not in Radar&apos;s topology, so its related resources cannot be previewed.
86
- </p>
129
+ {!cascadeLoading && !cascadePreviewFailed && cascadeDetail?.coverage && !orphaning && !cascadeDetail.coverage.complete ? (
130
+ <p className="text-xs text-theme-text-secondary">This preview is partial: Radar may not collect or you may not be allowed to read all dependents. Additional resources may be deleted.</p>
131
+ ) : null}
132
+ {!cascadeLoading && !cascadePreviewFailed && cascadeDetail?.controllerTeardown && (
133
+ <ControllerTeardownNotice teardown={cascadeDetail.controllerTeardown} force={forceDelete} />
87
134
  )}
88
135
 
89
- {!cascadeLoading && !cascadePreviewFailed && cascadeRootResolved === true && cascadeDependents && cascadeDependents.length > 0 && (
90
- <p className="text-xs text-theme-text-secondary">
91
- This list shows visible related resources; it does not predict what will be deleted.
92
- </p>
136
+ {!cascadeLoading && !cascadePreviewFailed && cascadeRootResolved === false && (
137
+ <AlertBanner
138
+ variant="warning"
139
+ title="Dependent resources unverified"
140
+ message="Radar couldn't verify which dependent resources Kubernetes will also delete. Additional resources may be deleted but aren't shown here."
141
+ className="mb-0"
142
+ />
93
143
  )}
94
144
 
145
+ {!cascadeLoading && !cascadePreviewFailed && orphaning && <p className="text-xs text-theme-text-secondary">Keeping the pending orphan policy: owner-reference dependents are orphaned. Controller cleanup may still delete resources.</p>}
146
+ {!cascadeLoading && !cascadePreviewFailed && forceDelete && cascadeDetail?.propagation === 'Orphan' && <p className="text-xs text-warning-text">Force delete removes the orphan finalizer and uses Background propagation. Owner-reference dependents may now be garbage collected.</p>}
147
+ {!cascadeLoading && !cascadePreviewFailed && !orphaning && cascadeDetail?.coverage?.complete && cascadeDetail.coverage.narrowed && <p className="text-xs text-theme-text-secondary">Only resources Radar observes and you can read are listed.</p>}
148
+ {!cascadeLoading && cascadePreviewFailed && <AlertBanner variant="warning" title="Deletion preview unavailable" message="Additional resources may be deleted." className="mb-0" />}
149
+ {!cascadeLoading && !cascadePreviewFailed && cascadeDetail?.notEnumerated?.map(item => <p key={item} className="text-xs text-theme-text-secondary">Not enumerated: {item}.</p>)}
150
+ <CrossplaneDeleteNotice resources={[resourceData]} force={forceDelete} />
151
+
95
152
  <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
96
153
  <input
97
154
  type="checkbox"
@@ -108,16 +165,64 @@ export function ForceDeleteConfirmDialog({
108
165
 
109
166
  const MAX_NAMES_PER_KIND = 8
110
167
 
111
- function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[] }) {
168
+ // How a managed resource opts out of its controller's teardown. A Helm
169
+ // uninstall keeps resources by Helm's own annotation, not Flux's.
170
+ function optOut(teardown: NonNullable<CascadeDetail['controllerTeardown']>): string | undefined {
171
+ if (teardown.action === 'uninstall') return 'helm.sh/resource-policy: keep'
172
+ if (teardown.controller === 'Argo CD') return 'argocd.argoproj.io/sync-options: Delete=false'
173
+ if (teardown.controller === 'Flux') return 'kustomize.toolkit.fluxcd.io/prune: disabled'
174
+ return undefined
175
+ }
176
+
177
+ function ControllerTeardownNotice({ teardown, force }: { teardown: NonNullable<CascadeDetail['controllerTeardown']>; force: boolean }) {
178
+ const resources = teardown.resources ?? []
179
+ const destinationNotice = teardown.destination === 'remote'
180
+ ? 'This controller targets a destination Radar cannot confirm is this cluster. Radar cannot list what it will delete there.'
181
+ : teardown.destination === 'unknown'
182
+ ? 'The destination cluster is unknown. Radar cannot show where controller cleanup will run.'
183
+ : ''
184
+ const what = teardown.action === 'uninstall' ? 'uninstall the Helm release' : 'delete the resources it manages'
185
+ if (force) {
186
+ return (
187
+ <AlertBanner
188
+ variant="warning"
189
+ title="Controller cleanup may be skipped"
190
+ message={<>
191
+ {destinationNotice && <>{destinationNotice} </>}
192
+ {teardown.terminating
193
+ ? `This is already being deleted, so ${teardown.controller} may already be working to ${what}. Force delete removes its finalizer but can't undo that.`
194
+ : `Force delete removes ${teardown.controller}'s finalizer, so ${teardown.controller}'s remaining cleanup may be skipped. Cleanup may already have started; removing the finalizer cannot undo it.`}
195
+ </>}
196
+ className="mb-0"
197
+ />
198
+ )
199
+ }
200
+ const title = teardown.destination === 'unknown'
201
+ ? `${teardown.controller} has a cleanup finalizer`
202
+ : teardown.action === 'uninstall'
203
+ ? `${teardown.controller} is configured to uninstall the Helm release`
204
+ : `${teardown.controller} is configured to prune managed resources`
205
+ return (
206
+ <CascadeDependentsList
207
+ dependents={resources}
208
+ title={resources.length > 0 ? `${title} (${resources.length} shown)` : title}
209
+ summary={`${destinationNotice ? destinationNotice + ' ' : ''}${teardown.action === 'uninstall' ? 'Helm uninstalls resources in its stored release manifest' : `${teardown.controller} deletes what its own inventory records`}, which can include resources Radar doesn't show.${optOut(teardown) ? ` Resources marked ${optOut(teardown)} stay.` : ''}`}
210
+ />
211
+ )
212
+ }
213
+
214
+ // `note` explains the expanded list; `summary` is a caveat that must read
215
+ // without expanding it.
216
+ function CascadeDependentsList({ dependents, title, note, summary }: { dependents: CascadeDependent[]; title: string; note?: string; summary?: string }) {
112
217
  const [expanded, setExpanded] = useState(false)
113
218
  const { panelId, buttonProps } = useDisclosure(expanded)
114
219
 
115
220
  const grouped = useMemo(() => {
116
221
  const map = new Map<string, string[]>()
117
222
  for (const dep of dependents) {
118
- const kind = dep.kind
223
+ const kind = dep.group ? `${dep.kind} (${dep.group})` : dep.kind
119
224
  if (!map.has(kind)) map.set(kind, [])
120
- map.get(kind)!.push(dep.name)
225
+ map.get(kind)!.push(dep.namespace ? `${dep.namespace}/${dep.name}` : dep.name)
121
226
  }
122
227
  return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0]))
123
228
  }, [dependents])
@@ -131,10 +236,9 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
131
236
  className="flex items-center gap-2 w-full px-3 py-2 text-left text-xs font-medium text-amber-400 hover:bg-amber-500/10 transition-colors"
132
237
  >
133
238
  <CollapseChevron open={expanded} inheritColor className="w-3.5 h-3.5" />
134
- <span>
135
- May affect {pluralize(dependents.length, 'related resource')}
136
- </span>
239
+ <span>{title}</span>
137
240
  </button>
241
+ {summary && <p className="px-3 pb-2 text-xs text-theme-text-secondary">{summary}</p>}
138
242
 
139
243
  <Collapse open={expanded} id={panelId}>
140
244
  <div className="px-3 pb-2.5 space-y-1.5">
@@ -150,6 +254,7 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
150
254
  </div>
151
255
  </div>
152
256
  ))}
257
+ {note && <p className="text-xs text-theme-text-tertiary">{note}</p>}
153
258
  </div>
154
259
  </Collapse>
155
260
  </div>
@@ -19,7 +19,8 @@ interface PageHeaderProps {
19
19
  export function PageHeader({ icon: Icon, title, description, onBack, actions }: PageHeaderProps) {
20
20
  return (
21
21
  // Phones: actions (freshness, summary tiles) wrap under the title
22
- // instead of squeezing it to nothing.
22
+ // instead of squeezing it to nothing, and take the full row so summary
23
+ // tiles can grow into it.
23
24
  <div className="flex items-center gap-4 phone:flex-wrap phone:gap-y-3">
24
25
  {onBack && (
25
26
  <button
@@ -39,7 +40,7 @@ export function PageHeader({ icon: Icon, title, description, onBack, actions }:
39
40
  <p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate phone:whitespace-normal phone:line-clamp-2">{description}</p>
40
41
  )}
41
42
  </div>
42
- {actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0 phone:shrink phone:justify-start">{actions}</div>}
43
+ {actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0 phone:shrink phone:basis-full phone:justify-start">{actions}</div>}
43
44
  </div>
44
45
  )
45
46
  }
@@ -8,6 +8,7 @@ export type SummaryTone = 'neutral' | 'success' | 'warning' | 'error' | 'info'
8
8
  // Applications both surface their status rollup as a row of these in the header.
9
9
  export function SummaryTile({
10
10
  label,
11
+ phoneLabel,
11
12
  value,
12
13
  tone = 'neutral',
13
14
  onClick,
@@ -15,6 +16,8 @@ export function SummaryTile({
15
16
  loading = false,
16
17
  }: {
17
18
  label: string
19
+ /** Shorter label for phones, where a row of tiles has to fit 360px. */
20
+ phoneLabel?: string
18
21
  value: number
19
22
  tone?: SummaryTone
20
23
  onClick?: () => void
@@ -42,10 +45,17 @@ export function SummaryTile({
42
45
  ) : (
43
46
  <div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
44
47
  )
45
- const label$ = <div className="text-xs text-theme-text-tertiary">{label}</div>
48
+ const label$ = phoneLabel ? (
49
+ <div className="text-xs text-theme-text-tertiary">
50
+ <span className="phone:hidden">{label}</span>
51
+ <span className="hidden phone:inline">{phoneLabel}</span>
52
+ </div>
53
+ ) : (
54
+ <div className="text-xs text-theme-text-tertiary">{label}</div>
55
+ )
46
56
  if (!onClick) {
47
57
  return (
48
- <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
58
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2 phone:flex-1 phone:basis-0 phone:whitespace-nowrap phone:px-2 phone:py-1.5">
49
59
  {value$}
50
60
  {label$}
51
61
  </div>
@@ -57,7 +67,7 @@ export function SummaryTile({
57
67
  onClick={onClick}
58
68
  aria-pressed={active}
59
69
  className={clsx(
60
- 'cursor-pointer rounded-md border bg-theme-base px-3 py-2 text-left transition-colors hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-skyhook-500',
70
+ 'cursor-pointer rounded-md border bg-theme-base px-3 py-2 phone:flex-1 phone:basis-0 phone:whitespace-nowrap phone:px-2 phone:py-1.5 text-left transition-colors hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-skyhook-500',
61
71
  active ? activeBorderClass : 'border-theme-border',
62
72
  )}
63
73
  >
@@ -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