@skyhook-io/radar-app 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 +1 -1
- package/package.json +3 -3
- package/src/App.tsx +125 -87
- package/src/api/actions.errorCode.test.ts +40 -0
- package/src/api/actions.test.ts +48 -0
- package/src/api/actions.ts +79 -0
- package/src/api/apiResources.navigation.test.tsx +232 -0
- package/src/api/apiResources.ts +46 -3
- package/src/api/client.helmSource.test.ts +39 -0
- package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
- package/src/api/client.namespaceSwitch.test.ts +107 -0
- package/src/api/client.ts +266 -39
- package/src/api/cnpg-ha.live.test.ts +140 -0
- package/src/api/cnpg-ha.test.ts +78 -0
- package/src/api/cnpg-ha.ts +19 -0
- package/src/api/cnpg-history.ts +123 -0
- package/src/api/cnpg-inspect.ts +102 -0
- package/src/api/cnpg-protection.ts +112 -0
- package/src/api/cnpg-recovery.test.tsx +60 -0
- package/src/api/cnpg-recovery.ts +262 -0
- package/src/api/cnpg-sessions.ts +175 -0
- package/src/api/cnpg-storage.ts +138 -0
- package/src/api/cnpg.legacy-reads.test.ts +43 -0
- package/src/api/cnpg.pooler-runtime.test.ts +15 -0
- package/src/api/cnpg.ts +428 -10
- package/src/api/radarFeatures.render.test.tsx +47 -3
- package/src/api/radarFeatures.ts +4 -1
- package/src/api/reviewedActionMutation.test.tsx +164 -0
- package/src/api/reviewedActionMutation.ts +66 -0
- package/src/api/timelineSource.test.ts +9 -0
- package/src/api/timelineSource.ts +8 -0
- package/src/components/audit/AuditView.tsx +9 -0
- package/src/components/capacity/CapacityActivity.tsx +3 -31
- package/src/components/capacity/CapacityDemand.tsx +4 -36
- package/src/components/capacity/CapacityOverview.tsx +217 -56
- package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
- package/src/components/capacity/CapacityView.test.tsx +323 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
- package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
- package/src/components/capacity/routes.ts +42 -0
- package/src/components/capacity/shared.tsx +11 -129
- package/src/components/capacity/workspaceRoute.ts +18 -0
- package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
- package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
- package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
- package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
- package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
- package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
- package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
- package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
- package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
- package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
- package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
- package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
- package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
- package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
- package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
- package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
- package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
- package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
- package/src/components/cnpg/CNPGOperator.tsx +78 -30
- package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
- package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
- package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
- package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
- package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
- package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
- package/src/components/cnpg/CNPGOverview.tsx +238 -63
- package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
- package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
- package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
- package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
- package/src/components/cnpg/CNPGPerformance.tsx +477 -0
- package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
- package/src/components/cnpg/CNPGPooling.tsx +97 -40
- package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
- package/src/components/cnpg/CNPGProtection.tsx +228 -122
- package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
- package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
- package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
- package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
- package/src/components/cnpg/CNPGStorage.tsx +619 -0
- package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
- package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
- package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
- package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
- package/src/components/cnpg/CNPGTrends.test.ts +38 -0
- package/src/components/cnpg/CNPGTrends.tsx +702 -0
- package/src/components/cnpg/CNPGView.tsx +34 -21
- package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
- package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
- package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
- package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
- package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
- package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
- package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
- package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
- package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
- package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
- package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
- package/src/components/cnpg/actions/actionModel.test.ts +111 -0
- package/src/components/cnpg/actions/actionModel.ts +127 -0
- package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
- package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
- package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
- package/src/components/cnpg/archivingRepair.test.ts +104 -0
- package/src/components/cnpg/archivingRepair.ts +145 -0
- package/src/components/cnpg/baseBackup.test.ts +36 -0
- package/src/components/cnpg/baseBackup.ts +36 -0
- package/src/components/cnpg/blocking.test.ts +59 -0
- package/src/components/cnpg/blocking.ts +131 -0
- package/src/components/cnpg/coverageText.test.ts +27 -0
- package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
- package/src/components/cnpg/creation/targetModel.test.ts +137 -0
- package/src/components/cnpg/creation/targetModel.ts +149 -0
- package/src/components/cnpg/fleetStatus.test.ts +46 -0
- package/src/components/cnpg/fleetStatus.ts +43 -0
- package/src/components/cnpg/formFields.ts +2 -0
- package/src/components/cnpg/grantText.test.tsx +11 -0
- package/src/components/cnpg/host.test.tsx +24 -0
- package/src/components/cnpg/host.tsx +101 -0
- package/src/components/cnpg/inspectModel.test.ts +120 -0
- package/src/components/cnpg/inspectModel.ts +140 -0
- package/src/components/cnpg/logicalSlots.test.ts +30 -0
- package/src/components/cnpg/logicalSlots.ts +29 -0
- package/src/components/cnpg/lsn.test.ts +40 -0
- package/src/components/cnpg/lsn.ts +43 -0
- package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
- package/src/components/cnpg/operations/model.test.ts +510 -0
- package/src/components/cnpg/operations/model.ts +499 -0
- package/src/components/cnpg/operations/presentation.test.ts +80 -0
- package/src/components/cnpg/operations/presentation.ts +84 -0
- package/src/components/cnpg/operations/store.ts +98 -0
- package/src/components/cnpg/operatorStatus.test.ts +134 -0
- package/src/components/cnpg/operatorStatus.ts +194 -0
- package/src/components/cnpg/paths.test.ts +54 -0
- package/src/components/cnpg/paths.ts +47 -4
- package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
- package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
- package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
- package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
- package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
- package/src/components/cnpg/protection/scheduleModel.ts +16 -0
- package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
- package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
- package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
- package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
- package/src/components/cnpg/recovery/restoreModel.ts +685 -0
- package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
- package/src/components/cnpg/refresh.test.ts +38 -0
- package/src/components/cnpg/refresh.ts +17 -0
- package/src/components/cnpg/refreshNotice.test.tsx +29 -0
- package/src/components/cnpg/routes.test.ts +34 -3
- package/src/components/cnpg/routes.ts +60 -48
- package/src/components/cnpg/runtimeAssessment.ts +198 -0
- package/src/components/cnpg/runtimeModel.test.ts +175 -0
- package/src/components/cnpg/runtimeModel.ts +196 -0
- package/src/components/cnpg/runtimeParts.test.tsx +17 -0
- package/src/components/cnpg/runtimeParts.tsx +79 -0
- package/src/components/cnpg/shared.test.tsx +59 -0
- package/src/components/cnpg/shared.tsx +100 -182
- package/src/components/cnpg/storageModel.test.ts +107 -0
- package/src/components/cnpg/storageModel.ts +101 -0
- package/src/components/cnpg/trendSamples.test.ts +148 -0
- package/src/components/cnpg/trendSamples.ts +222 -0
- package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
- package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
- package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
- package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
- package/src/components/cnpg/useCNPGNavigate.ts +21 -0
- package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
- package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
- package/src/components/cnpg/workspaceRoute.ts +23 -0
- package/src/components/cost/ApplicationCostTab.test.ts +7 -0
- package/src/components/cost/ApplicationCostTab.tsx +7 -7
- package/src/components/cost/CostView.test.ts +2 -1
- package/src/components/cost/CostView.tsx +0 -2
- package/src/components/cost/WorkloadCostTab.tsx +2 -4
- package/src/components/cost/errors.ts +3 -1
- package/src/components/cost/source.test.ts +10 -0
- package/src/components/cost/source.ts +5 -1
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
- package/src/components/dock/BottomDock.tsx +2 -0
- package/src/components/dock/TerminalTab.test.tsx +17 -0
- package/src/components/dock/TerminalTab.tsx +7 -3
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
- package/src/components/helm/HelmCompareRoute.tsx +57 -26
- package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
- package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
- package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
- package/src/components/helm/ValuesViewer.test.tsx +301 -0
- package/src/components/helm/ValuesViewer.tsx +169 -78
- package/src/components/home/ActivitySummary.tsx +2 -1
- package/src/components/home/HomeView.tsx +20 -9
- package/src/components/home/ProblemsPanel.test.tsx +81 -0
- package/src/components/home/TopologyPreview.tsx +2 -2
- package/src/components/issues/IssuesPane.render.test.tsx +122 -0
- package/src/components/issues/IssuesPane.test.ts +65 -1
- package/src/components/issues/IssuesPane.tsx +188 -25
- package/src/components/issues/uncheckedSources.ts +21 -0
- package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
- package/src/components/resource/PVCUsageBar.test.tsx +24 -0
- package/src/components/resource/PVCUsageBar.tsx +15 -6
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resources/CompositeRenderer.tsx +4 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +26 -12
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +28 -15
- package/src/components/timeline/TimelineList.tsx +22 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineView.tsx +18 -45
- package/src/components/timeline/ViewModeToggle.tsx +20 -0
- package/src/components/timeline/useStickyError.test.tsx +47 -0
- package/src/components/timeline/useStickyError.ts +15 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
- package/src/components/ui/Markdown.test.tsx +65 -0
- package/src/components/ui/Markdown.tsx +144 -127
- package/src/components/whats-new/releaseNotes.ts +63 -1
- package/src/components/workload/WorkloadView.test.ts +10 -1
- package/src/components/workload/WorkloadView.tsx +27 -239
- package/src/components/workspace/layout.test.tsx +29 -0
- package/src/components/workspace/layout.tsx +259 -0
- package/src/components/workspace/table.ts +7 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/integrations/resourceHost.test.tsx +55 -0
- package/src/integrations/resourceHost.ts +99 -0
- package/src/integrations/resourceHosts.test.tsx +141 -0
- package/src/integrations/resourceHosts.tsx +119 -0
- package/src/integrations/workspaceRoute.ts +20 -0
- package/src/integrations/workspaceRoutes.test.ts +40 -0
- package/src/integrations/workspaceRoutes.ts +22 -0
- package/src/integrations/workspaceScreens.tsx +14 -0
- package/src/utils/drawer-trail.ts +35 -0
- package/src/utils/kindFilterStore.test.ts +147 -0
- package/src/utils/kindFilterStore.ts +91 -0
- package/src/utils/navigation.test.ts +13 -1
- package/src/utils/navigation.ts +39 -2
- package/src/utils/page-links.ts +21 -0
- package/src/utils/tabStorage.ts +40 -0
- package/src/utils/viewMemory.test.ts +78 -0
- package/src/utils/viewMemory.ts +54 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, type ReactNode } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { MemoryRouter } from 'react-router-dom'
|
|
5
|
+
import { expect, it, vi } from 'vitest'
|
|
6
|
+
vi.mock('../../../api/cnpg-ha', () => ({ useCNPGClusterHA: () => ({ data: { rwEndpoints: { state: 'ok', pods: ['orders-1'] }, pods: { state: 'ok' }, instances: [{ pod: 'orders-1', role: 'primary', ready: true }] } }) }))
|
|
7
|
+
vi.mock('../useCNPGKubectlContext', () => ({ useCNPGKubectlContext: () => ({ name: 'kind-test', source: 'test-config' }) }))
|
|
8
|
+
import { CNPGConnectButton } from './CNPGConnectButton'
|
|
9
|
+
const state = vi.hoisted(() => ({ refetch: vi.fn(), retained: false, row: undefined as any }))
|
|
10
|
+
vi.mock('../useCNPGSidebarWorkspace', () => ({ useCNPGFleet: () => ({ fleet: state.row ? { rows: [state.row] } : state.retained ? { rows: [] } : null, query: { data: state.retained ? { installed: true } : undefined, error: new Error('Read failed'), refetch: state.refetch, isRefetchError: state.retained, dataUpdatedAt: Date.now() - 120_000 } }) }))
|
|
11
|
+
vi.mock('@skyhook-io/k8s-ui', async (original) => ({ ...(await original<typeof import('@skyhook-io/k8s-ui')>()), DialogPortal: ({ open, children }: { open: boolean; children: ReactNode }) => open ? <div>{children}</div> : null }))
|
|
12
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
13
|
+
it('shows and retries a failed Cluster read in Connect', () => {
|
|
14
|
+
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host)
|
|
15
|
+
act(() => root.render(<MemoryRouter><CNPGConnectButton namespace="db" name="pg" /></MemoryRouter>))
|
|
16
|
+
act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Connect')!.click())
|
|
17
|
+
expect(host.textContent).toContain('Cluster could not be read: Read failed')
|
|
18
|
+
expect(host.textContent).not.toContain('Reading the Cluster')
|
|
19
|
+
act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Retry')!.click())
|
|
20
|
+
expect(state.refetch).toHaveBeenCalledTimes(1)
|
|
21
|
+
state.retained = true
|
|
22
|
+
act(() => root.render(<MemoryRouter><CNPGConnectButton namespace="db" name="pg" /></MemoryRouter>))
|
|
23
|
+
expect(host.textContent).toContain('Radar cannot read this Cluster with your access')
|
|
24
|
+
expect(host.textContent).toContain('Last refresh failed: Read failed')
|
|
25
|
+
expect(host.textContent).toContain('2m ago')
|
|
26
|
+
expect(host.textContent).not.toContain('Cluster could not be read:')
|
|
27
|
+
act(() => root.unmount()); host.remove()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('passes the known host context and independently read HA availability into the Connect dialog', () => {
|
|
31
|
+
state.row = { namespace: 'db', name: 'orders', cluster: { metadata: { name: 'orders', namespace: 'db' }, spec: {} } }
|
|
32
|
+
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host)
|
|
33
|
+
act(() => root.render(<MemoryRouter><CNPGConnectButton namespace="db" name="orders" /></MemoryRouter>))
|
|
34
|
+
act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Connect')!.click())
|
|
35
|
+
expect(host.textContent).toContain('kubectl --context kind-test -n db port-forward service/orders-rw')
|
|
36
|
+
expect(host.textContent).toContain('Context as named in your kubeconfig (test-config); kubectl must read the same kubeconfig file.')
|
|
37
|
+
expect(host.textContent).toContain('Unavailable: no ready standby')
|
|
38
|
+
expect(host.textContent).toContain('Ready endpoints')
|
|
39
|
+
act(() => root.unmount()); host.remove(); state.row = undefined
|
|
40
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { claimConnectRequest, cnpgConnectParamValue, registerConnectButton } from './CNPGConnectButton'
|
|
3
|
+
|
|
4
|
+
describe('Connect requests', () => {
|
|
5
|
+
it('names the Cluster and the surface', () => {
|
|
6
|
+
expect(cnpgConnectParamValue('db', 'pg', 'page')).toBe('db/pg')
|
|
7
|
+
expect(cnpgConnectParamValue('db', 'pg', 'drawer')).toBe('db/pg@drawer')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('lets one mounted button answer a request, and a remount answer it again', () => {
|
|
11
|
+
const page = Symbol('page')
|
|
12
|
+
const drawer = Symbol('drawer')
|
|
13
|
+
const unregisterPage = registerConnectButton(page)
|
|
14
|
+
const unregisterDrawer = registerConnectButton(drawer)
|
|
15
|
+
expect(claimConnectRequest('entry-1', page)).toBe(true)
|
|
16
|
+
expect(claimConnectRequest('entry-1', drawer)).toBe(false)
|
|
17
|
+
expect(claimConnectRequest('entry-2', drawer)).toBe(true)
|
|
18
|
+
|
|
19
|
+
unregisterPage()
|
|
20
|
+
const remounted = Symbol('page again')
|
|
21
|
+
registerConnectButton(remounted)
|
|
22
|
+
unregisterDrawer()
|
|
23
|
+
expect(claimConnectRequest('entry-2', remounted)).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useCNPGClusterHA } from '../../../api/cnpg-ha'
|
|
2
|
+
import { useCNPGKubectlContext } from '../useCNPGKubectlContext'
|
|
3
|
+
import { useEffect, useId, useMemo, useState } from 'react'
|
|
4
|
+
import { useLocation, useSearchParams } from 'react-router-dom'
|
|
5
|
+
import { Plug, X } from 'lucide-react'
|
|
6
|
+
import { CNPGConnectSection, DialogPortal, Tooltip, type ResourceRef, type NavigateToResource } from '@skyhook-io/k8s-ui'
|
|
7
|
+
import { buildWorkloadPath, refToSelectedResource } from '../../../utils/navigation'
|
|
8
|
+
import { useCNPGFleet } from '../useCNPGSidebarWorkspace'
|
|
9
|
+
import { Notice, RefreshFailedNotice } from '../../workspace/layout'
|
|
10
|
+
import { useCNPGNavigate } from '../useCNPGNavigate'
|
|
11
|
+
|
|
12
|
+
// A link can ask for the dialog (the restore "Next steps") with this param;
|
|
13
|
+
// the button opens it and drops the param, so closing it and following a link
|
|
14
|
+
// from it never write the URL at once. The value names the Cluster and the
|
|
15
|
+
// surface, so a drawer showing the page's own Cluster never opens a second one.
|
|
16
|
+
export const CNPG_CONNECT_PARAM = 'connect'
|
|
17
|
+
|
|
18
|
+
export function cnpgConnectParamValue(namespace: string, name: string, surface: 'page' | 'drawer'): string {
|
|
19
|
+
return surface === 'drawer' ? `${namespace}/${name}@drawer` : `${namespace}/${name}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A request is answered by one button: a full-screen drawer can carry the
|
|
23
|
+
// same surface as the page beneath it, and both see the request in the same
|
|
24
|
+
// commit. A button that answered and then unmounted (before the param was
|
|
25
|
+
// dropped) gives the request back, so its remount can answer it.
|
|
26
|
+
const mountedButtons = new Set<symbol>()
|
|
27
|
+
let answered: { key: string; by: symbol } | null = null
|
|
28
|
+
|
|
29
|
+
export function registerConnectButton(id: symbol): () => void {
|
|
30
|
+
mountedButtons.add(id)
|
|
31
|
+
return () => {
|
|
32
|
+
mountedButtons.delete(id)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when this button should open the dialog for the request at locationKey. */
|
|
37
|
+
export function claimConnectRequest(locationKey: string, id: symbol): boolean {
|
|
38
|
+
if (answered && answered.key === locationKey && answered.by !== id && mountedButtons.has(answered.by)) return false
|
|
39
|
+
answered = { key: locationKey, by: id }
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function CNPGConnectButton({
|
|
44
|
+
namespace,
|
|
45
|
+
name,
|
|
46
|
+
compact = false,
|
|
47
|
+
onNavigate,
|
|
48
|
+
}: {
|
|
49
|
+
namespace: string
|
|
50
|
+
name: string
|
|
51
|
+
compact?: boolean
|
|
52
|
+
onNavigate?: NavigateToResource
|
|
53
|
+
}) {
|
|
54
|
+
const kubeconfigContext = useCNPGKubectlContext()
|
|
55
|
+
const titleId = useId()
|
|
56
|
+
const location = useLocation()
|
|
57
|
+
const [params, setParams] = useSearchParams()
|
|
58
|
+
const [open, setOpen] = useState(false)
|
|
59
|
+
const ha = useCNPGClusterHA(namespace, name, { enabled: open, refetchInterval: false })
|
|
60
|
+
const id = useMemo(() => Symbol('cnpg-connect'), [])
|
|
61
|
+
useEffect(() => registerConnectButton(id), [id])
|
|
62
|
+
const requested = params.get(CNPG_CONNECT_PARAM) === cnpgConnectParamValue(namespace, name, compact ? 'drawer' : 'page')
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!requested || !claimConnectRequest(location.key, id)) return
|
|
65
|
+
setOpen(true)
|
|
66
|
+
// Keeps the page's return label, which lives in the history state.
|
|
67
|
+
setParams(
|
|
68
|
+
(prev) => {
|
|
69
|
+
const p = new URLSearchParams(prev)
|
|
70
|
+
p.delete(CNPG_CONNECT_PARAM)
|
|
71
|
+
return p
|
|
72
|
+
},
|
|
73
|
+
{ replace: true, state: location.state },
|
|
74
|
+
)
|
|
75
|
+
}, [requested, id, setParams, location.key, location.state])
|
|
76
|
+
const { query, fleet } = useCNPGFleet([namespace], open)
|
|
77
|
+
const row = fleet?.rows.find((r) => r.namespace === namespace && r.name === name)
|
|
78
|
+
const go = onNavigate
|
|
79
|
+
? (ref: ResourceRef) => {
|
|
80
|
+
setOpen(false)
|
|
81
|
+
onNavigate(refToSelectedResource(ref))
|
|
82
|
+
}
|
|
83
|
+
: undefined
|
|
84
|
+
const navigate = useCNPGNavigate()
|
|
85
|
+
const openReachability = (svc: { namespace: string; name: string }) => {
|
|
86
|
+
setOpen(false)
|
|
87
|
+
navigate(buildWorkloadPath({ kind: 'services', group: '', namespace: svc.namespace, name: svc.name, tab: 'reachability' }))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<>
|
|
92
|
+
<Tooltip content="Hosts, database, owner and the credentials Secret applications use" position="bottom">
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
onClick={() => setOpen(true)}
|
|
96
|
+
aria-label={compact ? 'Connect' : undefined}
|
|
97
|
+
className="btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium"
|
|
98
|
+
>
|
|
99
|
+
<Plug className="h-3.5 w-3.5" />
|
|
100
|
+
{!compact && 'Connect'}
|
|
101
|
+
</button>
|
|
102
|
+
</Tooltip>
|
|
103
|
+
<DialogPortal open={open} onClose={() => setOpen(false)} className="w-full max-w-3xl" ariaLabelledBy={titleId}>
|
|
104
|
+
<div className="flex items-start gap-3 border-b border-theme-border p-4">
|
|
105
|
+
<div className="min-w-0 flex-1">
|
|
106
|
+
<h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">
|
|
107
|
+
Connect to {name}
|
|
108
|
+
</h3>
|
|
109
|
+
<p className="mt-0.5 text-xs text-theme-text-tertiary">From the Cluster spec · hosts resolve inside the Kubernetes cluster</p>
|
|
110
|
+
</div>
|
|
111
|
+
<button type="button" onClick={() => setOpen(false)} aria-label="Close" className="rounded p-1 text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary">
|
|
112
|
+
<X className="h-5 w-5" />
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="max-h-[70vh] overflow-y-auto p-4">
|
|
116
|
+
<RefreshFailedNotice queries={[query, ha]} />
|
|
117
|
+
{row?.cluster ? (
|
|
118
|
+
<CNPGConnectSection ha={ha.data} haUnavailableReason={ha.isLoading ? 'Reading availability…' : ha.error instanceof Error ? `Availability could not be read: ${ha.error.message}` : undefined} kubeconfigContext={kubeconfigContext?.name} kubeconfigSource={kubeconfigContext?.source} cluster={row.cluster} poolers={row.poolerObjects} poolersKnown={row.poolersKnown} onNavigate={go} onOpenReachability={openReachability} showHeading={false} />
|
|
119
|
+
) : query.error && !query.data ? (
|
|
120
|
+
<Notice>
|
|
121
|
+
Cluster could not be read: {query.error instanceof Error ? query.error.message : 'unknown error'}.{' '}
|
|
122
|
+
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching} className="text-accent-text hover:underline">Retry</button>
|
|
123
|
+
</Notice>
|
|
124
|
+
) : (
|
|
125
|
+
<div className="text-sm text-theme-text-tertiary">{fleet ? 'Radar cannot read this Cluster with your access.' : 'Reading the Cluster…'}</div>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
</DialogPortal>
|
|
129
|
+
</>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { ActionConfirmDialog, PaneLoader } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { cnpgActionErrorCode, useCNPGAction } from '../../../api/cnpg'
|
|
4
|
+
import { actionCompleted, actionDialogResult } from '../../../api/actions'
|
|
5
|
+
import { useCNPGDestroyPlan } from '../../../api/cnpg-sessions'
|
|
6
|
+
import { useToast } from '../../ui/Toast'
|
|
7
|
+
import { trackCNPGOperation } from '../operations/store'
|
|
8
|
+
import { cnpgDestroyBlocker } from './actionModel'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `kubectl cnpg destroy` for one standby: its volumes are deleted (or kept,
|
|
12
|
+
* detached), then its Pod and its Jobs. The operator replaces it with a new
|
|
13
|
+
* instance under a new name. Binds the Pod and volume identities reviewed.
|
|
14
|
+
*/
|
|
15
|
+
export function CNPGDestroyInstanceDialog({
|
|
16
|
+
namespace,
|
|
17
|
+
cluster,
|
|
18
|
+
pod,
|
|
19
|
+
onClose,
|
|
20
|
+
onFenceFirst,
|
|
21
|
+
}: {
|
|
22
|
+
namespace: string
|
|
23
|
+
cluster: string
|
|
24
|
+
pod: string
|
|
25
|
+
onClose: () => void
|
|
26
|
+
/** Opens the fence dialog for this instance; destroy requires the fence. */
|
|
27
|
+
onFenceFirst?: () => void
|
|
28
|
+
}) {
|
|
29
|
+
const plan = useCNPGDestroyPlan(namespace, cluster, pod)
|
|
30
|
+
const mutation = useCNPGAction('clusters', namespace, cluster, plan.data?.context)
|
|
31
|
+
const { showSuccess } = useToast()
|
|
32
|
+
const [keep, setKeep] = useState(false)
|
|
33
|
+
const data = plan.data
|
|
34
|
+
const cap = data ? (keep ? data.actions.keep : data.actions.delete) : undefined
|
|
35
|
+
const pvcNames = data?.pvcs.map((p) => p.name) ?? []
|
|
36
|
+
const partial = cnpgActionErrorCode(mutation.outcomeError ?? mutation.error) === 'partial'
|
|
37
|
+
const fenced = !!data && (data.facts.fencedInstances.all || data.facts.fencedInstances.instances.includes(pod))
|
|
38
|
+
const completed = actionCompleted(mutation.outcomeError ?? mutation.error)
|
|
39
|
+
const fenceOffered = !!data && !fenced && !!onFenceFirst
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<ActionConfirmDialog
|
|
43
|
+
open
|
|
44
|
+
onClose={onClose}
|
|
45
|
+
onConfirm={() => {
|
|
46
|
+
if (!data) return
|
|
47
|
+
mutation.mutate(
|
|
48
|
+
{
|
|
49
|
+
action: 'destroyInstance',
|
|
50
|
+
request: {
|
|
51
|
+
reviewedContext: data.context,
|
|
52
|
+
uid: data.uid,
|
|
53
|
+
facts: data.facts,
|
|
54
|
+
params: { pod, podUID: data.podUID, keepPVC: keep, pvcs: data.pvcs.map((p) => ({ name: p.name, uid: p.uid })), jobs: data.jobs },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
onSuccess: (r) => {
|
|
59
|
+
showSuccess(r.message)
|
|
60
|
+
trackCNPGOperation({
|
|
61
|
+
kind: 'destroyInstance',
|
|
62
|
+
label: `Destroy ${pod}`,
|
|
63
|
+
context: data.context,
|
|
64
|
+
namespace,
|
|
65
|
+
cluster,
|
|
66
|
+
clusterUID: data.uid,
|
|
67
|
+
target: { name: pod, uid: data.podUID },
|
|
68
|
+
baseline: { instances: data.facts.instances.map((i) => i.pod) },
|
|
69
|
+
})
|
|
70
|
+
onClose()
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
}}
|
|
75
|
+
title={`Destroy instance ${pod}?`}
|
|
76
|
+
subject={{ kind: 'Cluster', namespace, name: cluster }}
|
|
77
|
+
context={data?.context}
|
|
78
|
+
effect={
|
|
79
|
+
keep
|
|
80
|
+
? `Deletes the Pod ${pod} and detaches its volumes from the cluster, keeping their data. The operator then creates a new instance with a new name and fresh volumes, which joins as a standby.`
|
|
81
|
+
: `Deletes the Pod ${pod} and its volumes${pvcNames.length ? ` (${pvcNames.join(', ')})` : ''}; their data is gone. The operator then creates a new instance with a new name and fresh volumes, which joins as a standby.`
|
|
82
|
+
}
|
|
83
|
+
disruptive
|
|
84
|
+
typedConfirmation={pod}
|
|
85
|
+
confirmLabel={keep ? 'Destroy, keep volumes' : 'Destroy instance'}
|
|
86
|
+
writes={
|
|
87
|
+
data
|
|
88
|
+
? [
|
|
89
|
+
...data.pvcs.map((p) =>
|
|
90
|
+
keep && p.owned
|
|
91
|
+
? { summary: `update PersistentVolumeClaim ${namespace}/${p.name}`, detail: `ownerReferences: Cluster ${cluster} removed\nmetadata.annotations["cnpg.io/pvcStatus"] = "detached"` }
|
|
92
|
+
: keep
|
|
93
|
+
? { summary: `leave PersistentVolumeClaim ${namespace}/${p.name}`, detail: 'already detached' }
|
|
94
|
+
: { summary: `delete PersistentVolumeClaim ${namespace}/${p.name}`, detail: `preconditions.uid = ${p.uid}` },
|
|
95
|
+
),
|
|
96
|
+
{ summary: `delete Pod ${namespace}/${pod}`, detail: data.podUID ? `preconditions.uid = ${data.podUID}` : 'the Pod no longer exists' },
|
|
97
|
+
{ summary: `delete reviewed Jobs of ${pod}`, detail: data.jobsReadable ? (data.jobs.length ? data.jobs.map((j) => `${j.name} · preconditions.uid = ${j.uid}`).join('\n') : 'none now') : 'not readable; review is unavailable' },
|
|
98
|
+
{ summary: `patch Cluster ${namespace}/${cluster}`, detail: `metadata.annotations["cnpg.io/fencedInstances"]: ${pod} removed (unless fenced with ["*"])` },
|
|
99
|
+
]
|
|
100
|
+
: []
|
|
101
|
+
}
|
|
102
|
+
notes={[
|
|
103
|
+
'The volumes are handled before the Pod, as kubectl cnpg destroy does, so the operator never recreates this Pod on them.',
|
|
104
|
+
fenceOffered
|
|
105
|
+
? `Once ${pod} is destroyed its name is removed from the fence (a fence on every instance, ["*"], stays).`
|
|
106
|
+
: `${pod} must be fenced first; its name is removed from the fence once it is destroyed (a fence on every instance, ["*"], stays).`,
|
|
107
|
+
'Use this for a standby that cannot rejoin (for example after a failed pg_rewind or a corrupted volume). A plain restart deletes only the Pod and keeps the volumes attached.',
|
|
108
|
+
'Only volumes owned by this Cluster or detached by Radar from this Cluster are included.',
|
|
109
|
+
keep ? 'Kept volumes stay in the namespace, no longer owned by the cluster; delete them yourself once you no longer need them.' : null,
|
|
110
|
+
].filter(Boolean) as string[]}
|
|
111
|
+
disabledReason={
|
|
112
|
+
plan.isLoading
|
|
113
|
+
? 'Reading the instance’s volumes…'
|
|
114
|
+
: !data
|
|
115
|
+
? plan.error instanceof Error ? plan.error.message : 'The destroy plan could not be read'
|
|
116
|
+
: cnpgDestroyBlocker(cap, fenceOffered)
|
|
117
|
+
}
|
|
118
|
+
guardSatisfied={!!cap?.allowed}
|
|
119
|
+
isLoading={mutation.isPending}
|
|
120
|
+
result={actionDialogResult(mutation.outcomeError ?? mutation.error, { unknown: 'An earlier write to this Cluster may have taken effect', partial: 'Earlier writes to this Cluster partially completed' })}
|
|
121
|
+
>
|
|
122
|
+
{partial && completed.length > 0 && (
|
|
123
|
+
<div className="rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm">
|
|
124
|
+
<div className="mb-1 text-xs text-theme-text-secondary">Writes completed by that earlier attempt</div>
|
|
125
|
+
<ul className="space-y-0.5">
|
|
126
|
+
{completed.map((c) => (
|
|
127
|
+
<li key={c} className="font-mono text-xs text-theme-text-primary">
|
|
128
|
+
{c}
|
|
129
|
+
</li>
|
|
130
|
+
))}
|
|
131
|
+
</ul>
|
|
132
|
+
<div className="mt-2 text-xs text-theme-text-tertiary">Check the resources named above before doing anything else; writes to this Cluster are disabled.</div>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
{plan.isLoading && <PaneLoader label="Reading the instance’s volumes…" className="h-16" />}
|
|
136
|
+
{fenceOffered && (
|
|
137
|
+
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
|
|
138
|
+
<span>{pod} must be fenced before it is destroyed: the operator never promotes a fenced instance, so a failover cannot make it primary meanwhile.</span>
|
|
139
|
+
<button type="button" onClick={onFenceFirst} className="btn-brand rounded-lg px-3 py-1.5 text-sm font-medium">
|
|
140
|
+
Fence {pod} first
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
{data && (
|
|
145
|
+
<div className="space-y-3">
|
|
146
|
+
<div>
|
|
147
|
+
<div className="mb-1 text-xs text-theme-text-secondary">Volumes of {pod}</div>
|
|
148
|
+
{!data.pvcsReadable ? (
|
|
149
|
+
<div className="text-sm text-theme-text-tertiary">Not readable: {data.pvcReason}</div>
|
|
150
|
+
) : data.pvcs.length === 0 ? (
|
|
151
|
+
<div className="text-sm text-theme-text-tertiary">None found for this instance.</div>
|
|
152
|
+
) : (
|
|
153
|
+
<ul className="space-y-0.5 text-sm">
|
|
154
|
+
{data.pvcs.map((p) => (
|
|
155
|
+
<li key={p.uid} className="font-mono text-xs">
|
|
156
|
+
{p.name}
|
|
157
|
+
<span className="font-sans text-theme-text-tertiary">
|
|
158
|
+
{' · '}
|
|
159
|
+
{[p.role, p.tablespace, p.capacity, p.storageClass, p.detached ? 'detached earlier' : null].filter(Boolean).join(' · ')}
|
|
160
|
+
</span>
|
|
161
|
+
</li>
|
|
162
|
+
))}
|
|
163
|
+
</ul>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
<fieldset className="space-y-1.5">
|
|
167
|
+
<legend className="mb-1 text-xs text-theme-text-secondary">Volumes</legend>
|
|
168
|
+
<label className="flex items-start gap-2 text-sm">
|
|
169
|
+
<input type="radio" name="cnpg-destroy-pvc" checked={!keep} onChange={() => setKeep(false)} className="mt-1" />
|
|
170
|
+
<span>
|
|
171
|
+
Delete them <span className="text-theme-text-tertiary">— the data on this standby is lost; the primary and the other standbys are unaffected</span>
|
|
172
|
+
</span>
|
|
173
|
+
</label>
|
|
174
|
+
<label className="flex items-start gap-2 text-sm">
|
|
175
|
+
<input type="radio" name="cnpg-destroy-pvc" checked={keep} onChange={() => setKeep(true)} className="mt-1" />
|
|
176
|
+
<span>
|
|
177
|
+
Keep them, detached <span className="text-theme-text-tertiary">(--keep-pvc) — for inspecting the old data; they still use storage</span>
|
|
178
|
+
</span>
|
|
179
|
+
</label>
|
|
180
|
+
</fieldset>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</ActionConfirmDialog>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
5
|
+
import { CNPGInstanceActions } from './CNPGInstanceActions'
|
|
6
|
+
|
|
7
|
+
const state = vi.hoisted(() => ({ data: {} as any }))
|
|
8
|
+
vi.mock('../../../api/cnpg', () => ({ useCNPGClusterCapabilities: () => ({ data: state.data }) }))
|
|
9
|
+
vi.mock('./useOpenCNPGPsql', () => ({ useOpenCNPGPsql: () => vi.fn() }))
|
|
10
|
+
vi.mock('./CNPGClusterActions', () => ({ ClusterActionDialog: () => <div>Restart review opened</div> }))
|
|
11
|
+
vi.mock('./CNPGDestroyInstanceDialog', () => ({ CNPGDestroyInstanceDialog: () => null }))
|
|
12
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
13
|
+
let root: ReturnType<typeof createRoot>
|
|
14
|
+
let host: HTMLDivElement
|
|
15
|
+
afterEach(() => { act(() => root?.unmount()); host?.remove(); vi.useRealTimers() })
|
|
16
|
+
it('keeps blocked restart focusable, reveals its reason on hover, focus and click, and never opens the write dialog', () => {
|
|
17
|
+
vi.useFakeTimers()
|
|
18
|
+
const reason = 'A standby is still joining; wait until it is ready'
|
|
19
|
+
state.data = { facts: { instances: [{ pod: 'orders-1' }], currentPrimary: 'orders-1', fencedInstances: { all: false, instances: [] } }, actions: {}, instanceActions: { 'orders-1': { restart: { allowed: false, reason }, psql: { allowed: true }, fence: { allowed: true } } } }
|
|
20
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
21
|
+
act(() => root.render(<CNPGInstanceActions namespace="db" cluster="orders" pod="orders-1" />))
|
|
22
|
+
const restart = [...host.querySelectorAll('button')].find((b) => b.textContent === 'Restart')!
|
|
23
|
+
expect(restart.disabled).toBe(false)
|
|
24
|
+
expect(restart.getAttribute('aria-disabled')).toBe('true')
|
|
25
|
+
act(() => { restart.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); vi.advanceTimersByTime(350) })
|
|
26
|
+
expect(document.body.textContent).toContain(reason)
|
|
27
|
+
act(() => { restart.focus(); vi.advanceTimersByTime(350) })
|
|
28
|
+
expect(document.activeElement).toBe(restart)
|
|
29
|
+
expect(document.body.textContent).toContain(reason)
|
|
30
|
+
act(() => restart.click())
|
|
31
|
+
expect(host.querySelector('[role="status"]')?.textContent).toBe(reason)
|
|
32
|
+
expect(host.textContent).not.toContain('Restart review opened')
|
|
33
|
+
state.data.instanceActions['orders-1'].restart = { allowed: true }
|
|
34
|
+
act(() => root.render(<CNPGInstanceActions namespace="db" cluster="orders" pod="orders-1" />))
|
|
35
|
+
expect(host.querySelector('[role="status"]')).toBeNull()
|
|
36
|
+
act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Restart')!.click())
|
|
37
|
+
expect(host.textContent).toContain('Restart review opened')
|
|
38
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useCNPGClusterCapabilities, type CNPGClusterActionName } from '../../../api/cnpg'
|
|
3
|
+
import { type ActionCapability, capabilityReason } from '../../../api/actions'
|
|
4
|
+
import { ClusterActionDialog } from './CNPGClusterActions'
|
|
5
|
+
import { CNPGDestroyInstanceDialog } from './CNPGDestroyInstanceDialog'
|
|
6
|
+
import { useOpenCNPGPsql } from './useOpenCNPGPsql'
|
|
7
|
+
import { Tooltip } from '@skyhook-io/k8s-ui'
|
|
8
|
+
|
|
9
|
+
const LINK = 'text-accent-text hover:underline aria-disabled:cursor-not-allowed aria-disabled:text-theme-text-disabled aria-disabled:no-underline'
|
|
10
|
+
|
|
11
|
+
/** Per-instance operations shown on a replication row. */
|
|
12
|
+
export function CNPGInstanceActions({ namespace, cluster, pod }: { namespace: string; cluster: string; pod: string }) {
|
|
13
|
+
const caps = useCNPGClusterCapabilities(namespace, cluster)
|
|
14
|
+
const [open, setOpen] = useState<CNPGClusterActionName | null>(null)
|
|
15
|
+
const [blockedAction, setBlockedAction] = useState<CNPGClusterActionName | 'psql' | 'destroy'>()
|
|
16
|
+
const [destroying, setDestroying] = useState(false)
|
|
17
|
+
const openPsql = useOpenCNPGPsql()
|
|
18
|
+
const data = caps.data
|
|
19
|
+
if (!data) return null
|
|
20
|
+
const inst = data.facts.instances.find((i) => i.pod === pod)
|
|
21
|
+
if (!inst) return null
|
|
22
|
+
const isPrimary = pod === data.facts.currentPrimary
|
|
23
|
+
const allFenced = data.facts.fencedInstances.all
|
|
24
|
+
const isFenced = allFenced || data.facts.fencedInstances.instances.includes(pod)
|
|
25
|
+
const per = data.instanceActions?.[pod]
|
|
26
|
+
const capFor = (id: CNPGClusterActionName) =>
|
|
27
|
+
id === 'switchover' ? per?.switchoverTarget : id === 'restartInstance' ? per?.restart : id === 'fence' ? per?.fence : id === 'unfence' ? per?.unfence : data.actions[id]
|
|
28
|
+
|
|
29
|
+
const button = (key: CNPGClusterActionName | 'psql' | 'destroy', label: string, reason: string | undefined, onClick: () => void) => (
|
|
30
|
+
<Tooltip key={key} content={reason}>
|
|
31
|
+
<button type="button" aria-disabled={!!reason} onClick={() => { if (reason) setBlockedAction(key); else { setBlockedAction(undefined); onClick() } }} className={LINK}>
|
|
32
|
+
{label}
|
|
33
|
+
</button>
|
|
34
|
+
</Tooltip>
|
|
35
|
+
)
|
|
36
|
+
const link = (id: CNPGClusterActionName, label: string, extraReason?: string) => {
|
|
37
|
+
const cap = capFor(id) ?? data.actions[id]
|
|
38
|
+
return button(id, label, extraReason ?? capabilityReason(cap), () => setOpen(id))
|
|
39
|
+
}
|
|
40
|
+
const reasonOf = (cap: ActionCapability | undefined) => (!cap ? 'Not available for this instance' : capabilityReason(cap))
|
|
41
|
+
|
|
42
|
+
const blockedReason = blockedAction === 'psql' ? reasonOf(per?.psql) : blockedAction === 'destroy' ? (isFenced ? reasonOf(per?.destroy) : undefined) : blockedAction === 'unfence' && allFenced ? 'The whole cluster is fenced; lift it from the cluster menu' : blockedAction ? capabilityReason(capFor(blockedAction) ?? data.actions[blockedAction]) : undefined
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<>
|
|
46
|
+
{button('psql', 'psql', reasonOf(per?.psql), () => openPsql(namespace, pod, isPrimary))}
|
|
47
|
+
{!isPrimary && link('switchover', 'Promote')}
|
|
48
|
+
{link('restartInstance', 'Restart')}
|
|
49
|
+
{isFenced
|
|
50
|
+
? link('unfence', 'Lift fence', allFenced ? 'The whole cluster is fenced; lift it from the cluster menu' : undefined)
|
|
51
|
+
: link('fence', 'Fence')}
|
|
52
|
+
{/* Unfenced, the dialog opens anyway: it explains the fence and offers it. */}
|
|
53
|
+
{!isPrimary && button('destroy', 'Destroy…', isFenced ? reasonOf(per?.destroy) : undefined, () => setDestroying(true))}
|
|
54
|
+
{blockedReason && <div role="status" className="basis-full text-xs text-theme-text-secondary">{blockedReason}</div>}
|
|
55
|
+
{open && <ClusterActionDialog kind={open} caps={data} namespace={namespace} name={cluster} initialPod={pod} onClose={() => setOpen(null)} />}
|
|
56
|
+
{destroying && (
|
|
57
|
+
<CNPGDestroyInstanceDialog
|
|
58
|
+
namespace={namespace}
|
|
59
|
+
cluster={cluster}
|
|
60
|
+
pod={pod}
|
|
61
|
+
onClose={() => setDestroying(false)}
|
|
62
|
+
onFenceFirst={
|
|
63
|
+
per?.fence?.allowed
|
|
64
|
+
? () => {
|
|
65
|
+
setDestroying(false)
|
|
66
|
+
setOpen('fence')
|
|
67
|
+
}
|
|
68
|
+
: undefined
|
|
69
|
+
}
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
</>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { AlertBanner, Tooltip } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { useCNPGClusterCapabilities } from '../../../api/cnpg'
|
|
4
|
+
import { ClusterActionDialog } from './CNPGClusterActions'
|
|
5
|
+
import { capabilityReason } from '../../../api/actions'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Standing notice while spec.nodeMaintenanceWindow.inProgress is true. The
|
|
9
|
+
* mode suspends self-healing, so it must never be forgotten on: the way out is
|
|
10
|
+
* on the banner itself.
|
|
11
|
+
*/
|
|
12
|
+
export function CNPGMaintenanceBanner({
|
|
13
|
+
namespace,
|
|
14
|
+
name,
|
|
15
|
+
maintenance,
|
|
16
|
+
}: {
|
|
17
|
+
namespace: string
|
|
18
|
+
name: string
|
|
19
|
+
maintenance?: { inProgress: boolean; reusePVC: boolean }
|
|
20
|
+
}) {
|
|
21
|
+
const caps = useCNPGClusterCapabilities(namespace, name, !!maintenance?.inProgress)
|
|
22
|
+
const [open, setOpen] = useState(false)
|
|
23
|
+
if (!maintenance?.inProgress) return null
|
|
24
|
+
const cap = caps.data?.actions.unsetMaintenance
|
|
25
|
+
return (
|
|
26
|
+
<div className="mb-3">
|
|
27
|
+
<AlertBanner
|
|
28
|
+
variant="warning"
|
|
29
|
+
title="Node maintenance in progress"
|
|
30
|
+
message={
|
|
31
|
+
maintenance.reusePVC
|
|
32
|
+
? 'Self-healing, rolling updates and disruption budgets are limited: an instance on a drained node waits for that node and keeps its volume. Lift this as soon as the node work is done.'
|
|
33
|
+
: 'Self-healing, rolling updates and disruption budgets are limited: an instance on a drained node is rebuilt elsewhere on a new volume. Lift this as soon as the node work is done.'
|
|
34
|
+
}
|
|
35
|
+
>
|
|
36
|
+
<div className="mt-2">
|
|
37
|
+
<Tooltip content={cap ? capabilityReason(cap) : undefined}>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
disabled={!cap?.allowed}
|
|
41
|
+
onClick={() => setOpen(true)}
|
|
42
|
+
className="btn-brand px-3 py-1 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
|
43
|
+
>
|
|
44
|
+
Lift maintenance…
|
|
45
|
+
</button>
|
|
46
|
+
</Tooltip>
|
|
47
|
+
<span className="ml-2 text-xs text-theme-text-tertiary">spec.nodeMaintenanceWindow.inProgress</span>
|
|
48
|
+
</div>
|
|
49
|
+
</AlertBanner>
|
|
50
|
+
{open && caps.data && <ClusterActionDialog kind="unsetMaintenance" caps={caps.data} namespace={namespace} name={name} onClose={() => setOpen(false)} />}
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { expect, it, vi } from 'vitest'
|
|
5
|
+
import { CNPGPoolerActions } from './CNPGPoolerActions'
|
|
6
|
+
|
|
7
|
+
const mocks = vi.hoisted(() => ({ mutate: vi.fn(), success: vi.fn() }))
|
|
8
|
+
vi.mock('../../ui/Toast', () => ({ useToast: () => ({ showSuccess: mocks.success }) }))
|
|
9
|
+
vi.mock('./useCNPGWriteGuard', () => ({ useCNPGWriteGuard: () => ({ node: null, satisfied: true }) }))
|
|
10
|
+
vi.mock('../../../api/cnpg', () => ({ useCNPGAction: () => ({ mutate: mocks.mutate, isPending: false }) }))
|
|
11
|
+
vi.mock('../../../api/cnpg-sessions', () => ({
|
|
12
|
+
useCNPGPoolerCapabilities: () => ({ data: {
|
|
13
|
+
uid: 'pooler-uid', resourceVersion: '9', context: 'kind-test',
|
|
14
|
+
facts: { generation: 4, cluster: 'payments', type: 'rw', paused: true, parameters: {}, terminating: false, deployment: { name: 'pooler', state: 'ok', readyReplicas: 2 }, service: { name: 'pooler', state: 'ok' } },
|
|
15
|
+
actions: { pause: { allowed: true }, resume: { allowed: true }, observeState: { allowed: true } },
|
|
16
|
+
} }),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
it('binds the reviewed generation along with the paused state', async () => {
|
|
20
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
21
|
+
const host = document.createElement('div')
|
|
22
|
+
document.body.append(host)
|
|
23
|
+
const root = createRoot(host)
|
|
24
|
+
try {
|
|
25
|
+
await act(async () => root.render(<CNPGPoolerActions namespace="prod" name="pooler" />))
|
|
26
|
+
await act(async () => [...host.querySelectorAll('button')].find((button) => button.textContent === 'Resume')!.click())
|
|
27
|
+
const confirm = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find((button) => button.textContent === 'Resume')!
|
|
28
|
+
expect(confirm).toBeDefined()
|
|
29
|
+
await act(async () => confirm.click())
|
|
30
|
+
expect(mocks.mutate.mock.calls[0][0]).toMatchObject({ action: 'resume', request: { reviewedContext: 'kind-test', uid: 'pooler-uid', facts: { paused: true, generation: 4 } } })
|
|
31
|
+
} finally {
|
|
32
|
+
await act(async () => root.unmount())
|
|
33
|
+
host.remove()
|
|
34
|
+
}
|
|
35
|
+
})
|