@skyhook-io/radar-app 1.17.0 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/App.tsx +125 -87
- package/src/api/actions.errorCode.test.ts +40 -0
- package/src/api/actions.test.ts +48 -0
- package/src/api/actions.ts +79 -0
- package/src/api/apiResources.navigation.test.tsx +232 -0
- package/src/api/apiResources.ts +46 -3
- package/src/api/client.helmSource.test.ts +39 -0
- package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
- package/src/api/client.namespaceSwitch.test.ts +107 -0
- package/src/api/client.ts +266 -39
- package/src/api/cnpg-ha.live.test.ts +140 -0
- package/src/api/cnpg-ha.test.ts +78 -0
- package/src/api/cnpg-ha.ts +19 -0
- package/src/api/cnpg-history.ts +123 -0
- package/src/api/cnpg-inspect.ts +102 -0
- package/src/api/cnpg-protection.ts +112 -0
- package/src/api/cnpg-recovery.test.tsx +60 -0
- package/src/api/cnpg-recovery.ts +262 -0
- package/src/api/cnpg-sessions.ts +175 -0
- package/src/api/cnpg-storage.ts +138 -0
- package/src/api/cnpg.legacy-reads.test.ts +43 -0
- package/src/api/cnpg.pooler-runtime.test.ts +15 -0
- package/src/api/cnpg.ts +428 -10
- package/src/api/radarFeatures.render.test.tsx +47 -3
- package/src/api/radarFeatures.ts +4 -1
- package/src/api/reviewedActionMutation.test.tsx +164 -0
- package/src/api/reviewedActionMutation.ts +66 -0
- package/src/api/timelineSource.test.ts +9 -0
- package/src/api/timelineSource.ts +8 -0
- package/src/components/audit/AuditView.tsx +9 -0
- package/src/components/capacity/CapacityActivity.tsx +3 -31
- package/src/components/capacity/CapacityDemand.tsx +4 -36
- package/src/components/capacity/CapacityOverview.tsx +217 -56
- package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
- package/src/components/capacity/CapacityView.test.tsx +323 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
- package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
- package/src/components/capacity/routes.ts +42 -0
- package/src/components/capacity/shared.tsx +11 -129
- package/src/components/capacity/workspaceRoute.ts +18 -0
- package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
- package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
- package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
- package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
- package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
- package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
- package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
- package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
- package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
- package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
- package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
- package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
- package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
- package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
- package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
- package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
- package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
- package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
- package/src/components/cnpg/CNPGOperator.tsx +78 -30
- package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
- package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
- package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
- package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
- package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
- package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
- package/src/components/cnpg/CNPGOverview.tsx +238 -63
- package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
- package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
- package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
- package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
- package/src/components/cnpg/CNPGPerformance.tsx +477 -0
- package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
- package/src/components/cnpg/CNPGPooling.tsx +97 -40
- package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
- package/src/components/cnpg/CNPGProtection.tsx +228 -122
- package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
- package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
- package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
- package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
- package/src/components/cnpg/CNPGStorage.tsx +619 -0
- package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
- package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
- package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
- package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
- package/src/components/cnpg/CNPGTrends.test.ts +38 -0
- package/src/components/cnpg/CNPGTrends.tsx +702 -0
- package/src/components/cnpg/CNPGView.tsx +34 -21
- package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
- package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
- package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
- package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
- package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
- package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
- package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
- package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
- package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
- package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
- package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
- package/src/components/cnpg/actions/actionModel.test.ts +111 -0
- package/src/components/cnpg/actions/actionModel.ts +127 -0
- package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
- package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
- package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
- package/src/components/cnpg/archivingRepair.test.ts +104 -0
- package/src/components/cnpg/archivingRepair.ts +145 -0
- package/src/components/cnpg/baseBackup.test.ts +36 -0
- package/src/components/cnpg/baseBackup.ts +36 -0
- package/src/components/cnpg/blocking.test.ts +59 -0
- package/src/components/cnpg/blocking.ts +131 -0
- package/src/components/cnpg/coverageText.test.ts +27 -0
- package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
- package/src/components/cnpg/creation/targetModel.test.ts +137 -0
- package/src/components/cnpg/creation/targetModel.ts +149 -0
- package/src/components/cnpg/fleetStatus.test.ts +46 -0
- package/src/components/cnpg/fleetStatus.ts +43 -0
- package/src/components/cnpg/formFields.ts +2 -0
- package/src/components/cnpg/grantText.test.tsx +11 -0
- package/src/components/cnpg/host.test.tsx +24 -0
- package/src/components/cnpg/host.tsx +101 -0
- package/src/components/cnpg/inspectModel.test.ts +120 -0
- package/src/components/cnpg/inspectModel.ts +140 -0
- package/src/components/cnpg/logicalSlots.test.ts +30 -0
- package/src/components/cnpg/logicalSlots.ts +29 -0
- package/src/components/cnpg/lsn.test.ts +40 -0
- package/src/components/cnpg/lsn.ts +43 -0
- package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
- package/src/components/cnpg/operations/model.test.ts +510 -0
- package/src/components/cnpg/operations/model.ts +499 -0
- package/src/components/cnpg/operations/presentation.test.ts +80 -0
- package/src/components/cnpg/operations/presentation.ts +84 -0
- package/src/components/cnpg/operations/store.ts +98 -0
- package/src/components/cnpg/operatorStatus.test.ts +134 -0
- package/src/components/cnpg/operatorStatus.ts +194 -0
- package/src/components/cnpg/paths.test.ts +54 -0
- package/src/components/cnpg/paths.ts +47 -4
- package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
- package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
- package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
- package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
- package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
- package/src/components/cnpg/protection/scheduleModel.ts +16 -0
- package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
- package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
- package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
- package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
- package/src/components/cnpg/recovery/restoreModel.ts +685 -0
- package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
- package/src/components/cnpg/refresh.test.ts +38 -0
- package/src/components/cnpg/refresh.ts +17 -0
- package/src/components/cnpg/refreshNotice.test.tsx +29 -0
- package/src/components/cnpg/routes.test.ts +34 -3
- package/src/components/cnpg/routes.ts +60 -48
- package/src/components/cnpg/runtimeAssessment.ts +198 -0
- package/src/components/cnpg/runtimeModel.test.ts +175 -0
- package/src/components/cnpg/runtimeModel.ts +196 -0
- package/src/components/cnpg/runtimeParts.test.tsx +17 -0
- package/src/components/cnpg/runtimeParts.tsx +79 -0
- package/src/components/cnpg/shared.test.tsx +59 -0
- package/src/components/cnpg/shared.tsx +100 -182
- package/src/components/cnpg/storageModel.test.ts +107 -0
- package/src/components/cnpg/storageModel.ts +101 -0
- package/src/components/cnpg/trendSamples.test.ts +148 -0
- package/src/components/cnpg/trendSamples.ts +222 -0
- package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
- package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
- package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
- package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
- package/src/components/cnpg/useCNPGNavigate.ts +21 -0
- package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
- package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
- package/src/components/cnpg/workspaceRoute.ts +23 -0
- package/src/components/cost/ApplicationCostTab.test.ts +7 -0
- package/src/components/cost/ApplicationCostTab.tsx +7 -7
- package/src/components/cost/CostView.test.ts +2 -1
- package/src/components/cost/CostView.tsx +0 -2
- package/src/components/cost/WorkloadCostTab.tsx +2 -4
- package/src/components/cost/errors.ts +3 -1
- package/src/components/cost/source.test.ts +10 -0
- package/src/components/cost/source.ts +5 -1
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
- package/src/components/dock/BottomDock.tsx +2 -0
- package/src/components/dock/TerminalTab.test.tsx +17 -0
- package/src/components/dock/TerminalTab.tsx +7 -3
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
- package/src/components/helm/HelmCompareRoute.tsx +57 -26
- package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
- package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
- package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
- package/src/components/helm/ValuesViewer.test.tsx +301 -0
- package/src/components/helm/ValuesViewer.tsx +169 -78
- package/src/components/home/ActivitySummary.tsx +2 -1
- package/src/components/home/HomeView.tsx +20 -9
- package/src/components/home/ProblemsPanel.test.tsx +81 -0
- package/src/components/home/TopologyPreview.tsx +2 -2
- package/src/components/issues/IssuesPane.render.test.tsx +122 -0
- package/src/components/issues/IssuesPane.test.ts +65 -1
- package/src/components/issues/IssuesPane.tsx +188 -25
- package/src/components/issues/uncheckedSources.ts +21 -0
- package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
- package/src/components/resource/PVCUsageBar.test.tsx +24 -0
- package/src/components/resource/PVCUsageBar.tsx +15 -6
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resources/CompositeRenderer.tsx +4 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +26 -12
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +28 -15
- package/src/components/timeline/TimelineList.tsx +22 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineView.tsx +18 -45
- package/src/components/timeline/ViewModeToggle.tsx +20 -0
- package/src/components/timeline/useStickyError.test.tsx +47 -0
- package/src/components/timeline/useStickyError.ts +15 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
- package/src/components/ui/Markdown.test.tsx +65 -0
- package/src/components/ui/Markdown.tsx +144 -127
- package/src/components/whats-new/releaseNotes.ts +63 -1
- package/src/components/workload/WorkloadView.test.ts +10 -1
- package/src/components/workload/WorkloadView.tsx +27 -239
- package/src/components/workspace/layout.test.tsx +29 -0
- package/src/components/workspace/layout.tsx +259 -0
- package/src/components/workspace/table.ts +7 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/integrations/resourceHost.test.tsx +55 -0
- package/src/integrations/resourceHost.ts +99 -0
- package/src/integrations/resourceHosts.test.tsx +141 -0
- package/src/integrations/resourceHosts.tsx +119 -0
- package/src/integrations/workspaceRoute.ts +20 -0
- package/src/integrations/workspaceRoutes.test.ts +40 -0
- package/src/integrations/workspaceRoutes.ts +22 -0
- package/src/integrations/workspaceScreens.tsx +14 -0
- package/src/utils/drawer-trail.ts +35 -0
- package/src/utils/kindFilterStore.test.ts +147 -0
- package/src/utils/kindFilterStore.ts +91 -0
- package/src/utils/navigation.test.ts +13 -1
- package/src/utils/navigation.ts +39 -2
- package/src/utils/page-links.ts +21 -0
- package/src/utils/tabStorage.ts +40 -0
- package/src/utils/viewMemory.test.ts +78 -0
- package/src/utils/viewMemory.ts +54 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { cnpgFormatLag } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import { formatBytes } from '../lsn'
|
|
3
|
+
import type { CNPGBackupMethod } from '../../../api/cnpg'
|
|
4
|
+
import type { ActionCapability } from '../../../api/actions'
|
|
5
|
+
|
|
6
|
+
export interface StandbyChoice {
|
|
7
|
+
pod: string
|
|
8
|
+
podUID: string
|
|
9
|
+
ineligible?: string
|
|
10
|
+
/** pg_stat_replication replay_lag: the recent replay delay, which can stay high after the standby caught up. */
|
|
11
|
+
replayLagSeconds?: number
|
|
12
|
+
/** WAL the primary has written that this standby has not replayed, in bytes: what it still has to catch up on. */
|
|
13
|
+
replayBacklogBytes?: number
|
|
14
|
+
state?: string
|
|
15
|
+
syncState?: string
|
|
16
|
+
/** What the live read shows wrong with it as a new primary: not connected, replay paused, another timeline. */
|
|
17
|
+
concerns?: SwitchoverConcern[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pad(n: number) {
|
|
21
|
+
return String(n).padStart(2, '0')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `<cluster>-<yyyymmddhhmmss>` in UTC, the name kubectl-cnpg uses. */
|
|
25
|
+
export function backupNameFor(cluster: string, now = new Date()): string {
|
|
26
|
+
const ts = `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`
|
|
27
|
+
return `${cluster}-${ts}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function describeBackupMethod(m: CNPGBackupMethod): string {
|
|
31
|
+
switch (m.method) {
|
|
32
|
+
case 'plugin':
|
|
33
|
+
return `Plugin ${m.pluginName ?? ''}${m.capability === 'unknown' ? ' (backup support not reported)' : ''}`.trim()
|
|
34
|
+
case 'volumeSnapshot':
|
|
35
|
+
return 'Volume snapshot'
|
|
36
|
+
default:
|
|
37
|
+
return 'Barman object store (in-tree, deprecated)'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Synchronous standbys first, then the least WAL still to replay, then the
|
|
43
|
+
* least replay delay. Never an ineligible one, and never one the live read
|
|
44
|
+
* shows trouble with: when only such standbys remain, the user picks.
|
|
45
|
+
*/
|
|
46
|
+
export function pickDefaultStandby(standbys: StandbyChoice[]): StandbyChoice | undefined {
|
|
47
|
+
const eligible = standbys.filter((s) => !s.ineligible && !(s.concerns?.length ?? 0))
|
|
48
|
+
const rank = (s: StandbyChoice) => (s.syncState === 'sync' || s.syncState === 'quorum' ? 0 : 1)
|
|
49
|
+
const inf = Number.POSITIVE_INFINITY
|
|
50
|
+
return [...eligible].sort(
|
|
51
|
+
(a, b) =>
|
|
52
|
+
rank(a) - rank(b) ||
|
|
53
|
+
(a.replayBacklogBytes ?? inf) - (b.replayBacklogBytes ?? inf) ||
|
|
54
|
+
(a.replayLagSeconds ?? inf) - (b.replayLagSeconds ?? inf),
|
|
55
|
+
)[0]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The switchover target: the default pick until the user has chosen one (or the dialog opened on one). */
|
|
59
|
+
export function switchoverDefault(input: { touched: boolean; current: string | undefined; standbys: StandbyChoice[] }): string | undefined {
|
|
60
|
+
return input.touched ? input.current : pickDefaultStandby(input.standbys)?.pod
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The live facts that make a standby a poor switchover target, in the words the dialog shows. */
|
|
64
|
+
export interface SwitchoverConcern {
|
|
65
|
+
code: 'not_receiving' | 'replay_paused' | 'timeline_mismatch'
|
|
66
|
+
text: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function switchoverConcerns(
|
|
70
|
+
primary: { state: string; timeline?: number; replication?: { applicationName: string; state?: string }[] | null } | undefined,
|
|
71
|
+
standby: { pod: string; status: { state: string; timeline?: number; replayPaused?: boolean; roleDetail?: string } } | undefined,
|
|
72
|
+
): SwitchoverConcern[] {
|
|
73
|
+
if (!primary || !standby) return []
|
|
74
|
+
const out: SwitchoverConcern[] = []
|
|
75
|
+
if (primary.state === 'ok' && primary.replication && !primary.replication.some((r) => r.applicationName === standby.pod)) out.push({ code: 'not_receiving', text: 'not connected to the primary' })
|
|
76
|
+
if (standby.status.replayPaused || standby.status.roleDetail === 'replayPaused') out.push({ code: 'replay_paused', text: 'replay paused' })
|
|
77
|
+
// An instance reports the timeline of its last checkpoint, which on a
|
|
78
|
+
// standby lags until its next restartpoint. One streaming from the current
|
|
79
|
+
// primary follows the primary's timeline whatever it reports.
|
|
80
|
+
const streaming = !!primary.replication?.some((r) => r.applicationName === standby.pod && r.state === 'streaming')
|
|
81
|
+
if (!streaming && standby.status.timeline !== undefined && primary.timeline !== undefined && standby.status.timeline !== primary.timeline) {
|
|
82
|
+
out.push({ code: 'timeline_mismatch', text: `timeline ${standby.status.timeline}, primary on ${primary.timeline}` })
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The warning for a chosen target the live read shows trouble with; nothing it says depends on how CloudNativePG then handles the promotion. */
|
|
88
|
+
export function switchoverConcernWarning(pod: string, concerns: SwitchoverConcern[]): string | null {
|
|
89
|
+
if (concerns.length === 0) return null
|
|
90
|
+
const notReceiving = concerns.some((c) => c.code === 'not_receiving')
|
|
91
|
+
return `${pod}: ${concerns.map((c) => c.text).join(' · ')}. ${notReceiving ? 'It is not receiving WAL from the current primary, so writes since then reach it only if it replays them from the WAL archive, and ' : ''}Radar cannot measure how far behind it is. Check its logs, or pick a streaming standby.`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What blocks destroying an instance, for the dialog's alert. The missing
|
|
96
|
+
* fence is left out when the dialog already offers "Fence first": saying it
|
|
97
|
+
* again as an alert only repeats the callout. The server checks the fence
|
|
98
|
+
* after every other state guard, so its reason names the fence only when
|
|
99
|
+
* nothing else blocks.
|
|
100
|
+
*/
|
|
101
|
+
export function cnpgDestroyBlocker(cap: ActionCapability | undefined, fenceOffered: boolean): string | undefined {
|
|
102
|
+
if (!cap || cap.allowed) return undefined
|
|
103
|
+
if (fenceOffered && cap.permission !== 'denied' && cap.reasonCode === 'fence_required') return undefined
|
|
104
|
+
return cap.reason ?? 'Not allowed'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The note under a switchover candidate that still has WAL to replay. The
|
|
109
|
+
* new primary replays it before it takes over, so the switchover can take
|
|
110
|
+
* longer; nothing is lost by choosing it. Based on the LSN backlog, not on
|
|
111
|
+
* replay_lag, which is a recent delay and stays high after a standby caught up.
|
|
112
|
+
*/
|
|
113
|
+
export function switchoverLagNote(s: Pick<StandbyChoice, 'pod' | 'replayBacklogBytes' | 'state'>): string | undefined {
|
|
114
|
+
const b = s.replayBacklogBytes
|
|
115
|
+
if (b === undefined || b <= 0) return undefined
|
|
116
|
+
// No pg_stat_replication row: it isn't receiving WAL, so "catches up" would be a guess.
|
|
117
|
+
if (s.state === undefined) return `${s.pod} has ${formatBytes(b)} of WAL still to replay and isn't connected to the primary to receive it.`
|
|
118
|
+
return `${s.pod} has ${formatBytes(b)} of WAL still to replay; the switchover may take longer while it catches up.`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A switchover candidate's catch-up facts: the backlog first, then the replay delay. */
|
|
122
|
+
export function switchoverCandidateFacts(s: Pick<StandbyChoice, 'replayBacklogBytes' | 'replayLagSeconds'>): string[] {
|
|
123
|
+
return [
|
|
124
|
+
s.replayBacklogBytes !== undefined ? `backlog ${formatBytes(s.replayBacklogBytes)}` : null,
|
|
125
|
+
s.replayLagSeconds !== undefined ? `replay delay ${cnpgFormatLag(s.replayLagSeconds)}` : null,
|
|
126
|
+
].filter((x): x is string => !!x)
|
|
127
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useRadarFeature } from '../../../api/client'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { CNPG_BARMAN_OBJECTSTORE_GROUP, isApiGroup, type NavigateToResource } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { CNPGClusterActions } from './CNPGClusterActions'
|
|
5
|
+
import { CNPGConnectButton } from './CNPGConnectButton'
|
|
6
|
+
import { CNPGScheduleActions } from './CNPGScheduleActions'
|
|
7
|
+
import { CNPGPoolerActions } from './CNPGPoolerActions'
|
|
8
|
+
import { CNPGOperationTracker } from '../operations/CNPGOperationTracker'
|
|
9
|
+
import { CNPGRestoreButton } from '../recovery/CNPGRestoreButton'
|
|
10
|
+
|
|
11
|
+
function backupRestoreBlocker(b: any): string | undefined {
|
|
12
|
+
if (b?.status?.phase !== 'completed') return 'Only a completed Backup can be restored'
|
|
13
|
+
const plugin = b?.spec?.method === 'plugin' || b?.status?.method === 'plugin'
|
|
14
|
+
if (plugin && !b?.status?.backupId) return 'The Backup records no backup ID, which a plugin restore needs'
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function headerActions({
|
|
19
|
+
resource,
|
|
20
|
+
namespace,
|
|
21
|
+
name,
|
|
22
|
+
compact,
|
|
23
|
+
onNavigate,
|
|
24
|
+
}: {
|
|
25
|
+
resource: any
|
|
26
|
+
namespace: string
|
|
27
|
+
name: string
|
|
28
|
+
compact: boolean
|
|
29
|
+
onNavigate?: NavigateToResource
|
|
30
|
+
}): ReactNode {
|
|
31
|
+
if (isApiGroup(resource?.apiVersion, CNPG_BARMAN_OBJECTSTORE_GROUP) && resource.kind === 'ObjectStore') {
|
|
32
|
+
return <CNPGRestoreButton namespace={namespace} entry={{ kind: 'objectStore', name }} compact={compact} />
|
|
33
|
+
}
|
|
34
|
+
if (!isApiGroup(resource?.apiVersion, 'postgresql.cnpg.io')) return null
|
|
35
|
+
if (resource.kind === 'Cluster') {
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex items-center gap-1.5">
|
|
38
|
+
<CNPGOperationTracker namespace={namespace} name={name} uid={resource.metadata.uid} />
|
|
39
|
+
<CNPGConnectButton namespace={namespace} name={name} compact={compact} onNavigate={onNavigate} />
|
|
40
|
+
<CNPGClusterActions namespace={namespace} name={name} compact={compact} />
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
if (resource.kind === 'Backup') {
|
|
45
|
+
return <CNPGRestoreButton namespace={namespace} entry={{ kind: 'backup', name }} disabledReason={backupRestoreBlocker(resource)} compact={compact} />
|
|
46
|
+
}
|
|
47
|
+
if (resource.kind === 'ScheduledBackup') return <CNPGScheduleActions namespace={namespace} name={name} />
|
|
48
|
+
if (resource.kind === 'Pooler') return <CNPGPoolerActions namespace={namespace} name={name} />
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
function CNPGHeaderActionsForServer(props: Parameters<typeof headerActions>[0]) {
|
|
54
|
+
const { support } = useRadarFeature('cnpgWorkspace')
|
|
55
|
+
return support === 'supported' ? headerActions(props) : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function renderCNPGHeaderActions(props: Parameters<typeof headerActions>[0]): ReactNode {
|
|
59
|
+
return <CNPGHeaderActionsForServer {...props} />
|
|
60
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { GitOpsWriteWarning, canConfirmGitOpsWrite, gitOpsRouteForOwner, type GitOpsWrite } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { useGitOpsWriteGuard } from '../../../hooks/useGitOpsWriteGuard'
|
|
4
|
+
import { useCNPGNavigate } from '../useCNPGNavigate'
|
|
5
|
+
|
|
6
|
+
export type CNPGWriteScope =
|
|
7
|
+
| { kind: 'status' }
|
|
8
|
+
| { kind: 'metadata' | 'spec'; paths: string[] }
|
|
9
|
+
| { kind: 'create-child' }
|
|
10
|
+
| { kind: 'delete-operator-owned'; owner: string }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The shared GitOps write guard for a CNPG action on `name` (a Cluster unless
|
|
14
|
+
* `targetKind` says otherwise). Returns the warning block and whether the
|
|
15
|
+
* dialog may confirm.
|
|
16
|
+
*/
|
|
17
|
+
export function useCNPGWriteGuard({
|
|
18
|
+
namespace,
|
|
19
|
+
name,
|
|
20
|
+
scope,
|
|
21
|
+
targetKind = 'Cluster',
|
|
22
|
+
}: {
|
|
23
|
+
namespace: string
|
|
24
|
+
name: string
|
|
25
|
+
scope: CNPGWriteScope
|
|
26
|
+
targetKind?: string
|
|
27
|
+
}): { node: ReactNode; satisfied: boolean } {
|
|
28
|
+
const navigate = useCNPGNavigate()
|
|
29
|
+
const [acked, setAcked] = useState(false)
|
|
30
|
+
const operatorOwned = scope.kind === 'delete-operator-owned'
|
|
31
|
+
const writes: GitOpsWrite[] = operatorOwned
|
|
32
|
+
? []
|
|
33
|
+
: [{ scope: scope.kind, paths: 'paths' in scope ? scope.paths : undefined }]
|
|
34
|
+
const { guard } = useGitOpsWriteGuard({
|
|
35
|
+
target: { kind: targetKind, group: 'postgresql.cnpg.io', namespace, name },
|
|
36
|
+
writes,
|
|
37
|
+
enabled: !operatorOwned,
|
|
38
|
+
})
|
|
39
|
+
if (operatorOwned) {
|
|
40
|
+
return {
|
|
41
|
+
node: <div className="text-xs text-theme-text-secondary">{scope.owner} recreates this Pod; GitOps sync is not involved.</div>,
|
|
42
|
+
satisfied: true,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!guard) return { node: null, satisfied: false }
|
|
46
|
+
const route = guard.owner ? gitOpsRouteForOwner(guard.owner) : null
|
|
47
|
+
return {
|
|
48
|
+
node: (
|
|
49
|
+
<GitOpsWriteWarning
|
|
50
|
+
guard={guard}
|
|
51
|
+
acknowledged={acked}
|
|
52
|
+
onAcknowledgedChange={setAcked}
|
|
53
|
+
onOpenOwner={route ? () => navigate(route) : undefined}
|
|
54
|
+
/>
|
|
55
|
+
),
|
|
56
|
+
satisfied: canConfirmGitOpsWrite(guard, acked),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useOpenTerminal } from '../../dock'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Opens psql in an instance's postgres container in the dock terminal, over
|
|
5
|
+
* the caller's own pods/exec. It connects on the local socket as the postgres
|
|
6
|
+
* superuser, like `kubectl cnpg psql`.
|
|
7
|
+
*/
|
|
8
|
+
export function useOpenCNPGPsql() {
|
|
9
|
+
const openTerminal = useOpenTerminal()
|
|
10
|
+
return (namespace: string, pod: string, isPrimary: boolean) =>
|
|
11
|
+
openTerminal({
|
|
12
|
+
namespace,
|
|
13
|
+
podName: pod,
|
|
14
|
+
containerName: 'postgres',
|
|
15
|
+
containers: ['postgres'],
|
|
16
|
+
shell: 'psql',
|
|
17
|
+
title: `psql · ${pod}`,
|
|
18
|
+
sessionNote: isPrimary
|
|
19
|
+
? 'psql as postgres (superuser) · currently the primary: read-write'
|
|
20
|
+
: 'psql as postgres (superuser) · currently a standby: read-only while it stays a standby',
|
|
21
|
+
})
|
|
22
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { cnpgArchiveDestination, cnpgRecoveryBase, resumeBoundary, walAfter, type CNPGWALArchiver } from './archivingRepair'
|
|
3
|
+
|
|
4
|
+
describe('cnpgArchiveDestination', () => {
|
|
5
|
+
it('names each credential Secret and key, never reading them', () => {
|
|
6
|
+
const d = cnpgArchiveDestination(
|
|
7
|
+
{
|
|
8
|
+
destinationPath: 's3://radar-cnpg-demo/',
|
|
9
|
+
endpointURL: 'http://192.0.2.1:9000',
|
|
10
|
+
s3Credentials: { accessKeyId: { name: 'creds', key: 'ACCESS_KEY_ID' }, secretAccessKey: { name: 'creds', key: 'ACCESS_SECRET_KEY' } },
|
|
11
|
+
endpointCA: { name: 'minio-ca', key: 'ca.crt' },
|
|
12
|
+
},
|
|
13
|
+
'ObjectStore pg-store',
|
|
14
|
+
)
|
|
15
|
+
expect(d).toMatchObject({ path: 's3://radar-cnpg-demo/', endpointURL: 'http://192.0.2.1:9000', declaredIn: 'ObjectStore pg-store' })
|
|
16
|
+
expect(d.secrets).toEqual([
|
|
17
|
+
{ secret: 'creds', key: 'ACCESS_KEY_ID', what: 'S3 access key ID' },
|
|
18
|
+
{ secret: 'creds', key: 'ACCESS_SECRET_KEY', what: 'S3 secret access key' },
|
|
19
|
+
{ secret: 'minio-ca', key: 'ca.crt', what: 'endpoint CA bundle' },
|
|
20
|
+
])
|
|
21
|
+
expect(d.identity).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
it('says when credentials come from the workload identity instead', () => {
|
|
24
|
+
expect(cnpgArchiveDestination({ destinationPath: 's3://b/', s3Credentials: { inheritFromIAMRole: true } }, 'x').identity).toContain('IAM role')
|
|
25
|
+
expect(cnpgArchiveDestination({ destinationPath: 'gs://b/', googleCredentials: { gkeEnvironment: true } }, 'x').identity).toContain('GKE')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('a base backup after archiving resumed', () => {
|
|
30
|
+
const t = (iso: string) => Date.parse(iso)
|
|
31
|
+
const cluster = (since?: string) => ({ status: { conditions: since ? [{ type: 'ContinuousArchiving', status: 'True', lastTransitionTime: since }] : [] } })
|
|
32
|
+
const backup = (name: string, startedAt: string, extra: any = {}) => ({
|
|
33
|
+
apiVersion: 'postgresql.cnpg.io/v1',
|
|
34
|
+
kind: 'Backup',
|
|
35
|
+
metadata: { name, namespace: 'db' },
|
|
36
|
+
spec: { cluster: { name: 'pg' }, method: 'plugin', pluginConfiguration: { name: 'barman-cloud.cloudnative-pg.io' } },
|
|
37
|
+
status: { phase: 'completed', startedAt, ...extra },
|
|
38
|
+
})
|
|
39
|
+
const pg = { namespace: 'db', name: 'pg' }
|
|
40
|
+
const newest = (backups: any[] | null, boundary: number, archiver: CNPGWALArchiver) => {
|
|
41
|
+
const r = cnpgRecoveryBase(pg, backups, boundary, archiver)
|
|
42
|
+
return r.state === 'unread' ? 'unread' : 'backup' in r ? r.backup.metadata.name : undefined
|
|
43
|
+
}
|
|
44
|
+
const plugin: CNPGWALArchiver = { method: 'plugin', plugin: 'barman-cloud.cloudnative-pg.io' }
|
|
45
|
+
|
|
46
|
+
it('resumes no earlier than the condition turning True after the last failure', () => {
|
|
47
|
+
expect(resumeBoundary(cluster('2026-10-04T10:10:00Z'), t('2026-10-04T10:00:00Z'))).toBe(t('2026-10-04T10:10:00Z'))
|
|
48
|
+
// A True transition from before the failure says nothing about the resume.
|
|
49
|
+
expect(resumeBoundary(cluster('2026-10-01T00:00:00Z'), t('2026-10-04T10:00:00Z'))).toBe(t('2026-10-04T10:00:00Z'))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not accept a backup that started between the failure and the resume', () => {
|
|
53
|
+
const boundary = resumeBoundary(cluster('2026-10-04T10:10:00Z'), t('2026-10-04T10:00:00Z'))
|
|
54
|
+
expect(newest([backup('gap', '2026-10-04T10:05:00Z')], boundary, plugin)).toBeUndefined()
|
|
55
|
+
expect(newest([backup('gap', '2026-10-04T10:05:00Z'), backup('after', '2026-10-04T10:20:00Z')], boundary, plugin)).toBe('after')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('ignores other clusters, unfinished runs, and says when Backups could not be read', () => {
|
|
59
|
+
const boundary = t('2026-10-04T10:00:00Z')
|
|
60
|
+
const other = { ...backup('other', '2026-10-04T11:00:00Z'), spec: { cluster: { name: 'pg-b' } } }
|
|
61
|
+
const running = backup('running', '2026-10-04T11:00:00Z', { phase: 'running' })
|
|
62
|
+
expect(newest([other, running], boundary, plugin)).toBeUndefined()
|
|
63
|
+
expect(newest(null, boundary, plugin)).toBe('unread')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('accepts only a Backup a restore can start from with this cluster\'s WAL archive', () => {
|
|
67
|
+
const boundary = t('2026-10-04T10:00:00Z')
|
|
68
|
+
const otherPlugin = { ...backup('other-plugin', '2026-10-04T11:00:00Z'), spec: { cluster: { name: 'pg' }, method: 'plugin', pluginConfiguration: { name: 'other.example.io' } } }
|
|
69
|
+
const inTree = { ...backup('in-tree', '2026-10-04T11:00:00Z'), spec: { cluster: { name: 'pg' } } }
|
|
70
|
+
const snapshot = { ...backup('snap', '2026-10-04T11:00:00Z'), spec: { cluster: { name: 'pg' }, method: 'volumeSnapshot' } }
|
|
71
|
+
expect(newest([otherPlugin, inTree], boundary, plugin)).toBeUndefined()
|
|
72
|
+
expect(newest([otherPlugin, inTree], boundary, { method: 'barmanObjectStore' })).toBe('in-tree')
|
|
73
|
+
expect(newest([otherPlugin, snapshot], boundary, plugin)).toBe('snap')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('takes the resume from the condition when the instance manager no longer reports the failure', () => {
|
|
77
|
+
expect(resumeBoundary(cluster('2026-10-04T10:10:00Z'), NaN)).toBe(t('2026-10-04T10:10:00Z'))
|
|
78
|
+
expect(resumeBoundary(cluster(), NaN)).toBeNaN()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('counts a backup only when it begins after the last WAL that failed to archive', () => {
|
|
82
|
+
const boundary = t('2026-10-04T10:00:00Z')
|
|
83
|
+
const failed = '000000010000000000000004'
|
|
84
|
+
// Started after the resume, but taken from a standby that had replayed only to segment 3.
|
|
85
|
+
const lagging = backup('lagging', '2026-10-04T11:00:00Z', { beginWal: '000000010000000000000003' })
|
|
86
|
+
const after = backup('after', '2026-10-04T10:30:00Z', { beginWal: '000000010000000000000006' })
|
|
87
|
+
expect(cnpgRecoveryBase(pg, [lagging], boundary, plugin, failed)).toMatchObject({ state: 'beginsBefore', failedWal: failed })
|
|
88
|
+
expect(cnpgRecoveryBase(pg, [lagging, after], boundary, plugin, failed)).toMatchObject({ state: 'verified', backup: { metadata: { name: 'after' } } })
|
|
89
|
+
// Without the failed WAL (the primary's stats restarted) nothing can be verified.
|
|
90
|
+
expect(cnpgRecoveryBase(pg, [after], boundary, plugin)).toMatchObject({ state: 'unverifiable', missing: 'failedWal' })
|
|
91
|
+
// A backup whose status does not say where it begins is not diagnosed as beginning before.
|
|
92
|
+
const unreported = backup('unreported', '2026-10-04T11:30:00Z')
|
|
93
|
+
const malformed = backup('malformed', '2026-10-04T11:30:00Z', { beginWal: 'n/a' })
|
|
94
|
+
expect(cnpgRecoveryBase(pg, [unreported], boundary, plugin, failed)).toMatchObject({ state: 'unverifiable', missing: 'beginWal' })
|
|
95
|
+
expect(cnpgRecoveryBase(pg, [malformed, lagging], boundary, plugin, failed)).toMatchObject({ state: 'unverifiable', missing: 'beginWal' })
|
|
96
|
+
expect(cnpgRecoveryBase(pg, [], boundary, plugin, failed)).toEqual({ state: 'none' })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('orders WAL files by position across timelines', () => {
|
|
100
|
+
expect(walAfter('000000020000000000000005', '000000010000000000000004')).toBe(true)
|
|
101
|
+
expect(walAfter('000000020000000000000003', '000000010000000000000004')).toBe(false)
|
|
102
|
+
expect(walAfter(undefined, '000000010000000000000004')).toBeNull()
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The parts of a barman-cloud destination an on-call engineer checks when WAL
|
|
2
|
+
// archiving fails: where it writes and which Secrets hold its credentials.
|
|
3
|
+
// Secrets are named, never read.
|
|
4
|
+
|
|
5
|
+
export interface CNPGArchiveDestination {
|
|
6
|
+
/** destinationPath, e.g. s3://bucket/prefix */
|
|
7
|
+
path?: string
|
|
8
|
+
endpointURL?: string
|
|
9
|
+
/** Each Secret key the destination reads its credentials from. */
|
|
10
|
+
secrets: { secret: string; key?: string; what: string }[]
|
|
11
|
+
/** Credentials come from the workload's identity instead of a Secret. */
|
|
12
|
+
identity?: string
|
|
13
|
+
/** Where this configuration is declared. */
|
|
14
|
+
declaredIn: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SECRET_FIELDS: [string, string, string][] = [
|
|
18
|
+
['s3Credentials', 'accessKeyId', 'S3 access key ID'],
|
|
19
|
+
['s3Credentials', 'secretAccessKey', 'S3 secret access key'],
|
|
20
|
+
['s3Credentials', 'region', 'S3 region'],
|
|
21
|
+
['s3Credentials', 'sessionToken', 'S3 session token'],
|
|
22
|
+
['azureCredentials', 'connectionString', 'Azure connection string'],
|
|
23
|
+
['azureCredentials', 'storageAccount', 'Azure storage account'],
|
|
24
|
+
['azureCredentials', 'storageKey', 'Azure storage key'],
|
|
25
|
+
['azureCredentials', 'storageSasToken', 'Azure SAS token'],
|
|
26
|
+
['googleCredentials', 'applicationCredentials', 'Google application credentials'],
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
/** Reads a barman-cloud object store configuration (in-tree `spec.backup.barmanObjectStore` or an ObjectStore's `spec.configuration`). */
|
|
30
|
+
export function cnpgArchiveDestination(config: any, declaredIn: string): CNPGArchiveDestination {
|
|
31
|
+
const secrets: CNPGArchiveDestination['secrets'] = []
|
|
32
|
+
for (const [group, field, what] of SECRET_FIELDS) {
|
|
33
|
+
const ref = config?.[group]?.[field]
|
|
34
|
+
if (typeof ref?.name === 'string' && ref.name) secrets.push({ secret: ref.name, key: typeof ref.key === 'string' ? ref.key : undefined, what })
|
|
35
|
+
}
|
|
36
|
+
const ca = config?.endpointCA
|
|
37
|
+
if (typeof ca?.name === 'string' && ca.name) secrets.push({ secret: ca.name, key: typeof ca.key === 'string' ? ca.key : undefined, what: 'endpoint CA bundle' })
|
|
38
|
+
const identity = config?.s3Credentials?.inheritFromIAMRole
|
|
39
|
+
? 'the Pod’s IAM role (inheritFromIAMRole)'
|
|
40
|
+
: config?.azureCredentials?.inheritFromAzureAD
|
|
41
|
+
? 'the Pod’s Azure AD workload identity (inheritFromAzureAD)'
|
|
42
|
+
: config?.googleCredentials?.gkeEnvironment
|
|
43
|
+
? 'the GKE workload identity (gkeEnvironment)'
|
|
44
|
+
: undefined
|
|
45
|
+
return {
|
|
46
|
+
path: typeof config?.destinationPath === 'string' ? config.destinationPath : undefined,
|
|
47
|
+
endpointURL: typeof config?.endpointURL === 'string' ? config.endpointURL : undefined,
|
|
48
|
+
secrets,
|
|
49
|
+
identity,
|
|
50
|
+
declaredIn,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* When archiving resumed, at the latest: the later of the last failure the
|
|
56
|
+
* instance manager saw and the ContinuousArchiving condition turning True. A
|
|
57
|
+
* Backup that started after the last failure saw no archive failure while it
|
|
58
|
+
* ran. NaN when neither is known.
|
|
59
|
+
*/
|
|
60
|
+
export function resumeBoundary(cluster: any, failedAt: number): number {
|
|
61
|
+
const conds = cluster?.status?.conditions
|
|
62
|
+
const c = Array.isArray(conds) ? conds.find((x: any) => x?.type === 'ContinuousArchiving') : null
|
|
63
|
+
const trueSince = c?.status === 'True' ? Date.parse(c.lastTransitionTime ?? '') : NaN
|
|
64
|
+
if (!Number.isFinite(trueSince)) return failedAt
|
|
65
|
+
return Number.isFinite(failedAt) && failedAt > trueSince ? failedAt : trueSince
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The method a Backup ran with; CloudNativePG defaults an unset one to barmanObjectStore. */
|
|
69
|
+
export function backupMethod(b: any): string {
|
|
70
|
+
return b?.status?.method || b?.spec?.method || 'barmanObjectStore'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type CNPGWALArchiver = { method: 'plugin'; plugin: string } | { method: 'barmanObjectStore' }
|
|
74
|
+
|
|
75
|
+
const WAL_FILE = /^[0-9A-F]{24}$/i
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether WAL file `a` comes after `b`. The last 16 hex digits are the
|
|
79
|
+
* segment's position, which keeps increasing across timelines; null when
|
|
80
|
+
* either name is not a WAL file name.
|
|
81
|
+
*/
|
|
82
|
+
export function walAfter(a: unknown, b: unknown): boolean | null {
|
|
83
|
+
if (typeof a !== 'string' || typeof b !== 'string' || !WAL_FILE.test(a) || !WAL_FILE.test(b)) return null
|
|
84
|
+
return a.slice(8).toUpperCase() > b.slice(8).toUpperCase()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether a restore can start after the failure without any WAL that failed
|
|
89
|
+
* to archive. A Backup that started after archiving resumed can still begin
|
|
90
|
+
* earlier in the WAL — CloudNativePG backs up a standby by default, and a
|
|
91
|
+
* lagging one begins where it has replayed to — so its own beginWal decides,
|
|
92
|
+
* against the last WAL the instance manager failed to archive.
|
|
93
|
+
*/
|
|
94
|
+
export type CNPGRecoveryBase =
|
|
95
|
+
| { state: 'unread' }
|
|
96
|
+
| { state: 'none' }
|
|
97
|
+
| { state: 'verified'; backup: any; failedWal: string }
|
|
98
|
+
| { state: 'beginsBefore'; backup: any; failedWal: string }
|
|
99
|
+
/** `missing` says which side of the comparison could not be read. */
|
|
100
|
+
| { state: 'unverifiable'; backup: any; missing: 'failedWal' | 'beginWal' }
|
|
101
|
+
|
|
102
|
+
export function cnpgRecoveryBase(
|
|
103
|
+
cluster: { namespace: string; name: string },
|
|
104
|
+
backups: any[] | null,
|
|
105
|
+
boundary: number,
|
|
106
|
+
archiver: CNPGWALArchiver,
|
|
107
|
+
failedWal?: string,
|
|
108
|
+
): CNPGRecoveryBase {
|
|
109
|
+
if (backups === null) return { state: 'unread' }
|
|
110
|
+
const candidates = backupsAfterResume(cluster, backups, boundary, archiver)
|
|
111
|
+
if (candidates.length === 0) return { state: 'none' }
|
|
112
|
+
if (!failedWal || walAfter(failedWal, failedWal) === null) return { state: 'unverifiable', backup: candidates[0], missing: 'failedWal' }
|
|
113
|
+
const verified = candidates.find((b) => walAfter(b?.status?.beginWal, failedWal) === true)
|
|
114
|
+
if (verified) return { state: 'verified', backup: verified, failedWal }
|
|
115
|
+
const newest = candidates[0]
|
|
116
|
+
return walAfter(newest?.status?.beginWal, failedWal) === false
|
|
117
|
+
? { state: 'beginsBefore', backup: newest, failedWal }
|
|
118
|
+
: { state: 'unverifiable', backup: newest, missing: 'beginWal' }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Completed Backups of this cluster that started after `boundary`, newest
|
|
123
|
+
* first, that a restore can start from with this cluster's WAL archive: one
|
|
124
|
+
* written by the same archiver, or a volume snapshot (restored by replaying
|
|
125
|
+
* WAL from the archive).
|
|
126
|
+
*/
|
|
127
|
+
export function backupsAfterResume(cluster: { namespace: string; name: string }, backups: any[], boundary: number, archiver: CNPGWALArchiver): any[] {
|
|
128
|
+
const sameArchive = (b: any) => {
|
|
129
|
+
const m = backupMethod(b)
|
|
130
|
+
if (m === 'volumeSnapshot') return true
|
|
131
|
+
if (archiver.method === 'plugin') return m === 'plugin' && b?.spec?.pluginConfiguration?.name === archiver.plugin
|
|
132
|
+
return m === 'barmanObjectStore'
|
|
133
|
+
}
|
|
134
|
+
return backups
|
|
135
|
+
.filter(
|
|
136
|
+
(b) =>
|
|
137
|
+
String(b?.apiVersion ?? '').startsWith('postgresql.cnpg.io/') &&
|
|
138
|
+
b?.metadata?.namespace === cluster.namespace &&
|
|
139
|
+
b?.spec?.cluster?.name === cluster.name &&
|
|
140
|
+
b?.status?.phase === 'completed' &&
|
|
141
|
+
sameArchive(b) &&
|
|
142
|
+
Date.parse(b?.status?.startedAt ?? '') > boundary,
|
|
143
|
+
)
|
|
144
|
+
.sort((a, b) => Date.parse(b.status.startedAt) - Date.parse(a.status.startedAt))
|
|
145
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { CNPGRuntimeInstance, CNPGRuntimeResponse } from '../../api/cnpg'
|
|
3
|
+
import { cnpgBaseBackupFacts, describeCNPGBaseBackup } from './baseBackup'
|
|
4
|
+
|
|
5
|
+
function rt(status: Partial<CNPGRuntimeInstance['status']>, proxy: 'allowed' | 'denied' = 'allowed'): CNPGRuntimeResponse {
|
|
6
|
+
return {
|
|
7
|
+
cluster: { namespace: 'db', name: 'pg', uid: 'u' },
|
|
8
|
+
sampledAt: '2026-09-30T10:00:00Z',
|
|
9
|
+
permission: { proxy },
|
|
10
|
+
instances: [{ pod: 'pg-1', role: 'primary', status: { state: 'ok', ...status }, metrics: { state: 'ok' } }],
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('cnpgBaseBackupFacts', () => {
|
|
15
|
+
it('is unknown, not none, when the primary report was not read', () => {
|
|
16
|
+
expect(cnpgBaseBackupFacts(undefined)).toBeUndefined()
|
|
17
|
+
expect(cnpgBaseBackupFacts(rt({ baseBackups: [] }, 'denied'))).toBeUndefined()
|
|
18
|
+
expect(cnpgBaseBackupFacts(rt({ state: 'unreachable' }))).toBeUndefined()
|
|
19
|
+
expect(cnpgBaseBackupFacts(rt({}))).toBeUndefined()
|
|
20
|
+
expect(cnpgBaseBackupFacts(rt({ state: 'partial', incomplete: true, maskedError: 'x', baseBackups: null }))).toBeUndefined()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('says none only when the readable report lists none', () => {
|
|
24
|
+
expect(cnpgBaseBackupFacts(rt({ baseBackups: [] }))?.fact.text).toBe('None running')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('shows progress, and never 0 % when the total is not estimated', () => {
|
|
28
|
+
const f = cnpgBaseBackupFacts(
|
|
29
|
+
rt({ baseBackups: [{ applicationName: 'pg-4-join', instance: 'pg-4', phase: 'streaming database files', totalBytes: 4096, streamedBytes: 1024, tablespacesTotal: 1, tablespacesStreamed: 0 }] }),
|
|
30
|
+
)
|
|
31
|
+
expect(f?.fact.text).toBe('Base backup to new instance pg-4: streaming database files, 1.0 KiB of 4.0 KiB (25 %)')
|
|
32
|
+
expect(describeCNPGBaseBackup({ applicationName: 'x-join', instance: 'x', phase: 'waiting for checkpoint to finish', streamedBytes: 0, tablespacesTotal: 0, tablespacesStreamed: 0 })).toBe(
|
|
33
|
+
'Base backup to new instance x: waiting for checkpoint to finish, 0 B streamed, total not estimated yet',
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Fact } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import type { CNPGRuntimeBaseBackup, CNPGRuntimeResponse } from '../../api/cnpg'
|
|
3
|
+
import { formatBytes } from './lsn'
|
|
4
|
+
|
|
5
|
+
export const CNPG_BASE_BACKUP_SOURCE =
|
|
6
|
+
'pg_basebackup on the primary, joining instances only'
|
|
7
|
+
|
|
8
|
+
export function describeCNPGBaseBackup(bb: CNPGRuntimeBaseBackup): string {
|
|
9
|
+
const who = bb.instance ? `to new instance ${bb.instance}` : `for ${bb.applicationName}`
|
|
10
|
+
const progress =
|
|
11
|
+
bb.totalBytes !== undefined && bb.totalBytes > 0
|
|
12
|
+
? `${formatBytes(bb.streamedBytes)} of ${formatBytes(bb.totalBytes)} (${Math.min(100, (bb.streamedBytes / bb.totalBytes) * 100).toFixed(0)} %)`
|
|
13
|
+
: `${formatBytes(bb.streamedBytes)} streamed, total not estimated yet`
|
|
14
|
+
return `Base backup ${who}: ${bb.phase}, ${progress}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The primary's running base backups. Undefined when the primary's report was
|
|
19
|
+
* not read, so the Overview omits the fact rather than claiming none.
|
|
20
|
+
*/
|
|
21
|
+
export function cnpgBaseBackupFacts(rt: CNPGRuntimeResponse | undefined): { fact: Fact; rows: CNPGRuntimeBaseBackup[] } | undefined {
|
|
22
|
+
if (!rt || rt.permission.proxy === 'denied') return undefined
|
|
23
|
+
const primary = rt.instances.find((i) => i.role === 'primary')
|
|
24
|
+
if (!primary || (primary.status.state !== 'ok' && primary.status.state !== 'partial')) return undefined
|
|
25
|
+
const rows = primary.status.baseBackups
|
|
26
|
+
if (!rows) return undefined
|
|
27
|
+
if (rows.length === 0) return { fact: { text: 'None running', tone: 'neutral', source: CNPG_BASE_BACKUP_SOURCE }, rows }
|
|
28
|
+
return {
|
|
29
|
+
fact: {
|
|
30
|
+
text: rows.length === 1 ? describeCNPGBaseBackup(rows[0]) : `${rows.length} base backups running`,
|
|
31
|
+
tone: 'neutral',
|
|
32
|
+
source: CNPG_BASE_BACKUP_SOURCE,
|
|
33
|
+
},
|
|
34
|
+
rows,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildBlockingTree, cnpgConnectionFigure, cnpgNoMetricsReadings, countVictims } from './blocking'
|
|
3
|
+
import type { CNPGBackend } from '../../api/cnpg-sessions'
|
|
4
|
+
|
|
5
|
+
const s = (pid: number, blockedBy: number[] = []): CNPGBackend => ({ pid, blockedBy, backendStart: `t${pid}` })
|
|
6
|
+
|
|
7
|
+
describe('buildBlockingTree', () => {
|
|
8
|
+
it('nests a chain under its root blocker', () => {
|
|
9
|
+
const roots = buildBlockingTree([s(1), s(2, [1]), s(3, [2])])
|
|
10
|
+
expect(roots).toHaveLength(1)
|
|
11
|
+
expect(roots[0].session.pid).toBe(1)
|
|
12
|
+
expect(roots[0].children[0].session.pid).toBe(2)
|
|
13
|
+
expect(roots[0].children[0].children[0].session.pid).toBe(3)
|
|
14
|
+
expect(countVictims(roots[0])).toBe(2)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('shows a victim of two blockers under both, naming the other', () => {
|
|
18
|
+
const roots = buildBlockingTree([s(1), s(2), s(3, [1, 2])])
|
|
19
|
+
expect(roots.map((r) => r.session.pid)).toEqual([1, 2])
|
|
20
|
+
expect(roots[0].children[0].alsoWaitsOn).toEqual([2])
|
|
21
|
+
expect(roots[1].children[0].alsoWaitsOn).toEqual([1])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('keeps a victim whose blocker is not listed', () => {
|
|
25
|
+
const roots = buildBlockingTree([s(5, [99])])
|
|
26
|
+
expect(roots).toHaveLength(1)
|
|
27
|
+
expect(roots[0].session.pid).toBe(5)
|
|
28
|
+
expect(roots[0].cycle).toBeUndefined()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('marks a deadlock cycle instead of recursing forever', () => {
|
|
32
|
+
const roots = buildBlockingTree([s(1, [2]), s(2, [1])])
|
|
33
|
+
expect(roots.length).toBeGreaterThan(0)
|
|
34
|
+
expect(roots.every((r) => r.cycle)).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('cnpgConnectionFigure', () => {
|
|
39
|
+
it('states usable headroom from pg_stat_activity when exec read it', () => {
|
|
40
|
+
expect(cnpgConnectionFigure({ maxConnections: 100, superuserReservedConnections: 3, clientBackends: 6 }, { sessionsTotal: 6, maxConnections: 100 })).toMatchObject({
|
|
41
|
+
value: '6 in use',
|
|
42
|
+
limit: 'of 97 usable (max_connections 100, 3 reserved)',
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
it('falls back to the exporter count and says the reserve is unknown', () => {
|
|
46
|
+
const f = cnpgConnectionFigure(undefined, { sessionsTotal: 90, maxConnections: 100 })
|
|
47
|
+
expect(f).toMatchObject({ value: '90 in use', limit: 'of max_connections 100 (superuser reserve not read)', tone: 'degraded' })
|
|
48
|
+
expect(cnpgConnectionFigure(undefined, {})).toBeUndefined()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('cnpgNoMetricsReadings', () => {
|
|
53
|
+
it('says there are no readings only when no instance has metrics', () => {
|
|
54
|
+
expect(cnpgNoMetricsReadings([null, null])).toBe(true)
|
|
55
|
+
expect(cnpgNoMetricsReadings([null, { containers: [] }])).toBe(false)
|
|
56
|
+
expect(cnpgNoMetricsReadings([null, undefined])).toBe(false)
|
|
57
|
+
expect(cnpgNoMetricsReadings([])).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
})
|