@skyhook-io/k8s-ui 1.17.0 → 1.17.1
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 +41 -0
- package/package.json +3 -3
- package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
- package/src/components/gitops/tree/tree-helpers.ts +7 -1
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +40 -5
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +12 -1
- package/src/components/issues/issues.test.ts +49 -1
- package/src/components/issues/severity.ts +21 -0
- package/src/components/issues/types.ts +23 -0
- package/src/components/logs/LogCore.theme.test.tsx +81 -3
- package/src/components/logs/LogCore.tsx +25 -17
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +291 -0
- package/src/components/logs/LogsViewer.tsx +72 -11
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +22 -30
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogStream.ts +17 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +302 -109
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
- package/src/components/resources/renderers/HPARenderer.tsx +56 -16
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +41 -29
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -20
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils.ts +82 -6
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +11 -4
- package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
- package/src/components/shared/index.ts +2 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +3 -71
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +110 -1
- package/src/components/ui/drawer-components.tsx +62 -9
- package/src/components/ui/index.ts +3 -1
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +180 -64
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +11 -2
- package/src/types/core.ts +29 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +10 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +9 -0
- package/src/utils/format.ts +4 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +57 -0
- package/src/utils/navigation.ts +86 -8
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +37 -0
- package/src/utils/resource-hierarchy.ts +15 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- package/src/utils/view-transition.ts +16 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, useState } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ActionConfirmDialog, type ActionConfirmDialogProps } from './ActionConfirmDialog'
|
|
6
|
+
|
|
7
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
8
|
+
const roots: Root[] = []
|
|
9
|
+
const base: ActionConfirmDialogProps = {
|
|
10
|
+
open: true, onClose: () => {}, onConfirm: () => {}, title: 'Scale worker?',
|
|
11
|
+
subject: { kind: 'Deployment', namespace: 'jobs', name: 'worker' },
|
|
12
|
+
context: 'test-cluster', effect: 'Set the desired replicas to three.', confirmLabel: 'Scale',
|
|
13
|
+
}
|
|
14
|
+
function render(props: Partial<ActionConfirmDialogProps> = {}) {
|
|
15
|
+
const host = document.createElement('div')
|
|
16
|
+
document.body.appendChild(host)
|
|
17
|
+
const root = createRoot(host)
|
|
18
|
+
roots.push(root)
|
|
19
|
+
const update = (next: Partial<ActionConfirmDialogProps>) => act(() => root.render(<ActionConfirmDialog {...base} {...next} />))
|
|
20
|
+
update(props)
|
|
21
|
+
return update
|
|
22
|
+
}
|
|
23
|
+
function button(label: string) {
|
|
24
|
+
return [...document.querySelectorAll('button')].find(b => b.textContent?.trim() === label)!
|
|
25
|
+
}
|
|
26
|
+
function type(text: string) {
|
|
27
|
+
const input = document.querySelector('input')!
|
|
28
|
+
act(() => {
|
|
29
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, text)
|
|
30
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
act(() => roots.splice(0).forEach(root => root.unmount()))
|
|
35
|
+
document.body.innerHTML = ''
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('ActionConfirmDialog', () => {
|
|
39
|
+
it('shows the target and context before a reviewed write', () => {
|
|
40
|
+
const confirm = vi.fn()
|
|
41
|
+
render({ onConfirm: confirm })
|
|
42
|
+
expect(document.body.textContent).toContain('Deployment jobs/worker · context test-cluster')
|
|
43
|
+
act(() => button('Scale').click())
|
|
44
|
+
expect(confirm).toHaveBeenCalledOnce()
|
|
45
|
+
})
|
|
46
|
+
it('shows an incomplete form as a quiet hint and a disabled confirm', () => {
|
|
47
|
+
render({ incompleteReason: 'The replica count is unchanged' })
|
|
48
|
+
expect(document.body.textContent).toContain('The replica count is unchanged')
|
|
49
|
+
expect(document.body.textContent).not.toContain('This action is not available')
|
|
50
|
+
expect(button('Scale').disabled).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
it('keeps the alert for a real blocker', () => {
|
|
53
|
+
render({ disabledReason: 'Needs patch deployments in jobs' })
|
|
54
|
+
expect(document.body.textContent).toContain('This action is not available')
|
|
55
|
+
expect(button('Scale').disabled).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
it('requires typed acknowledgement and clears it after closing', () => {
|
|
58
|
+
const update = render({ typedConfirmation: 'worker' })
|
|
59
|
+
expect(button('Scale').disabled).toBe(true)
|
|
60
|
+
type('other')
|
|
61
|
+
expect(button('Scale').disabled).toBe(true)
|
|
62
|
+
type('worker')
|
|
63
|
+
expect(button('Scale').disabled).toBe(false)
|
|
64
|
+
update({ typedConfirmation: 'worker', open: false })
|
|
65
|
+
update({ typedConfirmation: 'worker' })
|
|
66
|
+
expect(document.querySelector('input')?.value).toBe('')
|
|
67
|
+
expect(button('Scale').disabled).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
it('requires the host write guard even with the correct acknowledgement', () => {
|
|
70
|
+
render({ typedConfirmation: 'worker', guardSatisfied: false })
|
|
71
|
+
type('worker')
|
|
72
|
+
expect(button('Scale').disabled).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
it('locks resubmission for an unknown or partial result without claiming no write happened', () => {
|
|
75
|
+
render({ result: { state: 'unknown', message: 'Refresh the target before another attempt.' } })
|
|
76
|
+
expect(button('Scale').disabled).toBe(true)
|
|
77
|
+
expect(document.body.textContent).toContain('Radar could not tell whether this took effect')
|
|
78
|
+
expect(document.body.textContent).not.toContain('The write did not happen')
|
|
79
|
+
})
|
|
80
|
+
it('locks an unknown result even without an error string', () => {
|
|
81
|
+
render({ result: { state: 'unknown' } })
|
|
82
|
+
expect(button('Scale').disabled).toBe(true)
|
|
83
|
+
expect(document.body.textContent).toContain('Radar could not tell whether this took effect')
|
|
84
|
+
expect(document.body.textContent).toContain('Check the object’s current state')
|
|
85
|
+
})
|
|
86
|
+
it('allows retry after a known refusal, using neutral default error copy', () => {
|
|
87
|
+
render({ result: { state: 'error', message: 'The reviewed replica count changed.' } })
|
|
88
|
+
expect(button('Scale').disabled).toBe(false)
|
|
89
|
+
expect(document.body.textContent).toContain('The action could not be completed')
|
|
90
|
+
})
|
|
91
|
+
it('locks confirm, Back and dismissal while the request is pending', () => {
|
|
92
|
+
const close = vi.fn(), back = vi.fn()
|
|
93
|
+
render({ isLoading: true, onClose: close, onBack: back })
|
|
94
|
+
expect(button('Scale').disabled).toBe(true)
|
|
95
|
+
expect(button('Back').disabled).toBe(true)
|
|
96
|
+
expect(button('Cancel').disabled).toBe(true)
|
|
97
|
+
act(() => button('Cancel').click())
|
|
98
|
+
act(() => document.querySelector<HTMLButtonElement>('[aria-label="Close"]')!.click())
|
|
99
|
+
expect(close).not.toHaveBeenCalled()
|
|
100
|
+
expect(back).not.toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
it('keeps Back and Cancel distinct', () => {
|
|
103
|
+
const close = vi.fn(), back = vi.fn()
|
|
104
|
+
render({ onClose: close, onBack: back })
|
|
105
|
+
act(() => button('Back').click())
|
|
106
|
+
expect(back).toHaveBeenCalledOnce()
|
|
107
|
+
expect(close).not.toHaveBeenCalled()
|
|
108
|
+
act(() => button('Cancel').click())
|
|
109
|
+
expect(close).toHaveBeenCalledOnce()
|
|
110
|
+
})
|
|
111
|
+
it('reveals the reviewed writes through an accessible disclosure', () => {
|
|
112
|
+
render({ writes: [{ summary: 'patch Deployment jobs/worker', detail: 'spec.replicas: 3' }] })
|
|
113
|
+
const toggle = button('The API write')
|
|
114
|
+
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
|
115
|
+
act(() => toggle.click())
|
|
116
|
+
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
|
117
|
+
expect(document.body.textContent).toContain('spec.replicas: 3')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('retains a host-owned outcome lock across dialog dismissal and remount', () => {
|
|
122
|
+
function Host() {
|
|
123
|
+
const [open, setOpen] = useState(true)
|
|
124
|
+
const [unknown, setUnknown] = useState(true)
|
|
125
|
+
return <>
|
|
126
|
+
<button onClick={() => setOpen(true)}>Review action</button>
|
|
127
|
+
<button onClick={() => setUnknown(false)}>Current state reviewed</button>
|
|
128
|
+
{open && <ActionConfirmDialog {...base} result={unknown ? { state: 'unknown' } : undefined} onClose={() => setOpen(false)} />}
|
|
129
|
+
</>
|
|
130
|
+
}
|
|
131
|
+
const root = createRoot(document.body.appendChild(document.createElement('div')))
|
|
132
|
+
roots.push(root)
|
|
133
|
+
act(() => root.render(<Host />))
|
|
134
|
+
expect(button('Scale').disabled).toBe(true)
|
|
135
|
+
act(() => button('Cancel').click())
|
|
136
|
+
act(() => button('Review action').click())
|
|
137
|
+
expect(button('Scale').disabled).toBe(true)
|
|
138
|
+
act(() => button('Current state reviewed').click())
|
|
139
|
+
expect(button('Scale').disabled).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('locks a partial result and explains that changes completed', () => {
|
|
143
|
+
render({ result: { state: 'partial' } })
|
|
144
|
+
expect(button('Scale').disabled).toBe(true)
|
|
145
|
+
expect(document.body.textContent).toContain('Some changes completed')
|
|
146
|
+
expect(document.body.textContent).toContain('Check the object’s current state')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('keeps an empty known-refusal message distinct from an uncertain outcome', () => {
|
|
150
|
+
render({ result: { state: 'error', message: '' } })
|
|
151
|
+
expect(button('Scale').disabled).toBe(false)
|
|
152
|
+
expect(document.body.textContent).toContain('The action could not be completed')
|
|
153
|
+
expect(document.body.textContent).not.toContain('Check the object’s current state')
|
|
154
|
+
})
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { useEffect, useId, useState, type ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { AlertTriangle, Info, X } from 'lucide-react'
|
|
4
|
+
import { DialogPortal } from '../ui/DialogPortal'
|
|
5
|
+
import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
|
|
6
|
+
import { TONE_SOLID_CLASS } from '../ui/severity-tone'
|
|
7
|
+
import { AlertBanner } from '../ui/drawer-components'
|
|
8
|
+
import type { ResourceRef } from '../../types/core'
|
|
9
|
+
|
|
10
|
+
/** One API call the action will make, shown in the technical section. */
|
|
11
|
+
export interface ActionWrite {
|
|
12
|
+
/** Plain-language line, e.g. "patch Cluster payments/pg-orders status". */
|
|
13
|
+
summary: string
|
|
14
|
+
/** The literal body or field change, shown monospaced. */
|
|
15
|
+
detail?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ActionConfirmResult =
|
|
19
|
+
| { state: 'error'; message: string; title?: string }
|
|
20
|
+
| { state: 'unknown' | 'partial'; message?: string; title?: string }
|
|
21
|
+
|
|
22
|
+
export interface ActionConfirmDialogProps {
|
|
23
|
+
open: boolean
|
|
24
|
+
onClose: () => void
|
|
25
|
+
onConfirm: () => void
|
|
26
|
+
onBack?: () => void
|
|
27
|
+
backLabel?: string
|
|
28
|
+
/** Imperative title: "Switch over pg-orders?" */
|
|
29
|
+
title: string
|
|
30
|
+
subject: Pick<ResourceRef, 'kind' | 'name'> & Partial<Pick<ResourceRef, 'namespace'>>
|
|
31
|
+
/** Kubernetes context the write goes to, always visible. */
|
|
32
|
+
context?: string
|
|
33
|
+
/** What happens, stated first. */
|
|
34
|
+
effect: ReactNode
|
|
35
|
+
/** Form fields (targets, options). */
|
|
36
|
+
children?: ReactNode
|
|
37
|
+
notes?: ReactNode[]
|
|
38
|
+
warnings?: ReactNode[]
|
|
39
|
+
/** Extra acknowledgement block, e.g. the GitOps write warning. */
|
|
40
|
+
guard?: ReactNode
|
|
41
|
+
/** Blocks confirm until the guard is satisfied. */
|
|
42
|
+
guardSatisfied?: boolean
|
|
43
|
+
writes?: ActionWrite[]
|
|
44
|
+
/** When set, the user must type this exact text to confirm. */
|
|
45
|
+
typedConfirmation?: string
|
|
46
|
+
confirmLabel: string
|
|
47
|
+
/** Disruptive actions use the danger styling. */
|
|
48
|
+
disruptive?: boolean
|
|
49
|
+
/** Why the action cannot run right now; disables confirm and is shown. */
|
|
50
|
+
disabledReason?: string
|
|
51
|
+
/** What the form still needs (an unchanged value, a missing input); disables confirm with a quiet hint beside it, not an alert. */
|
|
52
|
+
incompleteReason?: string
|
|
53
|
+
isLoading?: boolean
|
|
54
|
+
/**
|
|
55
|
+
* Unknown and partial results lock Confirm: repeating the action could apply
|
|
56
|
+
* it twice. The host must retain the result
|
|
57
|
+
* across dismissal/remount until it has reviewed the current target again.
|
|
58
|
+
*/
|
|
59
|
+
result?: ActionConfirmResult
|
|
60
|
+
/** `wide` for dialogs that show evidence beside their form fields. */
|
|
61
|
+
size?: 'default' | 'wide'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function ActionConfirmDialog({
|
|
65
|
+
open,
|
|
66
|
+
onClose,
|
|
67
|
+
onConfirm,
|
|
68
|
+
onBack,
|
|
69
|
+
backLabel = 'Back',
|
|
70
|
+
title,
|
|
71
|
+
subject,
|
|
72
|
+
context,
|
|
73
|
+
effect,
|
|
74
|
+
children,
|
|
75
|
+
notes = [],
|
|
76
|
+
warnings = [],
|
|
77
|
+
guard,
|
|
78
|
+
guardSatisfied = true,
|
|
79
|
+
writes = [],
|
|
80
|
+
typedConfirmation,
|
|
81
|
+
confirmLabel,
|
|
82
|
+
disruptive = false,
|
|
83
|
+
disabledReason,
|
|
84
|
+
incompleteReason,
|
|
85
|
+
isLoading = false,
|
|
86
|
+
result,
|
|
87
|
+
size = 'default',
|
|
88
|
+
}: ActionConfirmDialogProps) {
|
|
89
|
+
const titleId = useId()
|
|
90
|
+
const [typed, setTyped] = useState('')
|
|
91
|
+
const [showWrites, setShowWrites] = useState(false)
|
|
92
|
+
const writesDisclosure = useDisclosure(showWrites)
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!open) {
|
|
96
|
+
setTyped('')
|
|
97
|
+
setShowWrites(false)
|
|
98
|
+
}
|
|
99
|
+
}, [open])
|
|
100
|
+
|
|
101
|
+
const typedOk = !typedConfirmation || typed.trim() === typedConfirmation
|
|
102
|
+
const outcomeLocked = result?.state === 'unknown' || result?.state === 'partial'
|
|
103
|
+
const canConfirm = !disabledReason && !incompleteReason && !outcomeLocked && typedOk && guardSatisfied && !isLoading
|
|
104
|
+
const where = [subject.namespace, subject.name].filter(Boolean).join('/')
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<DialogPortal open={open} onClose={onClose} closable={!isLoading} className={size === 'wide' ? 'w-full max-w-4xl' : 'w-full max-w-xl'} ariaLabelledBy={titleId}>
|
|
108
|
+
<div className="flex items-start gap-3 border-b border-theme-border p-4">
|
|
109
|
+
<div className="min-w-0 flex-1">
|
|
110
|
+
<h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">{title}</h3>
|
|
111
|
+
<div className="mt-0.5 text-xs text-theme-text-tertiary">
|
|
112
|
+
{subject.kind} <span className="font-mono">{where}</span>
|
|
113
|
+
{context && (
|
|
114
|
+
<>
|
|
115
|
+
{' · context '}
|
|
116
|
+
<span className="font-mono">{context}</span>
|
|
117
|
+
</>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={onClose}
|
|
124
|
+
disabled={isLoading}
|
|
125
|
+
aria-label="Close"
|
|
126
|
+
className="rounded p-1 text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary disabled:opacity-50"
|
|
127
|
+
>
|
|
128
|
+
<X className="h-5 w-5" />
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-4 text-sm text-theme-text-primary">
|
|
133
|
+
<div className="text-theme-text-primary">{effect}</div>
|
|
134
|
+
|
|
135
|
+
{disabledReason && <AlertBanner variant="info" title="This action is not available" message={disabledReason} />}
|
|
136
|
+
|
|
137
|
+
{children}
|
|
138
|
+
|
|
139
|
+
{warnings.length > 0 && (
|
|
140
|
+
<div className="space-y-2">
|
|
141
|
+
{warnings.map((w, i) => (
|
|
142
|
+
<div key={i} className="flex items-start gap-2 text-sm text-theme-text-primary">
|
|
143
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning-text" />
|
|
144
|
+
<div className="min-w-0">{w}</div>
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{notes.length > 0 && (
|
|
151
|
+
<div className="space-y-1.5">
|
|
152
|
+
{notes.map((n, i) => (
|
|
153
|
+
<div key={i} className="flex items-start gap-2 text-xs text-theme-text-secondary">
|
|
154
|
+
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
155
|
+
<div className="min-w-0">{n}</div>
|
|
156
|
+
</div>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{guard}
|
|
162
|
+
|
|
163
|
+
{writes.length > 0 && (
|
|
164
|
+
<div>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
{...writesDisclosure.buttonProps}
|
|
168
|
+
onClick={() => setShowWrites((v) => !v)}
|
|
169
|
+
className="flex items-center gap-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
|
|
170
|
+
>
|
|
171
|
+
<CollapseChevron open={showWrites} className="h-3.5 w-3.5" />
|
|
172
|
+
{writes.length === 1 ? 'The API write' : `The API writes, in order (${writes.length})`}
|
|
173
|
+
</button>
|
|
174
|
+
<Collapse open={showWrites} id={writesDisclosure.panelId}>
|
|
175
|
+
<ol className="mt-2 space-y-2 pl-5 text-xs">
|
|
176
|
+
{writes.map((w, i) => (
|
|
177
|
+
<li key={i} className="list-decimal text-theme-text-secondary">
|
|
178
|
+
<div>{w.summary}</div>
|
|
179
|
+
{w.detail && (
|
|
180
|
+
<pre className="mt-1 overflow-auto whitespace-pre-wrap rounded bg-theme-base/60 p-2 font-mono text-[11.5px] text-theme-text-primary">
|
|
181
|
+
{w.detail}
|
|
182
|
+
</pre>
|
|
183
|
+
)}
|
|
184
|
+
</li>
|
|
185
|
+
))}
|
|
186
|
+
</ol>
|
|
187
|
+
</Collapse>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{typedConfirmation && (
|
|
192
|
+
<label className="block">
|
|
193
|
+
<span className="text-xs text-theme-text-secondary">
|
|
194
|
+
Type <span className="font-mono font-semibold text-theme-text-primary">{typedConfirmation}</span> to confirm
|
|
195
|
+
</span>
|
|
196
|
+
<input
|
|
197
|
+
value={typed}
|
|
198
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
199
|
+
autoComplete="off"
|
|
200
|
+
spellCheck={false}
|
|
201
|
+
className="mt-1 w-full rounded-lg border border-theme-border bg-theme-base px-3 py-1.5 font-mono text-sm text-theme-text-primary focus:outline-none focus:ring-2 focus:ring-skyhook-500"
|
|
202
|
+
/>
|
|
203
|
+
</label>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{result && (
|
|
207
|
+
<AlertBanner
|
|
208
|
+
variant={outcomeLocked ? 'warning' : 'error'}
|
|
209
|
+
title={result.title ?? (result.state === 'partial' ? 'Some changes completed' : result.state === 'unknown' ? 'Radar could not tell whether this took effect' : 'The action could not be completed')}
|
|
210
|
+
message={result.message || (outcomeLocked ? 'Check the object’s current state before another attempt.' : undefined)}
|
|
211
|
+
/>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div className="flex items-center justify-end gap-3 border-t border-theme-border p-4">
|
|
216
|
+
{onBack && <button type="button" onClick={onBack} disabled={isLoading} className="btn-secondary mr-auto px-4 py-2 text-sm disabled:opacity-50">{backLabel}</button>}
|
|
217
|
+
{incompleteReason && !disabledReason && <span className="mr-auto min-w-0 text-xs text-theme-text-tertiary">{incompleteReason}</span>}
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={onClose}
|
|
221
|
+
disabled={isLoading}
|
|
222
|
+
className="rounded-lg px-4 py-2 text-sm font-medium text-theme-text-secondary transition-colors hover:bg-theme-elevated hover:text-theme-text-primary disabled:opacity-50"
|
|
223
|
+
>
|
|
224
|
+
Cancel
|
|
225
|
+
</button>
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
onClick={onConfirm}
|
|
229
|
+
disabled={!canConfirm}
|
|
230
|
+
className={clsx(
|
|
231
|
+
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
|
232
|
+
disruptive ? [TONE_SOLID_CLASS.red, 'hover:opacity-90'] : 'btn-brand',
|
|
233
|
+
)}
|
|
234
|
+
>
|
|
235
|
+
{isLoading && (
|
|
236
|
+
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" aria-hidden>
|
|
237
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
|
238
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
239
|
+
</svg>
|
|
240
|
+
)}
|
|
241
|
+
{confirmLabel}
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
</DialogPortal>
|
|
245
|
+
)
|
|
246
|
+
}
|