@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,79 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Pause, Play } from 'lucide-react'
|
|
3
|
+
import { ActionConfirmDialog, Tooltip } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { useCNPGAction } from '../../../api/cnpg'
|
|
5
|
+
import { actionDialogResult, capabilityReason } from '../../../api/actions'
|
|
6
|
+
import { useCNPGPoolerCapabilities, type CNPGPoolerCapabilities } from '../../../api/cnpg-sessions'
|
|
7
|
+
import { useToast } from '../../ui/Toast'
|
|
8
|
+
import { useCNPGWriteGuard } from './useCNPGWriteGuard'
|
|
9
|
+
|
|
10
|
+
const BUTTON =
|
|
11
|
+
'btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium disabled:cursor-not-allowed'
|
|
12
|
+
|
|
13
|
+
/** Pause or resume a Pooler's PgBouncers (spec.pgbouncer.paused). */
|
|
14
|
+
export function CNPGPoolerActions({ namespace, name }: { namespace: string; name: string }) {
|
|
15
|
+
const caps = useCNPGPoolerCapabilities(namespace, name)
|
|
16
|
+
const [open, setOpen] = useState<'pause' | 'resume' | null>(null)
|
|
17
|
+
const data = caps.data
|
|
18
|
+
if (!data) return null
|
|
19
|
+
const kind = data.facts.paused ? 'resume' : 'pause'
|
|
20
|
+
const cap = data.actions[kind]
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<Tooltip content={capabilityReason(cap)} position="bottom">
|
|
24
|
+
<button type="button" className={BUTTON} disabled={!cap.allowed} onClick={() => setOpen(kind)}>
|
|
25
|
+
{kind === 'pause' ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
|
|
26
|
+
{kind === 'pause' ? 'Pause' : 'Resume'}
|
|
27
|
+
</button>
|
|
28
|
+
</Tooltip>
|
|
29
|
+
{open && <PoolerDialog kind={open} caps={data} namespace={namespace} name={name} onClose={() => setOpen(null)} />}
|
|
30
|
+
</>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function PoolerDialog({ kind, caps, namespace, name, onClose }: { kind: 'pause' | 'resume'; caps: CNPGPoolerCapabilities; namespace: string; name: string; onClose: () => void }) {
|
|
35
|
+
const mutation = useCNPGAction('poolers', namespace, name, caps.context)
|
|
36
|
+
const { showSuccess } = useToast()
|
|
37
|
+
const guard = useCNPGWriteGuard({ namespace, name, scope: { kind: 'spec', paths: ['spec.pgbouncer.paused'] }, targetKind: 'Pooler' })
|
|
38
|
+
const pause = kind === 'pause'
|
|
39
|
+
const cap = caps.actions[kind]
|
|
40
|
+
const pods = caps.facts.deployment.readyReplicas
|
|
41
|
+
return (
|
|
42
|
+
<ActionConfirmDialog
|
|
43
|
+
open
|
|
44
|
+
onClose={onClose}
|
|
45
|
+
onConfirm={() =>
|
|
46
|
+
mutation.mutate(
|
|
47
|
+
{ action: kind, request: { reviewedContext: caps.context, uid: caps.uid, facts: { paused: caps.facts.paused, generation: caps.facts.generation } } },
|
|
48
|
+
{
|
|
49
|
+
onSuccess: (r) => {
|
|
50
|
+
showSuccess(r.message)
|
|
51
|
+
onClose()
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
title={pause ? `Pause ${name}?` : `Resume ${name}?`}
|
|
57
|
+
subject={{ kind: 'Pooler', namespace, name }}
|
|
58
|
+
context={caps.context}
|
|
59
|
+
effect={
|
|
60
|
+
pause
|
|
61
|
+
? `Every PgBouncer of this Pooler${pods !== undefined ? ` (${pods} ready)` : ''} waits for running queries to complete, disconnects from ${caps.facts.cluster}, and then holds new client queries until it is resumed. Clients stay connected but their queries wait.`
|
|
62
|
+
: `Every PgBouncer of this Pooler reconnects to ${caps.facts.cluster} and serves the client queries it was holding.`
|
|
63
|
+
}
|
|
64
|
+
notes={[
|
|
65
|
+
'The operator applies this with PgBouncer’s PAUSE / RESUME on each Pod. Radar shows each PgBouncer’s own state (SHOW STATE) once it has applied it, when you can exec into the Pods.',
|
|
66
|
+
...(pause ? ['Clients whose queries wait longer than their own timeouts see errors; a long-running transaction delays the pause.'] : []),
|
|
67
|
+
]}
|
|
68
|
+
writes={[{ summary: `patch Pooler ${namespace}/${name}`, detail: `spec.pgbouncer.paused = ${pause}` }]}
|
|
69
|
+
guard={guard.node}
|
|
70
|
+
guardSatisfied={guard.satisfied}
|
|
71
|
+
typedConfirmation={pause ? name : undefined}
|
|
72
|
+
confirmLabel={pause ? 'Pause' : 'Resume'}
|
|
73
|
+
disruptive={pause}
|
|
74
|
+
disabledReason={capabilityReason(cap)}
|
|
75
|
+
isLoading={mutation.isPending}
|
|
76
|
+
result={actionDialogResult(mutation.outcomeError ?? mutation.error, { unknown: "An earlier write to this Pooler may have taken effect" })}
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { expect, it, vi } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { CNPGReportDialog } from './CNPGReportDialog'
|
|
4
|
+
vi.mock('@skyhook-io/k8s-ui', async (original) => ({ ...await original<typeof import('@skyhook-io/k8s-ui')>(), ActionConfirmDialog: ({ notes, children }: any) => <div>{notes.map((note: string) => <p key={note}>{note}</p>)}{children}</div> }))
|
|
5
|
+
vi.mock('../../../api/client', () => ({ useRadarFeature: () => ({ guard: vi.fn() }) }))
|
|
6
|
+
vi.mock('../../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'test' } }) }))
|
|
7
|
+
it('separates excluded Secret contents from best-effort log redaction and review', () => {
|
|
8
|
+
const html = renderToStaticMarkup(<CNPGReportDialog namespace="db" name="orders" onClose={() => {}} />)
|
|
9
|
+
expect(html).toContain('Secret objects are never read. Inline connection passwords and initialization and import SQL are withheld.')
|
|
10
|
+
expect(html).toContain('Included logs are redacted for recognisable secrets on a best-effort basis; review them before sharing.')
|
|
11
|
+
expect(html).not.toContain('Secret values are never included')
|
|
12
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { ActionConfirmDialog } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { downloadCNPGReport } from '../../../api/cnpg-recovery'
|
|
4
|
+
import { useRadarFeature } from '../../../api/client'
|
|
5
|
+
import { useConnection } from '../../../context/ConnectionContext'
|
|
6
|
+
import { downloadBlob } from '../../resources/file-browser-utils'
|
|
7
|
+
|
|
8
|
+
const INCLUDED = [
|
|
9
|
+
'The Cluster, its instance and Job Pods, Jobs and PersistentVolumeClaims',
|
|
10
|
+
'Events about those objects',
|
|
11
|
+
'Backups, ScheduledBackups and Poolers of this cluster, and the ObjectStore it uses',
|
|
12
|
+
'Operator and plugin versions and readiness',
|
|
13
|
+
'The live instance and storage snapshots Radar shows (instance manager status, selected exporter metrics)',
|
|
14
|
+
'report.json: what was read, what was skipped and why, and the Secrets referenced (names only)',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The read-only report bundle, mirroring `kubectl cnpg report cluster`.
|
|
19
|
+
* Secret values are never included; logs and the query text inside them are
|
|
20
|
+
* opt-in.
|
|
21
|
+
*/
|
|
22
|
+
export function CNPGReportDialog({ namespace, name, onClose }: { namespace: string; name: string; onClose: () => void }) {
|
|
23
|
+
const { connection } = useConnection()
|
|
24
|
+
const [logs, setLogs] = useState(false)
|
|
25
|
+
const [queryText, setQueryText] = useState(false)
|
|
26
|
+
const [tailLines, setTailLines] = useState(1000)
|
|
27
|
+
const [busy, setBusy] = useState(false)
|
|
28
|
+
const { guard } = useRadarFeature('cnpgWorkspace')
|
|
29
|
+
const [error, setError] = useState<string | null>(null)
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<ActionConfirmDialog
|
|
33
|
+
open
|
|
34
|
+
onClose={onClose}
|
|
35
|
+
onConfirm={async () => {
|
|
36
|
+
setBusy(true)
|
|
37
|
+
setError(null)
|
|
38
|
+
try {
|
|
39
|
+
const { blob, filename } = await guard(() => downloadCNPGReport(namespace, name, { logs, queryText: logs && queryText, tailLines }))
|
|
40
|
+
await downloadBlob(blob, filename)
|
|
41
|
+
onClose()
|
|
42
|
+
} catch (e) {
|
|
43
|
+
if (!(e instanceof Error && e.message === 'cancelled')) setError(e instanceof Error ? e.message : String(e))
|
|
44
|
+
} finally {
|
|
45
|
+
setBusy(false)
|
|
46
|
+
}
|
|
47
|
+
}}
|
|
48
|
+
title="Download a report bundle"
|
|
49
|
+
subject={{ kind: 'Cluster', namespace, name }}
|
|
50
|
+
context={connection.context || undefined}
|
|
51
|
+
effect="Read-only: a zip of what this cluster looks like now, for a support ticket or a colleague. Everything is read with your permissions; what you cannot read is listed in report.json instead."
|
|
52
|
+
confirmLabel={busy ? 'Building…' : 'Download'}
|
|
53
|
+
isLoading={busy}
|
|
54
|
+
result={error ? { state: 'error', message: error, title: 'The report could not be downloaded' } : undefined}
|
|
55
|
+
notes={['Secret objects are never read. Inline connection passwords and initialization and import SQL are withheld. Included logs are redacted for recognisable secrets on a best-effort basis; review them before sharing.', 'The bundle is capped at 32 MiB; anything past that is listed as skipped.']}
|
|
56
|
+
>
|
|
57
|
+
<ul className="mb-3 list-disc space-y-0.5 pl-5 text-sm text-theme-text-secondary">
|
|
58
|
+
{INCLUDED.map((i) => <li key={i}>{i}</li>)}
|
|
59
|
+
</ul>
|
|
60
|
+
<div className="space-y-2 rounded-lg border border-theme-border bg-theme-base px-3 py-2">
|
|
61
|
+
<label className="flex items-start gap-2 text-sm text-theme-text-primary">
|
|
62
|
+
<input type="checkbox" className="mt-1" checked={logs} onChange={(e) => setLogs(e.target.checked)} />
|
|
63
|
+
<span>
|
|
64
|
+
Include Pod logs
|
|
65
|
+
<span className="block text-xs text-theme-text-tertiary">Every container of the instance and Job Pods, plus the previous run of restarted containers. Needs get pods/log.</span>
|
|
66
|
+
</span>
|
|
67
|
+
</label>
|
|
68
|
+
{logs && (
|
|
69
|
+
<div className="ml-6 space-y-2">
|
|
70
|
+
<label className="flex items-center gap-2 text-sm text-theme-text-secondary">
|
|
71
|
+
Last
|
|
72
|
+
<select value={tailLines} onChange={(e) => setTailLines(Number(e.target.value))} className="rounded-lg border border-theme-border bg-theme-base px-2 py-1 text-sm text-theme-text-primary">
|
|
73
|
+
{[200, 1000, 5000, 10000].map((n) => <option key={n} value={n}>{n.toLocaleString()}</option>)}
|
|
74
|
+
</select>
|
|
75
|
+
lines per container (at most 2 MiB each)
|
|
76
|
+
</label>
|
|
77
|
+
<label className="flex items-start gap-2 text-sm text-theme-text-primary">
|
|
78
|
+
<input type="checkbox" className="mt-1" checked={queryText} onChange={(e) => setQueryText(e.target.checked)} />
|
|
79
|
+
<span>
|
|
80
|
+
Keep query text
|
|
81
|
+
<span className="block text-xs text-theme-text-tertiary">
|
|
82
|
+
Without this, statements and bind parameters in PostgreSQL log records are replaced. Query text can contain personal or secret data.
|
|
83
|
+
</span>
|
|
84
|
+
</span>
|
|
85
|
+
</label>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</ActionConfirmDialog>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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 { CNPGScheduleActions } from './CNPGScheduleActions'
|
|
6
|
+
|
|
7
|
+
const mocks = vi.hoisted(() => ({ track: vi.fn(), mutate: vi.fn(), success: vi.fn() }))
|
|
8
|
+
vi.mock('../useCNPGNavigate', () => ({ useCNPGNavigate: () => vi.fn() }))
|
|
9
|
+
vi.mock('../../ui/Toast', () => ({ useToast: () => ({ showSuccess: mocks.success }) }))
|
|
10
|
+
vi.mock('./useCNPGWriteGuard', () => ({ useCNPGWriteGuard: () => ({ node: null, satisfied: true }) }))
|
|
11
|
+
vi.mock('../operations/store', () => ({ trackCNPGOperation: mocks.track }))
|
|
12
|
+
vi.mock('../../../api/cnpg', () => ({
|
|
13
|
+
useCNPGAction: () => ({ mutate: mocks.mutate, isPending: false }),
|
|
14
|
+
useCNPGScheduleCapabilities: () => ({ data: {
|
|
15
|
+
uid: 'schedule-uid', context: 'kind-test',
|
|
16
|
+
facts: { cluster: 'payments', clusterUID: 'source-uid', clusterGeneration: 7, generation: 3, clusterState: 'ok', suspended: false },
|
|
17
|
+
actions: { run: { allowed: true }, suspend: { allowed: true }, setSchedule: { allowed: true } },
|
|
18
|
+
} }),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
it('binds the manual run and its follow-through to the reviewed source Cluster', async () => {
|
|
22
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
23
|
+
const host = document.createElement('div')
|
|
24
|
+
document.body.append(host)
|
|
25
|
+
const root = createRoot(host)
|
|
26
|
+
mocks.mutate.mockImplementation((_input, callbacks) => callbacks.onSuccess({ backup: 'manual-backup' }))
|
|
27
|
+
try {
|
|
28
|
+
await act(async () => root.render(<CNPGScheduleActions namespace="prod" name="nightly" />))
|
|
29
|
+
await act(async () => [...host.querySelectorAll('button')].find((button) => button.textContent === 'Run now')!.click())
|
|
30
|
+
const confirm = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find((button) => button.textContent === 'Create Backup')!
|
|
31
|
+
expect(confirm).toBeDefined()
|
|
32
|
+
await act(async () => confirm.click())
|
|
33
|
+
expect(mocks.mutate.mock.calls[0][0].request.facts).toMatchObject({ clusterUID: 'source-uid', clusterGeneration: 7 })
|
|
34
|
+
expect(mocks.track).toHaveBeenCalledWith(expect.objectContaining({ kind: 'run', cluster: 'payments', clusterUID: 'source-uid', target: { name: 'manual-backup' } }))
|
|
35
|
+
} finally {
|
|
36
|
+
await act(async () => root.unmount())
|
|
37
|
+
host.remove()
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('binds the reviewed generation when suspending', async () => {
|
|
42
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
43
|
+
const host = document.createElement('div')
|
|
44
|
+
document.body.append(host)
|
|
45
|
+
const root = createRoot(host)
|
|
46
|
+
mocks.mutate.mockReset()
|
|
47
|
+
try {
|
|
48
|
+
await act(async () => root.render(<CNPGScheduleActions namespace="prod" name="nightly" />))
|
|
49
|
+
await act(async () => [...host.querySelectorAll('button')].find((button) => button.textContent === 'Suspend')!.click())
|
|
50
|
+
const confirm = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find((button) => button.textContent === 'Suspend')!
|
|
51
|
+
expect(confirm).toBeDefined()
|
|
52
|
+
await act(async () => confirm.click())
|
|
53
|
+
expect(mocks.mutate.mock.calls[0][0]).toMatchObject({ action: 'suspend', request: { uid: 'schedule-uid', facts: { generation: 3, suspended: false } } })
|
|
54
|
+
} finally {
|
|
55
|
+
await act(async () => root.unmount())
|
|
56
|
+
host.remove()
|
|
57
|
+
}
|
|
58
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { act } from 'react'
|
|
4
|
+
import { createRoot } from 'react-dom/client'
|
|
5
|
+
import { expect, it, vi } from 'vitest'
|
|
6
|
+
import { CNPGScheduleActions } from './CNPGScheduleActions'
|
|
7
|
+
const navigate = vi.hoisted(() => vi.fn())
|
|
8
|
+
const schedule = vi.hoisted(() => ({ cluster: 'payments', reason: 'Configure a backup destination on payments first', backupBlockedReason: 'Configure a backup destination on payments first', clusterState: 'ok', reasonCode: 'backup_destination' }))
|
|
9
|
+
vi.mock('../useCNPGNavigate', () => ({ useCNPGNavigate: () => navigate }))
|
|
10
|
+
vi.mock('../../../api/cnpg', () => ({ useCNPGScheduleCapabilities: () => ({ data: { actions: { run: { allowed: false, reason: schedule.reason, reasonCode: schedule.reasonCode }, suspend: { allowed: true }, setSchedule: { allowed: true } }, context: 'viewer', facts: { cluster: schedule.cluster, clusterState: schedule.clusterState, backupBlockedReason: schedule.backupBlockedReason, suspended: false } } }) }))
|
|
11
|
+
it('shows the server destination blocker beneath the disabled Run now control', () => {
|
|
12
|
+
const html = renderToStaticMarkup(<CNPGScheduleActions namespace="prod" name="payments-nightly" />)
|
|
13
|
+
expect(html).toMatch(/disabled=""[^>]*>Run now/)
|
|
14
|
+
expect(new DOMParser().parseFromString(html, 'text/html').body.textContent).toContain('Configure a backup destination on payments first')
|
|
15
|
+
expect(html).toContain('Suspend')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('keeps actions in one group and opens the target Cluster Backups tab', () => {
|
|
19
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
20
|
+
const host = document.createElement('div'); const root = createRoot(host)
|
|
21
|
+
act(() => root.render(<CNPGScheduleActions namespace="prod" name="payments-nightly" />))
|
|
22
|
+
const run = [...host.querySelectorAll('button')].find((b) => b.textContent === 'Run now')!
|
|
23
|
+
expect(run.parentElement!.parentElement!.textContent).toBe('Run nowSuspendEdit schedule')
|
|
24
|
+
const cluster = [...host.querySelectorAll('button')].find((b) => b.textContent === 'Cluster payments Backups →')!
|
|
25
|
+
expect(run.parentElement!.parentElement!.contains(cluster)).toBe(false)
|
|
26
|
+
act(() => cluster.click())
|
|
27
|
+
expect(navigate).toHaveBeenCalledWith('/cnpg/clusters/prod/payments?ctx=viewer&tab=backups')
|
|
28
|
+
act(() => root.unmount())
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it.each(['backup', 'a', 'on'])('links the Cluster named %s rather than an earlier word in the reason', (cluster) => {
|
|
32
|
+
schedule.cluster = cluster; schedule.reason = `Configure a backup destination on ${cluster} first`; schedule.backupBlockedReason = schedule.reason
|
|
33
|
+
const html = renderToStaticMarkup(<CNPGScheduleActions namespace="prod" name="nightly" />)
|
|
34
|
+
expect(html).toMatch(new RegExp(`<button[^>]*>Cluster ${cluster} Backups →</button>`))
|
|
35
|
+
schedule.cluster = 'payments'; schedule.reason = 'Configure a backup destination on payments first'; schedule.backupBlockedReason = schedule.reason
|
|
36
|
+
})
|
|
37
|
+
it('adds the Cluster Backups destination when the blocker does not name the Cluster', () => {
|
|
38
|
+
schedule.reason = 'Set a volumeSnapshot backup configuration on the Cluster'; schedule.backupBlockedReason = schedule.reason
|
|
39
|
+
const html = renderToStaticMarkup(<CNPGScheduleActions namespace="prod" name="nightly" />)
|
|
40
|
+
expect(html).toContain('Set a volumeSnapshot backup configuration on the Cluster')
|
|
41
|
+
expect(html).toContain('Cluster payments Backups →')
|
|
42
|
+
schedule.reason = 'Configure a backup destination on payments first'; schedule.backupBlockedReason = schedule.reason
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it.each(['Your account may not do this', 'The admission webhook rejects writes', 'The schedule is being deleted', 'The cluster of the schedule does not exist in this namespace'])('does not send an unrelated blocker to Backups: %s', (reason) => {
|
|
46
|
+
schedule.reason = reason
|
|
47
|
+
schedule.reasonCode = 'other'
|
|
48
|
+
if (reason.includes('does not exist')) schedule.clusterState = 'missing'
|
|
49
|
+
const html = renderToStaticMarkup(<CNPGScheduleActions namespace="prod" name="nightly" />)
|
|
50
|
+
expect(html).toContain(reason)
|
|
51
|
+
expect(html).not.toContain('Cluster payments Backups')
|
|
52
|
+
expect(html).not.toMatch(/<button[^>]*>payments</)
|
|
53
|
+
schedule.reasonCode = 'backup_destination'; schedule.clusterState = 'ok'; schedule.reason = 'Configure a backup destination on payments first'; schedule.backupBlockedReason = schedule.reason
|
|
54
|
+
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useCNPGNavigate } from '../useCNPGNavigate'
|
|
3
|
+
import { cnpgDetailPath } from '../routes'
|
|
4
|
+
import { ActionConfirmDialog, CNPGSchedulePreviewFacts, FactGrid, Tooltip, useDebouncedValue } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { useCNPGAction, useCNPGScheduleCapabilities, useCNPGSchedulePreview, type CNPGScheduleActionName } from '../../../api/cnpg'
|
|
6
|
+
import { actionDialogResult, capabilityReason } from '../../../api/actions'
|
|
7
|
+
import { useToast } from '../../ui/Toast'
|
|
8
|
+
import { useCNPGWriteGuard } from './useCNPGWriteGuard'
|
|
9
|
+
import { cnpgOperatorActionNote } from '../operatorStatus'
|
|
10
|
+
import { trackCNPGOperation } from '../operations/store'
|
|
11
|
+
import { CNPGScheduleInput } from '../protection/CNPGScheduleInput'
|
|
12
|
+
import { CNPGScheduleRepairButton } from '../protection/CNPGScheduleRepairButton'
|
|
13
|
+
|
|
14
|
+
const BUTTON =
|
|
15
|
+
'btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium disabled:cursor-not-allowed'
|
|
16
|
+
|
|
17
|
+
/** Suspend, resume, run a ScheduledBackup's settings once, or change its schedule. */
|
|
18
|
+
export function CNPGScheduleActions({ namespace, name }: { namespace: string; name: string }) {
|
|
19
|
+
const caps = useCNPGScheduleCapabilities(namespace, name)
|
|
20
|
+
const navigate = useCNPGNavigate()
|
|
21
|
+
const [open, setOpen] = useState<CNPGScheduleActionName | null>(null)
|
|
22
|
+
const data = caps.data
|
|
23
|
+
if (!data) return null
|
|
24
|
+
const btn = (id: CNPGScheduleActionName, label: string) => {
|
|
25
|
+
const cap = data.actions[id]
|
|
26
|
+
return (
|
|
27
|
+
<Tooltip key={id} content={capabilityReason(cap)} position="bottom">
|
|
28
|
+
<button type="button" className={BUTTON} disabled={!cap.allowed} onClick={() => setOpen(id)}>
|
|
29
|
+
{label}
|
|
30
|
+
</button>
|
|
31
|
+
</Tooltip>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-1.5">
|
|
36
|
+
<div className="flex flex-nowrap items-center gap-1.5">
|
|
37
|
+
{btn('run', 'Run now')}
|
|
38
|
+
{data.facts.suspended ? btn('resume', 'Resume') : btn('suspend', 'Suspend')}
|
|
39
|
+
{btn('setSchedule', 'Edit schedule')}
|
|
40
|
+
</div>
|
|
41
|
+
{!data.actions.run.allowed && <div className="text-xs text-theme-text-secondary">{(() => {
|
|
42
|
+
const reason = capabilityReason(data.actions.run) ?? ''
|
|
43
|
+
const cluster = data.facts.cluster
|
|
44
|
+
const destinationBlocked = data.actions.run.reasonCode === 'backup_destination'
|
|
45
|
+
return <>{reason}{cluster && destinationBlocked && <button type="button" className="ml-1 text-accent-text hover:underline" onClick={() => navigate(cnpgDetailPath({ plural: 'clusters', namespace, name: cluster }, data.context, 'backups'))}>Cluster {cluster} Backups →</button>}</>
|
|
46
|
+
})()}</div>}
|
|
47
|
+
{data.actions.run.reasonCode === 'backup_destination' && <CNPGScheduleRepairButton namespace={namespace} name={name} />}
|
|
48
|
+
{open === 'setSchedule' && <EditScheduleDialog namespace={namespace} name={name} onClose={() => setOpen(null)} />}
|
|
49
|
+
{open && open !== 'setSchedule' && <ScheduleDialog kind={open} namespace={namespace} name={name} onClose={() => setOpen(null)} />}
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function EditScheduleDialog({ namespace, name, onClose }: { namespace: string; name: string; onClose: () => void }) {
|
|
55
|
+
const caps = useCNPGScheduleCapabilities(namespace, name)
|
|
56
|
+
const mutation = useCNPGAction('scheduledbackups', namespace, name, caps.data?.context)
|
|
57
|
+
const { showSuccess } = useToast()
|
|
58
|
+
const data = caps.data!
|
|
59
|
+
const current = data.facts.schedule
|
|
60
|
+
const [draft, setDraft] = useState(current)
|
|
61
|
+
const debounced = useDebouncedValue(draft.trim(), 300)
|
|
62
|
+
const preview = useCNPGSchedulePreview(namespace, name, debounced, debounced !== '')
|
|
63
|
+
const guard = useCNPGWriteGuard({ namespace, name, scope: { kind: 'spec', paths: ['spec.schedule'] }, targetKind: 'ScheduledBackup' })
|
|
64
|
+
const operatorNote = cnpgOperatorActionNote(data.operator)
|
|
65
|
+
const next = draft.trim()
|
|
66
|
+
const settled = debounced === next && preview.data?.schedule === next && !preview.isFetching
|
|
67
|
+
const p = settled ? preview.data : undefined
|
|
68
|
+
const disabledReason = !data.actions.setSchedule.allowed ? data.actions.setSchedule.reason : preview.error ? 'The schedule could not be checked' : undefined
|
|
69
|
+
const incompleteReason =
|
|
70
|
+
next === ''
|
|
71
|
+
? 'Enter a schedule'
|
|
72
|
+
: next === current
|
|
73
|
+
? 'The schedule is unchanged'
|
|
74
|
+
: !settled
|
|
75
|
+
? 'Checking the schedule…'
|
|
76
|
+
: p && !p.valid
|
|
77
|
+
? 'The operator cannot run this schedule'
|
|
78
|
+
: undefined
|
|
79
|
+
const warnings = [...(operatorNote?.tone === 'warning' ? [operatorNote.text] : []), ...(p?.valid && p.runsImmediately ? [p.clock?.declared ? 'The declared operator clock indicates a due run: saving can create one backup right away.' : 'The UTC estimate indicates a due run. The operator clock is not verified, so saving may create one backup right away.'] : [])]
|
|
80
|
+
return (
|
|
81
|
+
<ActionConfirmDialog
|
|
82
|
+
open
|
|
83
|
+
onClose={onClose}
|
|
84
|
+
onConfirm={() =>
|
|
85
|
+
mutation.mutate(
|
|
86
|
+
{
|
|
87
|
+
action: 'setSchedule',
|
|
88
|
+
request: { reviewedContext: data.context, uid: data.uid, facts: data.facts, params: { schedule: next } },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
onSuccess: () => {
|
|
92
|
+
showSuccess(`${name} now runs on ${next}.`)
|
|
93
|
+
onClose()
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
title={`Edit ${name}'s schedule`}
|
|
99
|
+
subject={{ kind: 'ScheduledBackup', namespace, name }}
|
|
100
|
+
context={data.context}
|
|
101
|
+
effect={`Backups of ${data.facts.cluster} are taken on the new schedule. Method, target and other settings are unchanged.`}
|
|
102
|
+
notes={operatorNote?.tone === 'info' ? [operatorNote.text] : []}
|
|
103
|
+
warnings={warnings}
|
|
104
|
+
writes={[{ summary: `patch ScheduledBackup ${namespace}/${name}`, detail: `spec.schedule = "${next}" (was "${current}")` }]}
|
|
105
|
+
guard={guard.node}
|
|
106
|
+
guardSatisfied={guard.satisfied}
|
|
107
|
+
confirmLabel="Save schedule"
|
|
108
|
+
isLoading={mutation.isPending}
|
|
109
|
+
result={actionDialogResult(mutation.outcomeError ?? mutation.error, { unknown: "An earlier write to this ScheduledBackup may have taken effect" })}
|
|
110
|
+
disabledReason={disabledReason}
|
|
111
|
+
incompleteReason={incompleteReason}
|
|
112
|
+
>
|
|
113
|
+
<div className="space-y-2">
|
|
114
|
+
<CNPGScheduleInput value={draft} onChange={setDraft} />
|
|
115
|
+
{preview.error && next !== '' && (
|
|
116
|
+
<div className="text-xs text-theme-text-tertiary">The schedule could not be checked: {preview.error instanceof Error ? preview.error.message : 'unknown error'}</div>
|
|
117
|
+
)}
|
|
118
|
+
{preview.data && next !== '' && (
|
|
119
|
+
<div className={settled ? undefined : 'opacity-60'}>
|
|
120
|
+
<FactGrid>
|
|
121
|
+
<CNPGSchedulePreviewFacts preview={preview.data} />
|
|
122
|
+
</FactGrid>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
<div className="text-[11.5px] text-theme-text-tertiary">Checked by Radar with the parser CloudNativePG uses; the operator's admission webhook checks it again on save.</div>
|
|
126
|
+
</div>
|
|
127
|
+
</ActionConfirmDialog>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ScheduleDialog({ kind, namespace, name, onClose }: { kind: CNPGScheduleActionName; namespace: string; name: string; onClose: () => void }) {
|
|
132
|
+
const caps = useCNPGScheduleCapabilities(namespace, name)
|
|
133
|
+
const mutation = useCNPGAction('scheduledbackups', namespace, name, caps.data?.context)
|
|
134
|
+
const { showSuccess } = useToast()
|
|
135
|
+
const data = caps.data!
|
|
136
|
+
const overdue = !!data.facts.nextScheduleTime && Date.parse(data.facts.nextScheduleTime) < Date.now()
|
|
137
|
+
const spec =
|
|
138
|
+
kind === 'run'
|
|
139
|
+
? {
|
|
140
|
+
title: `Create a backup using ${name}'s settings?`,
|
|
141
|
+
confirm: 'Create Backup',
|
|
142
|
+
effect: `Creates one Backup of ${data.facts.cluster} with this schedule's method, plugin, online and target settings. The schedule itself is not changed and its next run still happens on time.`,
|
|
143
|
+
writes: [{ summary: `create Backup ${namespace}/${name}-manual-<timestamp>`, detail: 'spec copied from the ScheduledBackup (method, pluginConfiguration, online, onlineConfiguration, target)' }],
|
|
144
|
+
notes: ['This Backup is not owned by the schedule, so it outlives it.'],
|
|
145
|
+
scope: { kind: 'create-child' as const },
|
|
146
|
+
}
|
|
147
|
+
: kind === 'suspend'
|
|
148
|
+
? {
|
|
149
|
+
title: `Suspend ${name}?`,
|
|
150
|
+
confirm: 'Suspend',
|
|
151
|
+
effect: `No new backups of ${data.facts.cluster} are taken on this schedule until it is resumed.`,
|
|
152
|
+
writes: [{ summary: `patch ScheduledBackup ${namespace}/${name}`, detail: 'spec.suspend = true' }],
|
|
153
|
+
notes: [] as string[],
|
|
154
|
+
scope: { kind: 'spec' as const, paths: ['spec.suspend'] },
|
|
155
|
+
}
|
|
156
|
+
: {
|
|
157
|
+
title: `Resume ${name}?`,
|
|
158
|
+
confirm: 'Resume',
|
|
159
|
+
effect: `Backups of ${data.facts.cluster} are taken on this schedule again.`,
|
|
160
|
+
writes: [{ summary: `patch ScheduledBackup ${namespace}/${name}`, detail: 'spec.suspend = false' }],
|
|
161
|
+
notes: overdue ? ['The next run is overdue, so the operator takes one catch-up backup right away. Missed runs are not replayed.'] : [],
|
|
162
|
+
scope: { kind: 'spec' as const, paths: ['spec.suspend'] },
|
|
163
|
+
}
|
|
164
|
+
const guard = useCNPGWriteGuard({ namespace, name, scope: spec.scope, targetKind: 'ScheduledBackup' })
|
|
165
|
+
const operatorNote = cnpgOperatorActionNote(data.operator)
|
|
166
|
+
return (
|
|
167
|
+
<ActionConfirmDialog
|
|
168
|
+
open
|
|
169
|
+
onClose={onClose}
|
|
170
|
+
onConfirm={() =>
|
|
171
|
+
mutation.mutate(
|
|
172
|
+
{ action: kind, request: { reviewedContext: data.context, uid: data.uid, facts: data.facts } },
|
|
173
|
+
{
|
|
174
|
+
onSuccess: (r) => {
|
|
175
|
+
if (kind === 'run' && r.backup) {
|
|
176
|
+
trackCNPGOperation({
|
|
177
|
+
kind: 'run',
|
|
178
|
+
label: `Backup ${r.backup} (from ${name})`,
|
|
179
|
+
context: data.context,
|
|
180
|
+
namespace,
|
|
181
|
+
cluster: data.facts.cluster,
|
|
182
|
+
clusterUID: data.facts.clusterUID,
|
|
183
|
+
target: { name: r.backup },
|
|
184
|
+
link: { kind: 'Backup', group: 'postgresql.cnpg.io', name: r.backup },
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
showSuccess(kind === 'run' ? `Backup ${r.backup ?? ''} requested.` : kind === 'suspend' ? `${name} suspended.` : `${name} resumed.`)
|
|
188
|
+
onClose()
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
title={spec.title}
|
|
194
|
+
subject={{ kind: 'ScheduledBackup', namespace, name }}
|
|
195
|
+
context={data.context}
|
|
196
|
+
effect={spec.effect}
|
|
197
|
+
notes={operatorNote?.tone === 'info' ? [...spec.notes, operatorNote.text] : spec.notes}
|
|
198
|
+
warnings={operatorNote?.tone === 'warning' ? [operatorNote.text] : undefined}
|
|
199
|
+
writes={spec.writes}
|
|
200
|
+
guard={guard.node}
|
|
201
|
+
guardSatisfied={guard.satisfied}
|
|
202
|
+
confirmLabel={spec.confirm}
|
|
203
|
+
isLoading={mutation.isPending}
|
|
204
|
+
result={actionDialogResult(mutation.outcomeError ?? mutation.error, { unknown: "An earlier write to this ScheduledBackup may have taken effect" })}
|
|
205
|
+
disabledReason={data.actions[kind].allowed ? undefined : data.actions[kind].reason}
|
|
206
|
+
/>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { backupNameFor, cnpgDestroyBlocker, pickDefaultStandby, switchoverCandidateFacts, switchoverConcernWarning, switchoverConcerns, switchoverDefault, switchoverLagNote } from './actionModel'
|
|
3
|
+
|
|
4
|
+
describe('CNPG action model', () => {
|
|
5
|
+
it('names backups like kubectl-cnpg, in UTC', () => {
|
|
6
|
+
expect(backupNameFor('pg-orders', new Date(Date.UTC(2026, 8, 28, 14, 5, 9)))).toBe('pg-orders-20260928140509')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('prefers a synchronous standby, then the least lag, and never an ineligible one', () => {
|
|
10
|
+
expect(
|
|
11
|
+
pickDefaultStandby([
|
|
12
|
+
{ pod: 'a', podUID: '1', replayLagSeconds: 0.1, syncState: 'async' },
|
|
13
|
+
{ pod: 'b', podUID: '2', replayLagSeconds: 3, syncState: 'sync' },
|
|
14
|
+
{ pod: 'c', podUID: '3', ineligible: 'fenced', syncState: 'sync' },
|
|
15
|
+
])?.pod,
|
|
16
|
+
).toBe('b')
|
|
17
|
+
expect(pickDefaultStandby([{ pod: 'a', podUID: '1', ineligible: 'not ready' }])).toBeUndefined()
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('cnpgDestroyBlocker', () => {
|
|
22
|
+
const fence = { allowed: false, permission: 'allowed' as const, reasonCode: 'fence_required', reason: 'Fence pg-2 first: a fenced instance cannot be promoted while it is destroyed' }
|
|
23
|
+
it('leaves the missing fence to the Fence-first callout', () => {
|
|
24
|
+
expect(cnpgDestroyBlocker(fence, true)).toBeUndefined()
|
|
25
|
+
expect(cnpgDestroyBlocker(fence, false)).toBe(fence.reason)
|
|
26
|
+
expect(cnpgDestroyBlocker({ ...fence, reason: 'A different explanation' }, true)).toBeUndefined()
|
|
27
|
+
})
|
|
28
|
+
it('keeps real blockers as the alert', () => {
|
|
29
|
+
expect(cnpgDestroyBlocker({ allowed: false, permission: 'denied', reason: 'Needs delete pods in db', grant: { verb: 'delete', resource: 'pods', namespace: 'db' } }, true)).toBe('Needs delete pods in db')
|
|
30
|
+
expect(cnpgDestroyBlocker({ allowed: false, permission: 'allowed', reason: 'The cluster is hibernated' }, true)).toBe('The cluster is hibernated')
|
|
31
|
+
expect(cnpgDestroyBlocker({ allowed: true, permission: 'allowed' }, true)).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('switchoverLagNote', () => {
|
|
36
|
+
it('warns from the WAL still to replay, never from replay delay alone', () => {
|
|
37
|
+
expect(switchoverLagNote({ pod: 'pg-2', replayBacklogBytes: 16 * 1024 * 1024, state: 'streaming' })).toBe('pg-2 has 16 MiB of WAL still to replay; the switchover may take longer while it catches up.')
|
|
38
|
+
expect(switchoverLagNote({ pod: 'pg-2', replayBacklogBytes: 16 * 1024 * 1024 })).toBe("pg-2 has 16 MiB of WAL still to replay and isn't connected to the primary to receive it.")
|
|
39
|
+
expect(switchoverLagNote({ pod: 'pg-2', replayBacklogBytes: 0 })).toBeUndefined()
|
|
40
|
+
expect(switchoverLagNote({ pod: 'pg-2' })).toBeUndefined()
|
|
41
|
+
})
|
|
42
|
+
it('lists the backlog before the replay delay on the candidate line', () => {
|
|
43
|
+
expect(switchoverCandidateFacts({ replayBacklogBytes: 0, replayLagSeconds: 12 })).toEqual(['backlog 0 B', 'replay delay 12 s'])
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('pickDefaultStandby by backlog', () => {
|
|
48
|
+
it('prefers the standby with less WAL to replay over a lower replay delay', () => {
|
|
49
|
+
const a = { pod: 'a', podUID: '1', replayBacklogBytes: 0, replayLagSeconds: 12 }
|
|
50
|
+
const b = { pod: 'b', podUID: '2', replayBacklogBytes: 4096, replayLagSeconds: 1 }
|
|
51
|
+
expect(pickDefaultStandby([b, a])?.pod).toBe('a')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('switchoverDefault', () => {
|
|
56
|
+
const before = [
|
|
57
|
+
{ pod: 'a', podUID: '1' },
|
|
58
|
+
{ pod: 'b', podUID: '2' },
|
|
59
|
+
]
|
|
60
|
+
const after = [
|
|
61
|
+
{ pod: 'a', podUID: '1', replayBacklogBytes: 4096 },
|
|
62
|
+
{ pod: 'b', podUID: '2', replayBacklogBytes: 0 },
|
|
63
|
+
]
|
|
64
|
+
it('re-picks the default when runtime data arrives, until the user chooses', () => {
|
|
65
|
+
expect(switchoverDefault({ touched: false, current: 'a', standbys: before })).toBe('a')
|
|
66
|
+
expect(switchoverDefault({ touched: false, current: 'a', standbys: after })).toBe('b')
|
|
67
|
+
expect(switchoverDefault({ touched: true, current: 'a', standbys: after })).toBe('a')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('switchover concerns', () => {
|
|
72
|
+
const primary = { state: 'ok', timeline: 7, replication: [{ applicationName: 'pg-2' }] }
|
|
73
|
+
it('names a standby that is not connected, paused, or on another timeline', () => {
|
|
74
|
+
expect(switchoverConcerns(primary, { pod: 'pg-2', status: { state: 'ok', timeline: 7 } })).toEqual([])
|
|
75
|
+
expect(switchoverConcerns(primary, { pod: 'pg-1', status: { state: 'ok', timeline: 4, replayPaused: true } })).toEqual([
|
|
76
|
+
{ code: 'not_receiving', text: 'not connected to the primary' },
|
|
77
|
+
{ code: 'replay_paused', text: 'replay paused' },
|
|
78
|
+
{ code: 'timeline_mismatch', text: 'timeline 4, primary on 7' },
|
|
79
|
+
])
|
|
80
|
+
// Without the primary's replication rows, connection is not judged.
|
|
81
|
+
expect(switchoverConcerns({ state: 'partial', timeline: 7, replication: null }, { pod: 'pg-1', status: { state: 'ok', timeline: 7 } })).toEqual([])
|
|
82
|
+
})
|
|
83
|
+
it('trusts a standby streaming from the primary over the timeline of its last checkpoint', () => {
|
|
84
|
+
// After a switchover the old primary rejoins and streams, but reports the
|
|
85
|
+
// previous timeline until its next restartpoint.
|
|
86
|
+
const promoted = { state: 'ok', timeline: 2, replication: [{ applicationName: 'pg-1', state: 'streaming' }] }
|
|
87
|
+
expect(switchoverConcerns(promoted, { pod: 'pg-1', status: { state: 'ok', timeline: 1, roleDetail: 'streaming' } })).toEqual([])
|
|
88
|
+
expect(pickDefaultStandby([{ pod: 'pg-1', podUID: '1', concerns: switchoverConcerns(promoted, { pod: 'pg-1', status: { state: 'ok', timeline: 1 } }) }])?.pod).toBe('pg-1')
|
|
89
|
+
})
|
|
90
|
+
it('keeps the timeline concern for a standby that is not streaming', () => {
|
|
91
|
+
const catchingUp = { state: 'ok', timeline: 2, replication: [{ applicationName: 'pg-1', state: 'catchup' }] }
|
|
92
|
+
expect(switchoverConcerns(catchingUp, { pod: 'pg-1', status: { state: 'ok', timeline: 1 } })).toEqual([{ code: 'timeline_mismatch', text: 'timeline 1, primary on 2' }])
|
|
93
|
+
expect(switchoverConcerns({ state: 'ok', timeline: 2, replication: [] }, { pod: 'pg-1', status: { state: 'ok', timeline: 1 } }).map((c) => c.code)).toEqual(['not_receiving', 'timeline_mismatch'])
|
|
94
|
+
})
|
|
95
|
+
it('never picks a standby with concerns by default while a clean one exists', () => {
|
|
96
|
+
expect(
|
|
97
|
+
pickDefaultStandby([
|
|
98
|
+
{ pod: 'pg-1', podUID: '1', concerns: [{ code: 'not_receiving', text: 'not connected to the primary' }] },
|
|
99
|
+
{ pod: 'pg-2', podUID: '2', replayBacklogBytes: 1024, syncState: 'async' },
|
|
100
|
+
])?.pod,
|
|
101
|
+
).toBe('pg-2')
|
|
102
|
+
expect(pickDefaultStandby([{ pod: 'pg-1', podUID: '1', concerns: [{ code: 'replay_paused', text: 'replay paused' }] }])).toBeUndefined()
|
|
103
|
+
})
|
|
104
|
+
it('warns with what was observed, not with a guess at the outcome', () => {
|
|
105
|
+
const w = switchoverConcernWarning('pg-1', [{ code: 'not_receiving', text: 'not connected to the primary' }, { code: 'replay_paused', text: 'replay paused' }])!
|
|
106
|
+
expect(w).toContain('pg-1: not connected to the primary · replay paused.')
|
|
107
|
+
expect(w).toContain('not receiving WAL from the current primary')
|
|
108
|
+
expect(switchoverConcernWarning('pg-1', [{ code: 'not_receiving', text: 'Connection unavailable' }])).toContain('not receiving WAL from the current primary')
|
|
109
|
+
expect(switchoverConcernWarning('pg-1', [])).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
})
|