@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
@@ -54,11 +54,12 @@ describe('FetchResult', () => {
54
54
  expect(html).toContain('Resource cache not available')
55
55
  })
56
56
 
57
- it('does not blame the cluster for a kind_sync_failed 503', () => {
57
+ // kind_sync_failed is how Radar releases before kind_sync_timed_out spelled it.
58
+ it.each(['kind_sync_timed_out', 'kind_sync_failed'])('does not blame the cluster for a %s 503', (code) => {
58
59
  const html = renderToString(
59
60
  <FetchResult
60
61
  loading={false}
61
- error={shaped('pods failed to load within the sync deadline', 503, { error_code: 'kind_sync_failed' })}
62
+ error={shaped('pods failed to load within the sync deadline', 503, { error_code: code })}
62
63
  />,
63
64
  )
64
65
  expect(html).not.toContain('Cluster unavailable')
@@ -66,6 +67,22 @@ describe('FetchResult', () => {
66
67
  expect(html).toContain('pods failed to load within the sync deadline')
67
68
  })
68
69
 
70
+ it('blames Radar\'s collector, not the cluster, for a collector failure', () => {
71
+ const html = renderToString(
72
+ <FetchResult loading={false} error={shaped('Radar is not allowed to list pods', 503, { error_code: 'radar_forbidden' })} />,
73
+ )
74
+ expect(html).toContain('Radar can')
75
+ expect(html).not.toContain('Cluster unavailable')
76
+ })
77
+
78
+ it('keeps a disconnected cluster reading as unavailable', () => {
79
+ const html = renderToString(
80
+ <FetchResult loading={false} error={shaped('Not connected to cluster', 503, { error_code: 'cluster_disconnected' })} />,
81
+ )
82
+ expect(html).toContain('Cluster unavailable')
83
+ expect(html).not.toContain('Radar can')
84
+ })
85
+
69
86
  it('renders a Retry button only when onRetry is given', () => {
70
87
  const err = shaped('internal server error', 500)
71
88
  expect(renderToString(<FetchResult loading={false} error={err} onRetry={() => {}} />)).toContain('Retry')
@@ -3,6 +3,7 @@ import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, RefreshCw, t
3
3
  import { clsx } from 'clsx'
4
4
  import { PaneLoader } from './PaneLoader'
5
5
  import { isFetchError } from '../../types/fetch-error'
6
+ import { readActionFor, canonicalReadCode } from '../../utils/read-actions'
6
7
 
7
8
  // FetchResult collapses (loading, error, no-data) into one rendered outcome:
8
9
  // loader while loading, a typed error surface when the fetch threw, or the
@@ -15,6 +16,7 @@ import { isFetchError } from '../../types/fetch-error'
15
16
  // error (403) → "Access denied" + error.message
16
17
  // error (404) → notFoundMessage + error.message
17
18
  // error (503 sync failed) → "Couldn't load this view" + error.message
19
+ // error (503 collector) → "Radar can't read this" + error.message
18
20
  // error (503) → "Cluster unavailable" + error.message
19
21
  // error (401) → "Sign-in required" (apiFetch redirects; fallback)
20
22
  // error (other / no shape) → "Couldn't load this view" + error.message
@@ -118,9 +120,19 @@ function classify(error: unknown, notFoundMessage: string): Classified {
118
120
  case 503:
119
121
  // The cluster is connected; one kind's informer missed its sync
120
122
  // deadline, so "Cluster unavailable" would send the reader the wrong way.
121
- if (errorCodeOf(error) === 'kind_sync_failed') {
123
+ if (canonicalReadCode(errorCodeOf(error)) === 'kind_sync_timed_out') {
122
124
  return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
123
125
  }
126
+ // Shares the collector's action, but it is the cluster connection
127
+ // that is down rather than one read.
128
+ if (errorCodeOf(error) === 'cluster_disconnected') {
129
+ return { headline: 'Cluster unavailable', detail: error.message, icon: ServerCrash }
130
+ }
131
+ // Radar's own identity or collection can't serve the read: neither
132
+ // the cluster being down nor the reader's access.
133
+ if (readActionFor(errorCodeOf(error)) === 'radar_config') {
134
+ return { headline: "Radar can't read this", detail: error.message, icon: ShieldOff }
135
+ }
124
136
  // The cluster is healthy and still loading — the retry policies keep
125
137
  // these surfaces polling, but a caller without one must not tell the
126
138
  // user a connecting cluster is down.
@@ -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>
@@ -36,6 +36,26 @@ describe('MultiSelectPicker', () => {
36
36
  expect(html).toContain('>Done</button>')
37
37
  })
38
38
 
39
+ it('adds an Only button to each row only when a select-only handler is given', () => {
40
+ expect(render()).not.toContain('>Only</button>')
41
+
42
+ const html = render({ onSelectOnly: () => {} })
43
+ expect(html.match(/>Only<\/button>/g)).toHaveLength(KINDS.length)
44
+ expect(html).toContain('aria-label="Show only Pod"')
45
+ // The name stays inside the checkbox label, so clicking it still toggles.
46
+ expect(html).toMatch(/<label[^>]*><span[^>]*><input type="checkbox"[^>]*\/><span class="truncate">Pod<\/span><\/span><\/label><button/)
47
+ })
48
+
49
+ it('hides Only until hover or keyboard focus, and keeps it visible on touch', () => {
50
+ const html = render({ onSelectOnly: () => {} })
51
+ const only = html.match(/<button[^>]*aria-label="Show only Pod"[^>]*>/)?.[0] ?? ''
52
+ expect(only).toContain('opacity-0')
53
+ expect(only).toContain('group-hover/row:opacity-100')
54
+ expect(only).toContain('group-has-[:focus-visible]/row:opacity-100')
55
+ expect(only).toContain('focus-visible:opacity-100')
56
+ expect(only).toContain('any-pointer-coarse:opacity-100')
57
+ })
58
+
39
59
  it('shows the filter input above the threshold and hides it below', () => {
40
60
  const shown = render()
41
61
  expect(shown).toContain('placeholder="Filter kinds"')
@@ -16,6 +16,12 @@ export interface MultiSelectPickerProps {
16
16
  onClearAll: () => void
17
17
  /** Footer "Done" button. */
18
18
  onDone: () => void
19
+ /**
20
+ * When set, each row gets an "Only" button that selects just that item. The
21
+ * name and checkbox still toggle. The host decides whether it also commits
22
+ * and closes.
23
+ */
24
+ onSelectOnly?: (item: string) => void
19
25
 
20
26
  /** Controlled search term (kept in the host so the body stays SSR-testable). */
21
27
  search: string
@@ -50,6 +56,7 @@ export function MultiSelectPicker({
50
56
  onSelectionChange,
51
57
  onClearAll,
52
58
  onDone,
59
+ onSelectOnly,
53
60
  search,
54
61
  onSearchChange,
55
62
  searchPlaceholder,
@@ -136,8 +143,8 @@ export function MultiSelectPicker({
136
143
  )}
137
144
 
138
145
  {filteredItems.map(item => (
139
- <li key={item}>
140
- <label className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer">
146
+ <li key={item} className="group/row flex items-center hover:bg-theme-hover">
147
+ <label className="flex-1 min-w-0 flex items-center px-3 py-1.5 text-sm text-left text-theme-text-primary cursor-pointer">
141
148
  <span className="flex items-center gap-2 min-w-0">
142
149
  <input
143
150
  type="checkbox"
@@ -149,6 +156,17 @@ export function MultiSelectPicker({
149
156
  {renderItemMeta?.(item)}
150
157
  </span>
151
158
  </label>
159
+ {onSelectOnly && (
160
+ // Touchscreens can coexist with a hover-capable mouse.
161
+ <button
162
+ type="button"
163
+ onClick={() => onSelectOnly(item)}
164
+ aria-label={`Show only ${item}`}
165
+ className="shrink-0 mr-2 px-1.5 py-0.5 rounded border border-theme-border bg-theme-surface text-[11px] text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary opacity-0 transition-opacity group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 focus-visible:opacity-100 any-pointer-coarse:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
166
+ >
167
+ Only
168
+ </button>
169
+ )}
152
170
  </li>
153
171
  ))}
154
172
  </ul>
@@ -18,7 +18,10 @@ interface PageHeaderProps {
18
18
 
19
19
  export function PageHeader({ icon: Icon, title, description, onBack, actions }: PageHeaderProps) {
20
20
  return (
21
- <div className="flex items-center gap-4">
21
+ // Phones: actions (freshness, summary tiles) wrap under the title
22
+ // instead of squeezing it to nothing, and take the full row so summary
23
+ // tiles can grow into it.
24
+ <div className="flex items-center gap-4 phone:flex-wrap phone:gap-y-3">
22
25
  {onBack && (
23
26
  <button
24
27
  onClick={onBack}
@@ -28,16 +31,16 @@ export function PageHeader({ icon: Icon, title, description, onBack, actions }:
28
31
  <ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
29
32
  </button>
30
33
  )}
31
- <div className="flex-1 min-w-0">
34
+ <div className="flex-1 min-w-0 phone:basis-full">
32
35
  <div className="flex items-center gap-2">
33
36
  <Icon className="w-5 h-5 text-theme-text-secondary shrink-0" />
34
37
  <h1 className="text-lg font-semibold text-theme-text-primary truncate">{title}</h1>
35
38
  </div>
36
39
  {description && (
37
- <p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate">{description}</p>
40
+ <p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate phone:whitespace-normal phone:line-clamp-2">{description}</p>
38
41
  )}
39
42
  </div>
40
- {actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0">{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>}
41
44
  </div>
42
45
  )
43
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
  >