@skyhook-io/radar-app 1.16.3 → 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 +138 -93
- package/src/RadarApp.tsx +27 -17
- 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.goneResource.test.ts +49 -0
- 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 +381 -47
- 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/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +65 -3
- package/src/api/radarFeatures.ts +6 -0
- 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/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/applications/ApplicationsView.tsx +3 -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 +24 -62
- package/src/components/capacity/CapacityView.test.tsx +354 -1
- 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 +23 -150
- 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 +230 -124
- 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/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- 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/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/ChartBrowser.tsx +3 -3
- 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/RevisionHistory.tsx +3 -2
- 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/ClusterHealthCard.tsx +7 -0
- 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/home/mcpToolCatalog.ts +1 -1
- 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/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +150 -34
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.tsx +25 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +25 -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/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -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 +11 -1
- package/src/components/workload/WorkloadView.tsx +162 -529
- 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/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- 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,853 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
2
|
+
import { ChevronDown, DatabaseBackup, MoreHorizontal, Repeat, X } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { ActionConfirmDialog, DialogPortal, FoldSection, Tooltip, cnpgPDBFact, cnpgQuorumFact, toneTextClass, type ActionWrite, type CNPGClusterHA, type CNPGProblem } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { useCNPGAction, useCNPGClusterCapabilities, useCNPGRuntime, type CNPGActionResult, type CNPGBackupMethod, type CNPGClusterActionName, type CNPGClusterCapabilities } from '../../../api/cnpg'
|
|
6
|
+
import { actionDialogResult, type ActionCapability, capabilityReason } from '../../../api/actions'
|
|
7
|
+
import { useToast } from '../../ui/Toast'
|
|
8
|
+
import { useCNPGClusterHA } from '../../../api/cnpg-ha'
|
|
9
|
+
import { trackCNPGOperation, type TrackCNPGOperationInput } from '../operations/store'
|
|
10
|
+
import { useCNPGWriteGuard, type CNPGWriteScope } from './useCNPGWriteGuard'
|
|
11
|
+
import { cnpgOperatorActionNote } from '../operatorStatus'
|
|
12
|
+
import { CNPGRestoreDialog } from '../recovery/CNPGRestoreDialog'
|
|
13
|
+
import { CNPGReportDialog } from './CNPGReportDialog'
|
|
14
|
+
import { useOpenCNPGPsql } from './useOpenCNPGPsql'
|
|
15
|
+
import {
|
|
16
|
+
backupNameFor,
|
|
17
|
+
describeBackupMethod,
|
|
18
|
+
pickDefaultStandby,
|
|
19
|
+
switchoverCandidateFacts,
|
|
20
|
+
switchoverConcernWarning,
|
|
21
|
+
switchoverConcerns,
|
|
22
|
+
switchoverDefault,
|
|
23
|
+
switchoverLagNote,
|
|
24
|
+
type StandbyChoice,
|
|
25
|
+
} from './actionModel'
|
|
26
|
+
import { cnpgDetailPath } from '../routes'
|
|
27
|
+
import { useCNPGNavigate } from '../useCNPGNavigate'
|
|
28
|
+
import { useCNPGFleet } from '../useCNPGSidebarWorkspace'
|
|
29
|
+
import { RefreshFailedNotice } from '../../workspace/layout'
|
|
30
|
+
import { assessRestoreSources } from '../recovery/restoreModel'
|
|
31
|
+
import { lsnDistance, standbyOwnBacklog } from '../lsn'
|
|
32
|
+
|
|
33
|
+
type DialogKind = CNPGClusterActionName | 'restore' | 'report' | null
|
|
34
|
+
|
|
35
|
+
const MENU_ITEM = 'block w-full px-3 py-1.5 text-left text-sm text-theme-text-primary hover:bg-theme-hover disabled:cursor-not-allowed disabled:text-theme-text-disabled'
|
|
36
|
+
|
|
37
|
+
const RESTART_EFFECT: Record<string, string> = {
|
|
38
|
+
recreate: 'Will recreate the standby Pod',
|
|
39
|
+
skipped_fenced: 'Will skip this fenced instance',
|
|
40
|
+
switchover: 'Will switch over to an updated standby, then recreate the former primary Pod',
|
|
41
|
+
restart: 'Will restart the primary in place',
|
|
42
|
+
wait_for_user: 'Will wait for you to promote or restart the primary',
|
|
43
|
+
restart_only_instance: 'Will restart the only instance (service stops during restart)',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function EffectItem({ list, label }: { list: { available: boolean; reason?: string; names: string[] }; label: string }) {
|
|
47
|
+
if (!list.available) return <li>{label}: not known{list.reason ? ` (${list.reason})` : ''}</li>
|
|
48
|
+
if (list.names.length === 0) return null
|
|
49
|
+
return <li>{label}: {list.names.join(', ')}</li>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function capabilityTitle(cap: ActionCapability | undefined): string | undefined {
|
|
53
|
+
return cap ? capabilityReason(cap) : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Operations on a CloudNativePG Cluster. Every button reflects the server's
|
|
58
|
+
* capability answer (RBAC plus the cluster's state) and says why it is
|
|
59
|
+
* disabled; every write goes through a confirmation that leads with the
|
|
60
|
+
* effect, lists the literal API writes, and names GitOps overwrite risk.
|
|
61
|
+
*/
|
|
62
|
+
export function CNPGClusterActions({ namespace, name, compact = false }: { namespace: string; name: string; compact?: boolean }) {
|
|
63
|
+
const caps = useCNPGClusterCapabilities(namespace, name)
|
|
64
|
+
const [open, setOpen] = useState<DialogKind>(null)
|
|
65
|
+
const [menu, setMenu] = useState(false)
|
|
66
|
+
const [blockedAction, setBlockedAction] = useState<'backup' | 'switchover'>()
|
|
67
|
+
|
|
68
|
+
const { query: workspace, fleet } = useCNPGFleet([namespace])
|
|
69
|
+
const restoreSourceReason = assessRestoreSources(workspace.data, namespace, fleet?.rows.find((r) => r.name === name && r.namespace === namespace)?.cluster).disabledReason
|
|
70
|
+
const actions = caps.data?.actions
|
|
71
|
+
const openPsql = useOpenCNPGPsql()
|
|
72
|
+
const unavailable = caps.data
|
|
73
|
+
? undefined
|
|
74
|
+
: caps.error
|
|
75
|
+
? `Actions unavailable: what you may do could not be checked (${caps.error instanceof Error ? caps.error.message : 'unknown error'})`
|
|
76
|
+
: 'Checking what you may do…'
|
|
77
|
+
const blockedReason = blockedAction && !actions?.[blockedAction]?.allowed ? unavailable ?? capabilityTitle(actions?.[blockedAction]) : undefined
|
|
78
|
+
|
|
79
|
+
const item = (id: CNPGClusterActionName, label: string) => {
|
|
80
|
+
const cap = actions?.[id]
|
|
81
|
+
const title = unavailable ?? capabilityTitle(cap)
|
|
82
|
+
return (
|
|
83
|
+
<Tooltip key={id} content={title} position="left" wrapperClassName="w-full">
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
role="menuitem"
|
|
87
|
+
disabled={!cap?.allowed}
|
|
88
|
+
onClick={() => {
|
|
89
|
+
setMenu(false)
|
|
90
|
+
setOpen(id)
|
|
91
|
+
}}
|
|
92
|
+
className={MENU_ITEM}
|
|
93
|
+
>
|
|
94
|
+
{label}
|
|
95
|
+
{!cap?.allowed && <span className="mt-0.5 block text-xs text-theme-text-secondary">{title}</span>}
|
|
96
|
+
</button>
|
|
97
|
+
</Tooltip>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const hibernated = !!caps.data?.facts.hibernated
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="relative flex flex-wrap items-center gap-1.5">
|
|
105
|
+
<Tooltip content={unavailable ?? capabilityTitle(actions?.backup) ?? 'Create an on-demand Backup'} position="bottom">
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
aria-disabled={!actions?.backup.allowed}
|
|
109
|
+
aria-label="Back up now"
|
|
110
|
+
onClick={() => { if (actions?.backup.allowed) { setBlockedAction(undefined); setOpen('backup') } else setBlockedAction('backup') }}
|
|
111
|
+
className={clsx('btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium', !actions?.backup.allowed && 'cursor-not-allowed opacity-50')}
|
|
112
|
+
>
|
|
113
|
+
<DatabaseBackup className="h-3.5 w-3.5" />
|
|
114
|
+
{!compact && 'Back up now'}
|
|
115
|
+
</button>
|
|
116
|
+
</Tooltip>
|
|
117
|
+
{!compact && (
|
|
118
|
+
<Tooltip content={unavailable ?? capabilityTitle(actions?.switchover) ?? 'Promote a standby to primary'} position="bottom">
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
aria-disabled={!actions?.switchover.allowed}
|
|
122
|
+
onClick={() => { if (actions?.switchover.allowed) { setBlockedAction(undefined); setOpen('switchover') } else setBlockedAction('switchover') }}
|
|
123
|
+
className={clsx('btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium', !actions?.switchover.allowed && 'cursor-not-allowed opacity-50')}
|
|
124
|
+
>
|
|
125
|
+
<Repeat className="h-3.5 w-3.5" />
|
|
126
|
+
Switchover
|
|
127
|
+
</button>
|
|
128
|
+
</Tooltip>
|
|
129
|
+
)}
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
aria-haspopup="dialog"
|
|
133
|
+
aria-expanded={menu}
|
|
134
|
+
aria-label="More cluster actions"
|
|
135
|
+
onClick={() => { setBlockedAction(undefined); setMenu((v) => !v) }}
|
|
136
|
+
className="btn-secondary inline-flex items-center gap-0.5 px-2 py-1.5 text-xs"
|
|
137
|
+
>
|
|
138
|
+
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
139
|
+
<ChevronDown className="h-3 w-3" />
|
|
140
|
+
</button>
|
|
141
|
+
{blockedReason && <div role="status" className="basis-full text-xs text-theme-text-secondary">{blockedReason}</div>}
|
|
142
|
+
<DialogPortal
|
|
143
|
+
open={menu}
|
|
144
|
+
onClose={() => setMenu(false)}
|
|
145
|
+
ariaLabel="Cluster actions"
|
|
146
|
+
className="w-full max-w-sm p-2"
|
|
147
|
+
>
|
|
148
|
+
<div className="flex items-center justify-between gap-3 px-3 py-2">
|
|
149
|
+
<h3 className="text-sm font-semibold text-theme-text-primary">Cluster actions · {name}</h3>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={() => setMenu(false)}
|
|
153
|
+
aria-label="Close cluster actions"
|
|
154
|
+
className="rounded p-1 text-theme-text-secondary hover:bg-theme-hover"
|
|
155
|
+
>
|
|
156
|
+
<X className="h-4 w-4" />
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
<div role="menu" className="max-h-[65vh] overflow-y-auto">
|
|
160
|
+
{compact && item('switchover', 'Switchover…')}
|
|
161
|
+
{item('restart', 'Restart instances…')}
|
|
162
|
+
{item('reload', 'Reload configuration…')}
|
|
163
|
+
{item('fence', 'Fence instances…')}
|
|
164
|
+
{item('unfence', 'Lift fencing…')}
|
|
165
|
+
{hibernated ? item('rehydrate', 'Resume from hibernation…') : item('hibernate', 'Hibernate…')}
|
|
166
|
+
<div className="my-1 border-t border-theme-border" />
|
|
167
|
+
<Tooltip content={unavailable ?? capabilityTitle(actions?.psql)} position="left" wrapperClassName="w-full">
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
role="menuitem"
|
|
171
|
+
disabled={!actions?.psql.allowed || !caps.data?.facts.currentPrimary}
|
|
172
|
+
onClick={() => {
|
|
173
|
+
setMenu(false)
|
|
174
|
+
if (caps.data?.facts.currentPrimary) openPsql(namespace, caps.data.facts.currentPrimary, true)
|
|
175
|
+
}}
|
|
176
|
+
className={MENU_ITEM}
|
|
177
|
+
>
|
|
178
|
+
Open psql on the primary
|
|
179
|
+
{(!actions?.psql.allowed || !caps.data?.facts.currentPrimary) && (
|
|
180
|
+
<span className="mt-0.5 block text-xs text-theme-text-secondary">
|
|
181
|
+
{unavailable ?? capabilityTitle(actions?.psql) ?? 'No primary instance is reported'}
|
|
182
|
+
</span>
|
|
183
|
+
)}
|
|
184
|
+
</button>
|
|
185
|
+
</Tooltip>
|
|
186
|
+
<div className="px-3 pb-0.5 pt-1 text-[11px] uppercase tracking-wide text-theme-text-tertiary">Advanced</div>
|
|
187
|
+
{caps.data?.facts.maintenance.inProgress
|
|
188
|
+
? item('unsetMaintenance', 'Lift node maintenance…')
|
|
189
|
+
: item('setMaintenance', 'Set node maintenance…')}
|
|
190
|
+
<div className="my-1 border-t border-theme-border" />
|
|
191
|
+
<Tooltip
|
|
192
|
+
content={unavailable ?? capabilityTitle(actions?.restore) ?? restoreSourceReason}
|
|
193
|
+
position="left"
|
|
194
|
+
wrapperClassName="w-full"
|
|
195
|
+
>
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
role="menuitem"
|
|
199
|
+
disabled={!actions?.restore.allowed || !!restoreSourceReason}
|
|
200
|
+
className={MENU_ITEM}
|
|
201
|
+
onClick={() => {
|
|
202
|
+
setMenu(false)
|
|
203
|
+
setOpen('restore')
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
Restore to a new cluster…
|
|
207
|
+
</button>
|
|
208
|
+
</Tooltip>
|
|
209
|
+
{(!actions?.restore.allowed || restoreSourceReason) && (
|
|
210
|
+
<div className="px-3 py-1 text-xs text-theme-text-secondary">
|
|
211
|
+
{unavailable ?? capabilityTitle(actions?.restore) ?? restoreSourceReason}
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
role="menuitem"
|
|
217
|
+
className={MENU_ITEM}
|
|
218
|
+
onClick={() => {
|
|
219
|
+
setMenu(false)
|
|
220
|
+
setOpen('report')
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
Download report…
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
</DialogPortal>
|
|
227
|
+
<RefreshFailedNotice queries={[caps]} />
|
|
228
|
+
{caps.error && !caps.data && (
|
|
229
|
+
<div role="status" className="text-xs text-theme-text-secondary">
|
|
230
|
+
Actions could not be checked: {caps.error instanceof Error ? caps.error.message : 'unknown error'}.{' '}
|
|
231
|
+
<button type="button" onClick={() => void caps.refetch()} disabled={caps.isFetching} className="text-accent-text hover:underline">Retry</button>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
{caps.data && open && open !== 'restore' && open !== 'report' && (
|
|
235
|
+
<ClusterActionDialog kind={open} caps={caps.data} namespace={namespace} name={name} onClose={() => setOpen(null)} />
|
|
236
|
+
)}
|
|
237
|
+
{open === 'restore' && <CNPGRestoreDialog namespace={namespace} entry={{ kind: 'cluster', name }} onClose={() => setOpen(null)} />}
|
|
238
|
+
{open === 'report' && <CNPGReportDialog namespace={namespace} name={name} onClose={() => setOpen(null)} />}
|
|
239
|
+
</div>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const UNSUPPORTED = {
|
|
244
|
+
title: 'Unsupported action',
|
|
245
|
+
confirmLabel: 'Close',
|
|
246
|
+
effect: '',
|
|
247
|
+
writes: [],
|
|
248
|
+
scope: { kind: 'status' as const },
|
|
249
|
+
success: () => '',
|
|
250
|
+
invalid: 'Restart a single instance from its row in the Replication tab.',
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
interface DialogSpec {
|
|
254
|
+
title: string
|
|
255
|
+
confirmLabel: string
|
|
256
|
+
effect: ReactNode
|
|
257
|
+
body?: ReactNode
|
|
258
|
+
notes?: ReactNode[]
|
|
259
|
+
warnings?: ReactNode[]
|
|
260
|
+
writes: ActionWrite[]
|
|
261
|
+
scope: CNPGWriteScope
|
|
262
|
+
typed?: boolean
|
|
263
|
+
disruptive?: boolean
|
|
264
|
+
params?: Record<string, unknown>
|
|
265
|
+
success: (result: { backup?: string }) => string
|
|
266
|
+
invalid?: string
|
|
267
|
+
/** What the form still needs; disables confirm without an alert. */
|
|
268
|
+
incomplete?: string
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
type TrackedSpec = Omit<TrackCNPGOperationInput, 'context' | 'namespace' | 'cluster' | 'clusterUID'>
|
|
272
|
+
|
|
273
|
+
/** What to follow after the server accepted `kind`: the target and the baseline to compare against. */
|
|
274
|
+
function trackedOperationFor(
|
|
275
|
+
kind: CNPGClusterActionName,
|
|
276
|
+
x: {
|
|
277
|
+
result: CNPGActionResult
|
|
278
|
+
facts: CNPGClusterCapabilities['facts']
|
|
279
|
+
switchTarget?: string
|
|
280
|
+
chosenPodUID?: string
|
|
281
|
+
fenceSel: string
|
|
282
|
+
fenced: string[]
|
|
283
|
+
instance?: CNPGClusterCapabilities['facts']['instances'][number]
|
|
284
|
+
},
|
|
285
|
+
): TrackedSpec | null {
|
|
286
|
+
const pods = x.facts.instances.map((i) => i.pod)
|
|
287
|
+
const podUIDs = Object.fromEntries(x.facts.instances.filter((i) => i.podUID).map((i) => [i.pod, i.podUID]))
|
|
288
|
+
switch (kind) {
|
|
289
|
+
case 'backup':
|
|
290
|
+
return x.result.backup
|
|
291
|
+
? { kind, label: `Backup ${x.result.backup}`, target: { name: x.result.backup }, link: { kind: 'Backup', group: 'postgresql.cnpg.io', name: x.result.backup } }
|
|
292
|
+
: null
|
|
293
|
+
case 'switchover':
|
|
294
|
+
return x.switchTarget
|
|
295
|
+
? { kind, label: `Switchover to ${x.switchTarget}`, target: { name: x.switchTarget, uid: x.chosenPodUID }, baseline: { currentPrimary: x.facts.currentPrimary } }
|
|
296
|
+
: null
|
|
297
|
+
case 'restart':
|
|
298
|
+
return {
|
|
299
|
+
kind,
|
|
300
|
+
label: 'Rolling restart',
|
|
301
|
+
baseline: { instances: pods, podUIDs, fenced: x.facts.fencedInstances.all ? pods : x.facts.fencedInstances.instances },
|
|
302
|
+
}
|
|
303
|
+
case 'restartInstance':
|
|
304
|
+
return x.instance
|
|
305
|
+
? { kind, label: `Restart ${x.instance.pod}`, target: { name: x.instance.pod, uid: x.instance.podUID }, baseline: { podUIDs: { [x.instance.pod]: x.instance.podUID } } }
|
|
306
|
+
: null
|
|
307
|
+
case 'reload':
|
|
308
|
+
return { kind, label: 'Configuration reload' }
|
|
309
|
+
case 'fence':
|
|
310
|
+
return { kind, label: x.fenceSel === '*' ? 'Fence all instances' : `Fence ${x.fenceSel}`, baseline: { instances: x.fenceSel === '*' ? pods : [x.fenceSel] } }
|
|
311
|
+
case 'unfence': {
|
|
312
|
+
const lifted = x.fenceSel === '*' ? (x.fenced.includes('*') ? pods : x.fenced) : [x.fenceSel]
|
|
313
|
+
return { kind, label: x.fenceSel === '*' ? 'Lift all fencing' : `Lift fence on ${x.fenceSel}`, baseline: { instances: lifted } }
|
|
314
|
+
}
|
|
315
|
+
case 'hibernate':
|
|
316
|
+
return { kind, label: 'Hibernate' }
|
|
317
|
+
case 'rehydrate':
|
|
318
|
+
return { kind, label: 'Resume from hibernation' }
|
|
319
|
+
case 'setMaintenance':
|
|
320
|
+
return { kind, label: 'Set node maintenance' }
|
|
321
|
+
case 'unsetMaintenance':
|
|
322
|
+
return { kind, label: 'Lift node maintenance' }
|
|
323
|
+
default:
|
|
324
|
+
return null
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Sync requirements, quorum and disruption budgets, as they stand before a switchover. */
|
|
329
|
+
function SwitchoverContext({ ha, haLoading, target }: { ha?: CNPGClusterHA; haLoading: boolean; target?: string }) {
|
|
330
|
+
if (!ha) {
|
|
331
|
+
return <div className="mt-2 text-xs text-theme-text-tertiary">{haLoading ? 'Reading quorum and disruption budgets…' : 'Quorum and disruption budgets could not be read.'}</div>
|
|
332
|
+
}
|
|
333
|
+
const q = ha.quorum
|
|
334
|
+
const quorum = cnpgQuorumFact(q)
|
|
335
|
+
const pdb = cnpgPDBFact(ha.pdbs)
|
|
336
|
+
return (
|
|
337
|
+
<div className="mt-3 space-y-1 rounded-md border border-theme-border bg-theme-base p-2 text-xs text-theme-text-secondary">
|
|
338
|
+
<div>
|
|
339
|
+
<span className="text-theme-text-tertiary">Synchronous replication: </span>
|
|
340
|
+
{q.method || q.number !== undefined
|
|
341
|
+
? `${(q.method ?? '').toUpperCase()} ${q.number ?? ''}${q.dataDurability ? ` · dataDurability ${q.dataDurability}` : ''}`.trim()
|
|
342
|
+
: 'not configured (asynchronous)'}
|
|
343
|
+
</div>
|
|
344
|
+
<div>
|
|
345
|
+
<span className="text-theme-text-tertiary">Failover quorum: </span>
|
|
346
|
+
{quorum.text}
|
|
347
|
+
{q.enabled && q.promotable && target ? (q.promotable.includes(target) ? ` · ${target} is promotable` : ` · ${target} is not counted as promotable`) : ''}
|
|
348
|
+
</div>
|
|
349
|
+
<div>
|
|
350
|
+
<span className="text-theme-text-tertiary">Disruption budgets: </span>
|
|
351
|
+
{pdb.text}
|
|
352
|
+
</div>
|
|
353
|
+
<div className="text-theme-text-tertiary">
|
|
354
|
+
The operator restarts the old primary itself rather than evicting it, so disruption budgets do not gate the switchover; the primary’s budget follows the new
|
|
355
|
+
primary for later node drains.
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function ClusterActionDialog({
|
|
362
|
+
kind,
|
|
363
|
+
caps,
|
|
364
|
+
namespace,
|
|
365
|
+
name,
|
|
366
|
+
onClose,
|
|
367
|
+
initialPod,
|
|
368
|
+
}: {
|
|
369
|
+
kind: CNPGClusterActionName
|
|
370
|
+
caps: CNPGClusterCapabilities
|
|
371
|
+
namespace: string
|
|
372
|
+
name: string
|
|
373
|
+
onClose: () => void
|
|
374
|
+
initialPod?: string
|
|
375
|
+
}) {
|
|
376
|
+
const navigate = useCNPGNavigate()
|
|
377
|
+
const facts = caps.facts
|
|
378
|
+
const cap = caps.actions[kind]
|
|
379
|
+
const mutation = useCNPGAction('clusters', namespace, name, caps.context)
|
|
380
|
+
const { showSuccess } = useToast()
|
|
381
|
+
const { fleet } = useCNPGFleet([namespace])
|
|
382
|
+
const runtime = useCNPGRuntime(namespace, name, kind === 'switchover')
|
|
383
|
+
const ha = useCNPGClusterHA(namespace, name, { enabled: kind === 'switchover' || kind === 'setMaintenance' || kind === 'unsetMaintenance', refetchInterval: false })
|
|
384
|
+
const [reusePVC, setReusePVC] = useState(() => facts.maintenance.reusePVC)
|
|
385
|
+
|
|
386
|
+
const backupMethods = facts.backupMethods.filter((m) => m.capability !== 'none')
|
|
387
|
+
const [method, setMethod] = useState<CNPGBackupMethod | undefined>(backupMethods[0])
|
|
388
|
+
const [target, setTarget] = useState<'' | 'primary' | 'prefer-standby'>('')
|
|
389
|
+
const [backupName, setBackupName] = useState(() => backupNameFor(name))
|
|
390
|
+
|
|
391
|
+
const standbys: StandbyChoice[] = useMemo(() => {
|
|
392
|
+
const lags = new Map<string, { replayLagSeconds?: number; replayBacklogBytes?: number; state?: string; syncState?: string }>()
|
|
393
|
+
const primary = runtime.data?.instances.find((i) => i.role === 'primary')
|
|
394
|
+
for (const r of primary?.status.replication ?? []) {
|
|
395
|
+
lags.set(r.applicationName, {
|
|
396
|
+
replayLagSeconds: r.replayLag,
|
|
397
|
+
replayBacklogBytes: lsnDistance(primary?.status.currentLsn, r.replayLsn),
|
|
398
|
+
state: r.state,
|
|
399
|
+
syncState: r.syncState,
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
return facts.instances
|
|
403
|
+
.filter((i) => i.pod !== facts.currentPrimary)
|
|
404
|
+
.map((i) => {
|
|
405
|
+
// A standby that isn't connected has no row; its own replayed
|
|
406
|
+
// position still says how far behind it is.
|
|
407
|
+
const own = standbyOwnBacklog(primary?.status, runtime.data?.instances.find((x) => x.pod === i.pod)?.status)
|
|
408
|
+
const lag = lags.get(i.pod) ?? (own !== undefined ? { replayBacklogBytes: own } : undefined)
|
|
409
|
+
const cap = caps.instanceActions?.[i.pod]?.switchoverTarget
|
|
410
|
+
const ineligible = i.fenced ? 'fenced' : !i.podExists ? 'Pod missing' : !i.ready ? 'not ready' : cap && !cap.allowed ? cap.reason ?? 'not eligible' : undefined
|
|
411
|
+
const concerns = switchoverConcerns(primary?.status, runtime.data?.instances.find((x) => x.pod === i.pod))
|
|
412
|
+
return { pod: i.pod, podUID: i.podUID, ineligible, ...lag, concerns }
|
|
413
|
+
})
|
|
414
|
+
}, [facts, runtime.data, caps.instanceActions])
|
|
415
|
+
const [switchTarget, setSwitchTarget] = useState<string | undefined>(() => initialPod ?? pickDefaultStandby(standbys)?.pod)
|
|
416
|
+
const [switchTouched, setSwitchTouched] = useState(false)
|
|
417
|
+
// Lag and backlog arrive with the runtime read, often after the dialog
|
|
418
|
+
// opened: re-pick the default until the user chooses for themselves.
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
const next = switchoverDefault({ touched: switchTouched || !!initialPod, current: switchTarget, standbys })
|
|
421
|
+
if (next !== switchTarget) setSwitchTarget(next)
|
|
422
|
+
}, [standbys, switchTouched, initialPod]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
423
|
+
const chosenStandby = standbys.find((s) => s.pod === switchTarget)
|
|
424
|
+
|
|
425
|
+
const fenced = facts.fencedInstances.all ? ['*'] : facts.fencedInstances.instances
|
|
426
|
+
const fencingMalformed = !!facts.fencedInstances.malformed
|
|
427
|
+
const allFenced = facts.fencedInstances.all
|
|
428
|
+
const [fenceSel, setFenceSel] = useState<string>(kind === 'fence' ? '' : initialPod ?? '*')
|
|
429
|
+
const instance = facts.instances.find((i) => i.pod === initialPod)
|
|
430
|
+
const instanceIsPrimary = !!initialPod && initialPod === facts.currentPrimary
|
|
431
|
+
|
|
432
|
+
const spec: DialogSpec = (() => {
|
|
433
|
+
switch (kind) {
|
|
434
|
+
case 'backup':
|
|
435
|
+
return {
|
|
436
|
+
title: `Back up ${name} now?`,
|
|
437
|
+
confirmLabel: 'Create Backup',
|
|
438
|
+
effect: (
|
|
439
|
+
<>
|
|
440
|
+
Creates Backup <span className="font-mono">{backupName}</span>. The operator runs it on an eligible instance; a backup that is already running
|
|
441
|
+
makes this one wait.
|
|
442
|
+
</>
|
|
443
|
+
),
|
|
444
|
+
body: (
|
|
445
|
+
<div className="grid grid-cols-[7rem_minmax(0,1fr)] items-center gap-x-3 gap-y-2">
|
|
446
|
+
<label className="text-xs text-theme-text-secondary" htmlFor="cnpg-backup-method">Method</label>
|
|
447
|
+
<select
|
|
448
|
+
id="cnpg-backup-method"
|
|
449
|
+
value={method ? `${method.method}:${method.pluginName ?? ''}` : ''}
|
|
450
|
+
onChange={(e) => setMethod(backupMethods.find((m) => `${m.method}:${m.pluginName ?? ''}` === e.target.value))}
|
|
451
|
+
className="rounded-lg border border-theme-border bg-theme-base px-2 py-1 text-sm"
|
|
452
|
+
>
|
|
453
|
+
{backupMethods.map((m) => (
|
|
454
|
+
<option key={`${m.method}:${m.pluginName ?? ''}`} value={`${m.method}:${m.pluginName ?? ''}`}>
|
|
455
|
+
{describeBackupMethod(m)}
|
|
456
|
+
</option>
|
|
457
|
+
))}
|
|
458
|
+
</select>
|
|
459
|
+
<label className="text-xs text-theme-text-secondary" htmlFor="cnpg-backup-target">Target</label>
|
|
460
|
+
<select id="cnpg-backup-target" value={target} onChange={(e) => setTarget(e.target.value as typeof target)} className="rounded-lg border border-theme-border bg-theme-base px-2 py-1 text-sm">
|
|
461
|
+
<option value="">{facts.backupTarget ? `Cluster default (${facts.backupTarget})` : 'Cluster default'}</option>
|
|
462
|
+
<option value="prefer-standby">Prefer a standby</option>
|
|
463
|
+
<option value="primary">Primary</option>
|
|
464
|
+
</select>
|
|
465
|
+
<label className="text-xs text-theme-text-secondary" htmlFor="cnpg-backup-name">Name</label>
|
|
466
|
+
<input id="cnpg-backup-name" value={backupName} onChange={(e) => setBackupName(e.target.value)} className="rounded-lg border border-theme-border bg-theme-base px-2 py-1 font-mono text-sm" />
|
|
467
|
+
</div>
|
|
468
|
+
),
|
|
469
|
+
// A volume snapshot can be recovered without archived WAL, so only
|
|
470
|
+
// the object-store methods are warned.
|
|
471
|
+
warnings:
|
|
472
|
+
facts.archivingFailing && method?.method === 'barmanObjectStore'
|
|
473
|
+
? ['WAL archiving is failing. The operator checks archiving before a Barman backup and marks this Backup walArchivingFailing instead of taking it: fix archiving first.']
|
|
474
|
+
: facts.archivingFailing && method?.method === 'plugin'
|
|
475
|
+
? ['WAL archiving is failing. A base backup in the object store can only be restored once the WAL written during it reaches the archive, so fix archiving first.']
|
|
476
|
+
: [],
|
|
477
|
+
notes: [
|
|
478
|
+
method?.capability === 'unknown' ? `The plugin ${method.pluginName} does not report whether it can take backups; the operator will reject the Backup if it cannot.` : null,
|
|
479
|
+
method?.method === 'barmanObjectStore' ? 'The in-tree Barman object store is deprecated in favour of the barman-cloud plugin.' : null,
|
|
480
|
+
'A Backup’s spec cannot be edited once it is created.',
|
|
481
|
+
].filter(Boolean) as string[],
|
|
482
|
+
writes: [
|
|
483
|
+
{
|
|
484
|
+
summary: `create Backup ${namespace}/${backupName}`,
|
|
485
|
+
detail: JSON.stringify(
|
|
486
|
+
{
|
|
487
|
+
spec: {
|
|
488
|
+
cluster: { name },
|
|
489
|
+
method: method?.method,
|
|
490
|
+
...(method?.pluginName ? { pluginConfiguration: { name: method.pluginName } } : {}),
|
|
491
|
+
...(target ? { target } : {}),
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
null,
|
|
495
|
+
2,
|
|
496
|
+
),
|
|
497
|
+
},
|
|
498
|
+
],
|
|
499
|
+
scope: { kind: 'create-child' },
|
|
500
|
+
params: { method: method?.method, pluginName: method?.pluginName, target: target || undefined, name: backupName },
|
|
501
|
+
invalid: !method ? 'This cluster declares no backup method.' : undefined,
|
|
502
|
+
incomplete: !/^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(backupName) ? 'The name must be a valid Kubernetes object name.' : undefined,
|
|
503
|
+
success: (r) => `Backup ${r.backup ?? backupName} requested. The operator does the rest.`,
|
|
504
|
+
}
|
|
505
|
+
case 'switchover':
|
|
506
|
+
return {
|
|
507
|
+
title: `Switch over ${name}?`,
|
|
508
|
+
confirmLabel: 'Switch over',
|
|
509
|
+
effect: (
|
|
510
|
+
<>
|
|
511
|
+
Promotes <span className="font-mono">{switchTarget ?? '—'}</span> to primary. The current primary <span className="font-mono">{facts.currentPrimary}</span> shuts
|
|
512
|
+
down and returns as a standby; applications lose their connection while the primary changes.
|
|
513
|
+
</>
|
|
514
|
+
),
|
|
515
|
+
body: (
|
|
516
|
+
<fieldset className="space-y-1.5">
|
|
517
|
+
<legend className="mb-1 text-xs text-theme-text-secondary">New primary</legend>
|
|
518
|
+
{standbys.length === 0 && <div className="text-sm text-theme-text-tertiary">No standby instances.</div>}
|
|
519
|
+
{standbys.map((s) => {
|
|
520
|
+
const behind = switchoverLagNote(s)
|
|
521
|
+
return (
|
|
522
|
+
<div key={s.pod}>
|
|
523
|
+
<label className="flex items-center gap-2 text-sm">
|
|
524
|
+
<input type="radio" name="cnpg-switch" value={s.pod} disabled={!!s.ineligible} checked={switchTarget === s.pod} onChange={() => {
|
|
525
|
+
setSwitchTouched(true)
|
|
526
|
+
setSwitchTarget(s.pod)
|
|
527
|
+
}}
|
|
528
|
+
/>
|
|
529
|
+
<span className="font-mono">{s.pod}</span>
|
|
530
|
+
<span className={clsx('text-xs', !s.ineligible && s.concerns?.length ? toneTextClass('degraded') : 'text-theme-text-tertiary')}>
|
|
531
|
+
{s.ineligible ??
|
|
532
|
+
(s.concerns?.length
|
|
533
|
+
? s.concerns.map((c) => c.text).join(' · ')
|
|
534
|
+
: [s.state, s.syncState, ...(s.replayLagSeconds !== undefined || s.replayBacklogBytes !== undefined ? switchoverCandidateFacts(s) : [runtime.isLoading ? 'lag loading…' : 'lag unknown'])]
|
|
535
|
+
.filter(Boolean)
|
|
536
|
+
.join(' · '))}
|
|
537
|
+
</span>
|
|
538
|
+
</label>
|
|
539
|
+
{behind && switchTarget === s.pod && <div className={clsx('ml-6 mt-0.5 text-xs', toneTextClass('degraded'))}>{behind}</div>}
|
|
540
|
+
</div>
|
|
541
|
+
)
|
|
542
|
+
})}
|
|
543
|
+
<SwitchoverContext ha={ha.data} haLoading={ha.isLoading} target={switchTarget} />
|
|
544
|
+
</fieldset>
|
|
545
|
+
),
|
|
546
|
+
warnings: [
|
|
547
|
+
ha.data?.quorum.enabled && ha.data.quorum.holds === false
|
|
548
|
+
? 'The failover quorum does not hold right now (R + W ≤ N): an automatic failover would wait. A switchover you request still proceeds.'
|
|
549
|
+
: null,
|
|
550
|
+
ha.data?.quorum.enabled && switchTarget && ha.data.quorum.status && ha.data.quorum.status.standbyNames.length > 0 && !ha.data.quorum.status.standbyNames.includes(switchTarget)
|
|
551
|
+
? `${switchTarget} is not among the recorded potentially synchronous standbys (${ha.data.quorum.status.standbyNames.join(', ')}).`
|
|
552
|
+
: null,
|
|
553
|
+
chosenStandby && chosenStandby.concerns?.length
|
|
554
|
+
? switchoverConcernWarning(chosenStandby.pod, chosenStandby.concerns)
|
|
555
|
+
: chosenStandby && chosenStandby.replayLagSeconds === undefined && chosenStandby.replayBacklogBytes === undefined
|
|
556
|
+
? runtime.data && runtime.data.permission.proxy !== 'denied'
|
|
557
|
+
? 'How far behind this standby is was not reported.'
|
|
558
|
+
: 'How far behind this standby is is unknown: live instance data is not readable.'
|
|
559
|
+
: null,
|
|
560
|
+
].filter(Boolean) as string[],
|
|
561
|
+
notes: ['The operator performs a controlled shutdown of the old primary; its duration depends on spec.switchoverDelay and open transactions.'],
|
|
562
|
+
writes: [
|
|
563
|
+
{
|
|
564
|
+
summary: `patch Cluster ${namespace}/${name} status (subresource)`,
|
|
565
|
+
detail: `status.targetPrimary = ${switchTarget}\nstatus.phase = "Switchover in progress"\nstatus.phaseReason = "Switching over to ${switchTarget}"`,
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
scope: { kind: 'status' },
|
|
569
|
+
typed: true,
|
|
570
|
+
disruptive: true,
|
|
571
|
+
params: { target: switchTarget, targetPodUID: chosenStandby?.podUID },
|
|
572
|
+
incomplete: !switchTarget || chosenStandby?.ineligible ? 'Pick an eligible standby.' : undefined,
|
|
573
|
+
success: () => `Switchover to ${switchTarget} requested.`,
|
|
574
|
+
}
|
|
575
|
+
case 'restart':
|
|
576
|
+
return {
|
|
577
|
+
title: `Restart ${name}?`,
|
|
578
|
+
confirmLabel: 'Restart instances',
|
|
579
|
+
effect: 'Restarts every instance with a rolling update: standbys first, then the primary.',
|
|
580
|
+
body: <CNPGRestartReview caps={caps} problems={fleet?.rows.find((r) => r.namespace === namespace && r.name === name)?.problems} onOpenOverview={() => { onClose(); navigate(cnpgDetailPath({ plural: 'clusters', namespace, name }, caps.context, 'overview')) }} />,
|
|
581
|
+
writes: [{ summary: `patch Cluster ${namespace}/${name}`, detail: 'metadata.annotations["kubectl.kubernetes.io/restartedAt"] = <now, RFC 3339>' }],
|
|
582
|
+
scope: { kind: 'metadata', paths: ['metadata.annotations["kubectl.kubernetes.io/restartedAt"]'] },
|
|
583
|
+
typed: true,
|
|
584
|
+
disruptive: true,
|
|
585
|
+
success: () => 'Restart requested. Instances restart one at a time.',
|
|
586
|
+
}
|
|
587
|
+
case 'reload':
|
|
588
|
+
return {
|
|
589
|
+
title: `Reload ${name}’s configuration?`,
|
|
590
|
+
confirmLabel: 'Reload',
|
|
591
|
+
effect: 'Asks every instance to reload its PostgreSQL configuration and certificates without restarting.',
|
|
592
|
+
notes: ['Nothing reports when a reload completes.'],
|
|
593
|
+
writes: [{ summary: `patch Cluster ${namespace}/${name}`, detail: 'metadata.annotations["cnpg.io/reloadedAt"] = <now>' }],
|
|
594
|
+
scope: { kind: 'metadata', paths: ['metadata.annotations["cnpg.io/reloadedAt"]'] },
|
|
595
|
+
success: () => 'Reload requested.',
|
|
596
|
+
}
|
|
597
|
+
case 'fence': {
|
|
598
|
+
const choices = [...facts.instances.map((i) => i.pod).filter((p) => !fenced.includes(p)), '*']
|
|
599
|
+
// The server refuses some instances on their own, such as the target of
|
|
600
|
+
// a switchover or failover in progress.
|
|
601
|
+
const refusal = (pod: string) => {
|
|
602
|
+
const cap = pod === '*' ? undefined : caps.instanceActions?.[pod]?.fence
|
|
603
|
+
return cap && !cap.allowed ? capabilityReason(cap) : undefined
|
|
604
|
+
}
|
|
605
|
+
const refused = caps.actions.fence.allowed ? choices.flatMap((pod) => { const reason = refusal(pod); return reason ? [{ pod, reason }] : [] }) : []
|
|
606
|
+
const selRefusal = fenceSel ? refusal(fenceSel) : undefined
|
|
607
|
+
const next = fenceSel === '*' ? ['*'] : [...fenced.filter((f) => f !== '*'), fenceSel]
|
|
608
|
+
const fencesPrimary = fenceSel === '*' || fenceSel === facts.currentPrimary
|
|
609
|
+
return {
|
|
610
|
+
title: !fenceSel ? 'Fence instances?' : fenceSel === '*' ? `Fence every instance of ${name}?` : `Fence ${fenceSel}?`,
|
|
611
|
+
confirmLabel: 'Fence',
|
|
612
|
+
effect: !fenceSel ? 'Choose which instance to stop.' : fencesPrimary
|
|
613
|
+
? 'Stops PostgreSQL on the fenced instances while keeping their Pods and volumes. Writes stop and no failover happens while the primary is fenced.'
|
|
614
|
+
: 'Stops PostgreSQL on this standby while keeping its Pod and volume for investigation.',
|
|
615
|
+
body: (
|
|
616
|
+
<div className="space-y-1.5">
|
|
617
|
+
<label className="flex items-center gap-2 text-sm">
|
|
618
|
+
<span className="text-xs text-theme-text-secondary">Instance</span>
|
|
619
|
+
<select value={fenceSel} onChange={(e) => setFenceSel(e.target.value)} className="rounded-lg border border-theme-border bg-theme-base px-2 py-1 text-sm">
|
|
620
|
+
<option value="" disabled>Choose an instance</option>
|
|
621
|
+
{choices.map((c) => (
|
|
622
|
+
<option key={c} value={c} disabled={!!refusal(c)}>
|
|
623
|
+
{c === '*' ? 'All instances — stops service' : c}{c === facts.currentPrimary ? ' (primary)' : ''}{refusal(c) ? ' — cannot be fenced' : ''}
|
|
624
|
+
</option>
|
|
625
|
+
))}
|
|
626
|
+
</select>
|
|
627
|
+
</label>
|
|
628
|
+
{refused.map((r) => (
|
|
629
|
+
<div key={r.pod} className="text-xs text-theme-text-secondary">
|
|
630
|
+
<span className="font-mono">{r.pod}</span> cannot be fenced: {r.reason}
|
|
631
|
+
</div>
|
|
632
|
+
))}
|
|
633
|
+
</div>
|
|
634
|
+
),
|
|
635
|
+
writes: fenceSel ? [{ summary: `patch Cluster ${namespace}/${name}`, detail: `metadata.annotations["cnpg.io/fencedInstances"] = ${JSON.stringify(JSON.stringify(next))}` }] : [],
|
|
636
|
+
scope: { kind: 'metadata', paths: ['metadata.annotations["cnpg.io/fencedInstances"]'] },
|
|
637
|
+
typed: fencesPrimary,
|
|
638
|
+
disruptive: fencesPrimary,
|
|
639
|
+
params: { instances: fenceSel === '*' ? '*' : [fenceSel] },
|
|
640
|
+
invalid: fencingMalformed ? 'The cnpg.io/fencedInstances annotation is not valid JSON; fix it in YAML first.' : selRefusal ? `${fenceSel} cannot be fenced: ${selRefusal}` : undefined,
|
|
641
|
+
incomplete: !fenceSel ? 'Choose an instance to fence.' : undefined,
|
|
642
|
+
success: () => 'Fencing requested.',
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
case 'unfence': {
|
|
646
|
+
const choices = allFenced ? ['*'] : ['*', ...fenced]
|
|
647
|
+
return {
|
|
648
|
+
title: `Lift fencing on ${name}?`,
|
|
649
|
+
confirmLabel: 'Lift fencing',
|
|
650
|
+
effect: 'Starts PostgreSQL again on the selected fenced instances. An instance fenced on purpose (for example during an investigation) rejoins the cluster.',
|
|
651
|
+
body: (
|
|
652
|
+
<label className="flex items-center gap-2 text-sm">
|
|
653
|
+
<span className="text-xs text-theme-text-secondary">Instance</span>
|
|
654
|
+
<select value={fenceSel} onChange={(e) => setFenceSel(e.target.value)} className="rounded-lg border border-theme-border bg-theme-base px-2 py-1 text-sm">
|
|
655
|
+
{choices.map((c) => <option key={c} value={c}>{c === '*' ? 'All fenced instances' : c}</option>)}
|
|
656
|
+
</select>
|
|
657
|
+
</label>
|
|
658
|
+
),
|
|
659
|
+
notes: allFenced ? ['The whole cluster is fenced ("*"); fencing can only be lifted for every instance at once here.'] : [],
|
|
660
|
+
writes: [
|
|
661
|
+
{
|
|
662
|
+
summary: `patch Cluster ${namespace}/${name}`,
|
|
663
|
+
detail: fenceSel === '*' ? 'metadata.annotations["cnpg.io/fencedInstances"] removed' : `metadata.annotations["cnpg.io/fencedInstances"] = ${JSON.stringify(JSON.stringify(fenced.filter((f) => f !== fenceSel)))}`,
|
|
664
|
+
},
|
|
665
|
+
],
|
|
666
|
+
scope: { kind: 'metadata', paths: ['metadata.annotations["cnpg.io/fencedInstances"]'] },
|
|
667
|
+
typed: true,
|
|
668
|
+
disruptive: true,
|
|
669
|
+
params: { instances: fenceSel === '*' ? '*' : [fenceSel] },
|
|
670
|
+
invalid: fencingMalformed ? 'The cnpg.io/fencedInstances annotation is not valid JSON; fix it in YAML first.' : fenced.length === 0 ? 'No instance is fenced.' : undefined,
|
|
671
|
+
success: () => 'Fencing lifted.',
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
case 'hibernate': {
|
|
675
|
+
const fx = caps.hibernateEffects
|
|
676
|
+
return {
|
|
677
|
+
title: `Hibernate ${name}?`,
|
|
678
|
+
confirmLabel: 'Hibernate',
|
|
679
|
+
effect: 'Shuts down every instance and deletes their Pods, primary first. Volumes are kept so the cluster can resume where it stopped.',
|
|
680
|
+
body: fx ? (
|
|
681
|
+
<ul className="list-disc space-y-1 pl-5 text-xs text-theme-text-secondary">
|
|
682
|
+
<EffectItem list={{ available: fx.volumes.available, reason: fx.volumes.reason, names: fx.volumes.items.map((v) => `${v.name} (${v.capacity ? `capacity ${v.capacity}` : 'capacity not reported'}${v.requested ? `; requested ${v.requested}` : ''})`) }} label="Kept PVCs" />
|
|
683
|
+
<EffectItem list={fx.poolers} label="Poolers that lose their backend" />
|
|
684
|
+
<EffectItem list={fx.unsuspendedScheduledBackups} label="Schedules still active (each run will fail until it resumes)" />
|
|
685
|
+
<EffectItem list={{ available: fx.databases.available && fx.publications.available && fx.subscriptions.available, names: [...fx.databases.names, ...fx.publications.names, ...fx.subscriptions.names] }} label="Declarations that stop reconciling" />
|
|
686
|
+
</ul>
|
|
687
|
+
) : undefined,
|
|
688
|
+
writes: [{ summary: `patch Cluster ${namespace}/${name}`, detail: 'metadata.annotations["cnpg.io/hibernation"] = "on"' }],
|
|
689
|
+
scope: { kind: 'metadata', paths: ['metadata.annotations["cnpg.io/hibernation"]'] },
|
|
690
|
+
typed: true,
|
|
691
|
+
disruptive: true,
|
|
692
|
+
success: () => 'Hibernation requested. Pods shut down in order.',
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
case 'rehydrate':
|
|
696
|
+
return {
|
|
697
|
+
title: `Resume ${name} from hibernation?`,
|
|
698
|
+
confirmLabel: 'Resume',
|
|
699
|
+
effect: 'Recreates the instance Pods on the kept volumes, primary first.',
|
|
700
|
+
writes: [{ summary: `patch Cluster ${namespace}/${name}`, detail: 'metadata.annotations["cnpg.io/hibernation"] = "off"' }],
|
|
701
|
+
scope: { kind: 'metadata', paths: ['metadata.annotations["cnpg.io/hibernation"]'] },
|
|
702
|
+
success: () => 'Resume requested.',
|
|
703
|
+
}
|
|
704
|
+
case 'setMaintenance':
|
|
705
|
+
case 'unsetMaintenance': {
|
|
706
|
+
const setting = kind === 'setMaintenance'
|
|
707
|
+
const single = facts.instances.length <= 1
|
|
708
|
+
return {
|
|
709
|
+
title: setting ? `Put ${name} in node maintenance?` : `Lift node maintenance on ${name}?`,
|
|
710
|
+
confirmLabel: setting ? 'Set maintenance' : 'Lift maintenance',
|
|
711
|
+
effect: setting ? (
|
|
712
|
+
<>
|
|
713
|
+
Tells the operator that node maintenance is under way. While it lasts, self-healing, rolling updates and the PodDisruptionBudgets are limited:
|
|
714
|
+
{reusePVC
|
|
715
|
+
? ' an instance on a drained node waits for that node to return and restarts on the same volume, and the disruption budget is removed so the drain can proceed.'
|
|
716
|
+
: ' an instance on a drained node is recreated on another node with a new volume cloned from the primary, and the old volume is deleted.'}{' '}
|
|
717
|
+
Keep the window as short as possible.
|
|
718
|
+
</>
|
|
719
|
+
) : (
|
|
720
|
+
'Tells the operator maintenance is over: self-healing, rolling updates and disruption budgets return to normal.'
|
|
721
|
+
),
|
|
722
|
+
body: (
|
|
723
|
+
<label className="flex items-start gap-2 text-sm">
|
|
724
|
+
<input type="checkbox" className="mt-0.5" checked={reusePVC} onChange={(e) => setReusePVC(e.target.checked)} />
|
|
725
|
+
<span>
|
|
726
|
+
Reuse volumes (<span className="font-mono">reusePVC</span>)
|
|
727
|
+
<span className="block text-xs text-theme-text-secondary">
|
|
728
|
+
{setting ? 'On: wait for the node and keep the data volume. Off: rebuild the instance elsewhere (slow for large databases).' : `Currently ${facts.maintenance.reusePVC ? 'on' : 'off'}; kubectl cnpg maintenance unset writes this value too.`}
|
|
729
|
+
</span>
|
|
730
|
+
</span>
|
|
731
|
+
</label>
|
|
732
|
+
),
|
|
733
|
+
notes: [
|
|
734
|
+
'CloudNativePG recommends managing drains with PodDisruptionBudgets (spec.enablePDB) instead; this mode is kept for local-storage setups.',
|
|
735
|
+
...(setting && single && !reusePVC ? ['With one instance and volume reuse off, the operator still refuses to drain its node: deleting the only instance would lose the data.'] : []),
|
|
736
|
+
],
|
|
737
|
+
warnings: setting && ha.data?.pdbs.state === 'ok' && ha.data.pdbs.items.length > 0 && reusePVC ? ['The Cluster’s PodDisruptionBudgets are removed while maintenance is in progress, so drains are no longer held back.'] : [],
|
|
738
|
+
writes: [{ summary: `patch Cluster ${namespace}/${name}`, detail: `spec.nodeMaintenanceWindow = {"inProgress": ${setting}, "reusePVC": ${reusePVC}}` }],
|
|
739
|
+
scope: { kind: 'spec', paths: ['spec.nodeMaintenanceWindow.inProgress', 'spec.nodeMaintenanceWindow.reusePVC'] },
|
|
740
|
+
typed: setting,
|
|
741
|
+
disruptive: setting,
|
|
742
|
+
params: { reusePVC },
|
|
743
|
+
success: () => (setting ? 'Maintenance mode set. Lift it as soon as the node work is done.' : 'Maintenance mode lifted.'),
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
case 'restartInstance':
|
|
747
|
+
if (!instance) return UNSUPPORTED
|
|
748
|
+
{
|
|
749
|
+
return {
|
|
750
|
+
title: `Restart ${instance.pod}?`,
|
|
751
|
+
confirmLabel: 'Restart instance',
|
|
752
|
+
effect: instanceIsPrimary
|
|
753
|
+
? 'Restarts PostgreSQL on the primary in place. Writes stop until it is back; no switchover happens.'
|
|
754
|
+
: 'Deletes this standby’s Pod; the operator recreates it on the same volume and it catches up from the primary.',
|
|
755
|
+
warnings: [
|
|
756
|
+
!instanceIsPrimary && facts.instances.filter((i) => i.pod !== facts.currentPrimary).length <= 1
|
|
757
|
+
? 'This is the only standby: while it restarts there is no failover target, and a synchronous quorum may block writes.'
|
|
758
|
+
: null,
|
|
759
|
+
].filter(Boolean) as string[],
|
|
760
|
+
writes: instanceIsPrimary
|
|
761
|
+
? [{ summary: `patch Cluster ${namespace}/${name} status (subresource)`, detail: 'status.phase = "Primary instance is being restarted in-place"\nstatus.phaseReason = "Requested by the user"' }]
|
|
762
|
+
: [{ summary: `delete Pod ${namespace}/${instance.pod}`, detail: `preconditions.uid = ${instance.podUID}` }],
|
|
763
|
+
scope: instanceIsPrimary ? { kind: 'status' } : { kind: 'delete-operator-owned', owner: `Cluster ${name}` },
|
|
764
|
+
typed: instanceIsPrimary,
|
|
765
|
+
disruptive: true,
|
|
766
|
+
params: { pod: instance.pod, podUID: instance.podUID },
|
|
767
|
+
success: () => `Restart of ${instance.pod} requested.`,
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
default:
|
|
771
|
+
return UNSUPPORTED
|
|
772
|
+
}
|
|
773
|
+
})()
|
|
774
|
+
|
|
775
|
+
const guard = useCNPGWriteGuard({ namespace, name, scope: spec.scope })
|
|
776
|
+
const operatorNote = cnpgOperatorActionNote(caps.operator)
|
|
777
|
+
|
|
778
|
+
const confirm = () => {
|
|
779
|
+
mutation.mutate(
|
|
780
|
+
{
|
|
781
|
+
action: kind,
|
|
782
|
+
request: { reviewedContext: caps.context, uid: caps.uid, facts: facts, params: spec.params },
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
onSuccess: (result) => {
|
|
786
|
+
showSuccess(spec.success(result))
|
|
787
|
+
const tracked = trackedOperationFor(kind, { result, facts, switchTarget, chosenPodUID: chosenStandby?.podUID, fenceSel, fenced, instance })
|
|
788
|
+
if (tracked) trackCNPGOperation({ ...tracked, context: caps.context, namespace, cluster: name, clusterUID: caps.uid })
|
|
789
|
+
onClose()
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return (
|
|
796
|
+
<ActionConfirmDialog
|
|
797
|
+
open
|
|
798
|
+
onClose={onClose}
|
|
799
|
+
onConfirm={confirm}
|
|
800
|
+
title={spec.title}
|
|
801
|
+
subject={{ kind: 'Cluster', namespace, name }}
|
|
802
|
+
context={caps.context}
|
|
803
|
+
effect={spec.effect}
|
|
804
|
+
notes={operatorNote?.tone === 'info' ? [...(spec.notes ?? []), operatorNote.text] : spec.notes}
|
|
805
|
+
warnings={operatorNote?.tone === 'warning' ? [operatorNote.text, ...(spec.warnings ?? [])] : spec.warnings}
|
|
806
|
+
guard={guard.node}
|
|
807
|
+
guardSatisfied={guard.satisfied}
|
|
808
|
+
writes={spec.writes}
|
|
809
|
+
typedConfirmation={spec.typed ? name : undefined}
|
|
810
|
+
confirmLabel={spec.confirmLabel}
|
|
811
|
+
disruptive={spec.disruptive}
|
|
812
|
+
disabledReason={!cap.allowed ? capabilityTitle(cap) : spec.invalid}
|
|
813
|
+
incompleteReason={spec.incomplete}
|
|
814
|
+
isLoading={mutation.isPending}
|
|
815
|
+
result={actionDialogResult(mutation.outcomeError ?? mutation.error, { unknown: "An earlier write to this Cluster may have taken effect" })}
|
|
816
|
+
>
|
|
817
|
+
{spec.body}
|
|
818
|
+
</ActionConfirmDialog>
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
export function CNPGRestartReview({ caps, problems = [], onOpenOverview }: { caps: CNPGClusterCapabilities; problems?: CNPGProblem[]; onOpenOverview?: () => void }) {
|
|
823
|
+
const instances = caps.facts.instances
|
|
824
|
+
const other = instances.filter((i) => i.pod !== caps.facts.currentPrimary)
|
|
825
|
+
const noOtherReady = other.every((i) => i.podReadable && !i.ready)
|
|
826
|
+
const primary = caps.restartPlan?.steps.find((s) => s.role === 'primary')
|
|
827
|
+
const waiting = instances.filter((i) => i.podReadable && (!i.podExists || !i.ready))
|
|
828
|
+
const primaryWillRestart = primary && ['restart', 'restart_only_instance', 'switchover'].includes(primary.effect)
|
|
829
|
+
return (
|
|
830
|
+
<div className="space-y-2 text-xs text-theme-text-secondary">
|
|
831
|
+
{waiting.length > 0 && <p className={toneTextClass('degraded')}>The rolling restart waits for every instance to be ready. Currently blocked by {waiting.map((i) => i.pod).join(', ')}.</p>}
|
|
832
|
+
{primaryWillRestart && <p>Restarting the primary interrupts its connections.{primary.effect === 'restart_only_instance' ? ' There is no other instance: the cluster stops serving until the primary is back.' : noOtherReady ? ' No other instance is ready now. If the primary restarts without another ready instance, the cluster stops serving until it is back.' : !other.some((i) => i.ready) ? ' Other instances’ readiness could not be read; service continuity is unknown.' : ''}</p>}
|
|
833
|
+
{primary?.effect === 'wait_for_user' && <p>The primary waits for your manual promotion or restart. Restarting it later interrupts its connections.</p>}
|
|
834
|
+
{primary?.effect === 'skipped_fenced' && <p>The fenced primary is skipped.</p>}
|
|
835
|
+
{caps.restartPlan && <>
|
|
836
|
+
{primary && ['switchover', 'restart'].includes(primary.effect) && <p>The operator updates the primary automatically after the standbys: {primary.effect === 'switchover' ? 'it promotes an updated standby, then recreates the former primary Pod.' : 'it restarts the primary in place.'}</p>}
|
|
837
|
+
<div>Planned order:</div>
|
|
838
|
+
<ol className="list-decimal space-y-1 pl-5">
|
|
839
|
+
{caps.restartPlan.steps.map((s) => {
|
|
840
|
+
const instance = instances.find((i) => i.pod === s.instance)
|
|
841
|
+
const blockers = problems.filter((p) => p.instance === s.instance || p.subject.kind === 'Pod' && p.subject.name === s.instance)
|
|
842
|
+
return <li key={s.instance}>
|
|
843
|
+
<span className="font-mono">{s.instance}</span> : {RESTART_EFFECT[s.effect] ?? s.effect}
|
|
844
|
+
<span> (currently {instance?.podReadable ? instance.podExists ? instance.ready ? 'ready' : 'not ready' : 'absent' : 'readiness not read'})</span>
|
|
845
|
+
{blockers.map((p) => <div key={p.id} className={toneTextClass('degraded')}>{p.title}{p.detail ? ` · ${p.detail}` : ''}{onOpenOverview && <button type="button" onClick={onOpenOverview} className="ml-1 text-accent-text hover:underline">See Overview’s problems →</button>}</div>)}
|
|
846
|
+
</li>
|
|
847
|
+
})}
|
|
848
|
+
</ol>
|
|
849
|
+
<FoldSection title="Operator update settings" summary="" attention={false}><div>primaryUpdateStrategy: {caps.restartPlan.primaryUpdateStrategy}</div><div>primaryUpdateMethod: {caps.restartPlan.primaryUpdateMethod}</div></FoldSection>
|
|
850
|
+
</>}
|
|
851
|
+
</div>
|
|
852
|
+
)
|
|
853
|
+
}
|