@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.
- package/README.md +21 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +46 -12
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +16 -11
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +62 -26
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +342 -83
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +21 -8
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +158 -5
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +31 -9
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +7 -4
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +280 -0
- package/src/components/ui/drawer-components.tsx +302 -18
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +105 -34
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +119 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +158 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +3 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +90 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- 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
|
-
|
|
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:
|
|
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) === '
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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('
|
|
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
|
|
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
|
|
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
|
|
101
|
+
Checking for dependent resources...
|
|
70
102
|
</div>
|
|
71
103
|
)}
|
|
72
104
|
|
|
73
|
-
{!cascadeLoading && !cascadePreviewFailed && cascadeDependents && cascadeDependents.length > 0 && (
|
|
74
|
-
|
|
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
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
{!
|
|
84
|
-
<p className="text-xs text-theme-text-
|
|
85
|
-
|
|
86
|
-
|
|
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 ===
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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$ =
|
|
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
|
>
|