@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,56 @@
|
|
|
1
|
+
import { registerCNPGOperationObserver, summarizeSteps, type CNPGObservation, type CNPGObserverResult, type CNPGOpStep, type CNPGTrackedOperation } from '../operations/model'
|
|
2
|
+
import { isCNPGRecoveryJob } from './restoreModel'
|
|
3
|
+
|
|
4
|
+
const HEALTHY_PHASE = 'Cluster in healthy state'
|
|
5
|
+
const FAILING_PHASE = /unrecoverable|unable to|cannot|error|failed/i
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The tracker's view of a restore, from what the operation tracker already
|
|
9
|
+
* reads (the Cluster and its Jobs). The Cluster page's restore panel adds the
|
|
10
|
+
* recovery Pods and their init containers from /recovery.
|
|
11
|
+
*/
|
|
12
|
+
export function restoreOperationObserver(op: CNPGTrackedOperation, obs: CNPGObservation): CNPGObserverResult {
|
|
13
|
+
const c = obs.cluster
|
|
14
|
+
if (!c) {
|
|
15
|
+
return obs.now - op.startedAt < 60_000
|
|
16
|
+
? { state: 'requested', detail: `Waiting for Cluster ${op.cluster} to appear`, progressKey: 'absent' }
|
|
17
|
+
: { state: 'unobservable', detail: `Cluster ${op.cluster} is not visible with your access` }
|
|
18
|
+
}
|
|
19
|
+
const phase: string | undefined = c.status?.phase
|
|
20
|
+
const desired: number | undefined = c.spec?.instances
|
|
21
|
+
const ready: number | undefined = c.status?.readyInstances
|
|
22
|
+
const jobs = obs.ha?.jobs.state === 'ok' ? obs.ha.jobs.items.filter(isCNPGRecoveryJob) : undefined
|
|
23
|
+
const failed = jobs?.find((j) => j.phase === 'failed')
|
|
24
|
+
if (failed) return { state: 'failed', detail: `Recovery Job ${failed.name} failed${failed.reason ? `: ${failed.reason}` : ''}` }
|
|
25
|
+
if (phase && phase !== HEALTHY_PHASE && FAILING_PHASE.test(phase)) {
|
|
26
|
+
return { state: 'failed', detail: phase + (c.status?.phaseReason ? `: ${c.status.phaseReason}` : '') }
|
|
27
|
+
}
|
|
28
|
+
// A ready restored primary proves recovery finished, whether or not the
|
|
29
|
+
// caller can read the recovery Job.
|
|
30
|
+
const primaryReady = typeof ready === 'number' && ready > 0
|
|
31
|
+
const jobDone = primaryReady ? true : jobs === undefined ? null : jobs.some((j) => j.phase === 'succeeded')
|
|
32
|
+
const steps: CNPGOpStep[] = [
|
|
33
|
+
{ label: 'Base backup restored and WAL replayed (recovery Job finished)', done: jobDone },
|
|
34
|
+
{ label: 'Restored primary ready', done: typeof ready === 'number' ? ready > 0 : false },
|
|
35
|
+
{
|
|
36
|
+
label: `All ${desired ?? ''} instances ready and the Cluster healthy`.replace(' ', ' '),
|
|
37
|
+
done: typeof ready === 'number' && typeof desired === 'number' ? ready >= desired && phase === HEALTHY_PHASE : false,
|
|
38
|
+
},
|
|
39
|
+
]
|
|
40
|
+
const state = summarizeSteps(steps)
|
|
41
|
+
return {
|
|
42
|
+
state: state === 'unobservable' ? 'progressing' : state,
|
|
43
|
+
steps,
|
|
44
|
+
detail: [phase, typeof ready === 'number' ? `${ready}/${desired ?? '?'} ready` : null].filter(Boolean).join(' · '),
|
|
45
|
+
progressKey: `${phase ?? ''}|${ready ?? ''}|${jobs?.map((j) => j.phase).join(',') ?? '?'}`,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function registerRestoreObserver(): 'restore' {
|
|
50
|
+
registerCNPGOperationObserver('restore', restoreOperationObserver)
|
|
51
|
+
return 'restore'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Registered through a used export, not a bare side-effect import: the package
|
|
55
|
+
// declares itself side-effect free, so a bare import would be dropped.
|
|
56
|
+
export const CNPG_RESTORE_OPERATION = registerRestoreObserver()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { QueryClient, QueryObserver } from '@tanstack/react-query'
|
|
2
|
+
import { expect, it, vi } from 'vitest'
|
|
3
|
+
import { refetchCNPGDetail } from './refresh'
|
|
4
|
+
|
|
5
|
+
it('refreshes active reads for the object and namespace and waits for them', async () => {
|
|
6
|
+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
7
|
+
let finish!: (value: number) => void
|
|
8
|
+
const pending = new Promise<number>((resolve) => { finish = resolve })
|
|
9
|
+
const reads = [
|
|
10
|
+
['cnpg', 'runtime', 'db', 'pg'], ['cnpg', 'ha', 'db', 'pg'], ['cnpg', 'storage', 'db', 'pg'],
|
|
11
|
+
['cnpg', 'history', 'db', 'pg', '1h'], ['cnpg', 'sessions', 'db', 'pg', 'pg-1'],
|
|
12
|
+
['cnpg', 'workspace', 'db'], ['cnpg', 'workspace', 'db,other'], ['cnpg', 'workspace', ''],
|
|
13
|
+
['cnpg', 'capabilities', 'clusters', 'db', 'pg'], ['cnpg', 'restore-capability', 'db'],
|
|
14
|
+
['cnpg', 'runtime', 'db', 'other'], ['cnpg', 'runtime', 'other', 'pg'], ['resource', 'pods'],
|
|
15
|
+
['cnpg', 'workspace', 'elsewhere'], ['cnpg', 'parameters', 'db', 'pg'],
|
|
16
|
+
]
|
|
17
|
+
const fns = reads.map((_, i) => vi.fn(() => i === 0 ? pending : Promise.resolve(i)))
|
|
18
|
+
const observers = reads.map((queryKey, i) => {
|
|
19
|
+
client.setQueryData(queryKey, -1)
|
|
20
|
+
const observer = new QueryObserver(client, { queryKey, queryFn: fns[i], staleTime: Infinity })
|
|
21
|
+
return observer.subscribe(() => {})
|
|
22
|
+
})
|
|
23
|
+
const inactive = vi.fn(async () => 1)
|
|
24
|
+
client.setQueryData(['cnpg', 'recovery', 'db', 'pg'], -1)
|
|
25
|
+
client.setQueryDefaults(['cnpg', 'recovery', 'db', 'pg'], { queryFn: inactive })
|
|
26
|
+
let done = false
|
|
27
|
+
const refresh = refetchCNPGDetail(client, { plural: 'clusters', namespace: 'db', name: 'pg', group: 'postgresql.cnpg.io' }).then(() => { done = true })
|
|
28
|
+
await Promise.resolve()
|
|
29
|
+
expect(done).toBe(false)
|
|
30
|
+
for (const i of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 14]) expect(fns[i]).toHaveBeenCalledTimes(1)
|
|
31
|
+
for (const i of [10, 11, 12, 13]) expect(fns[i]).not.toHaveBeenCalled()
|
|
32
|
+
expect(inactive).not.toHaveBeenCalled()
|
|
33
|
+
finish(1)
|
|
34
|
+
await refresh
|
|
35
|
+
expect(done).toBe(true)
|
|
36
|
+
observers.forEach((off) => off())
|
|
37
|
+
client.clear()
|
|
38
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type { CNPGDetailTarget } from './routes'
|
|
3
|
+
|
|
4
|
+
export function refetchCNPGDetail(client: QueryClient, target: CNPGDetailTarget): Promise<void> {
|
|
5
|
+
return client.refetchQueries({
|
|
6
|
+
type: 'active',
|
|
7
|
+
predicate: ({ queryKey: key }) => {
|
|
8
|
+
if (key[0] !== 'cnpg') return false
|
|
9
|
+
if (['workspace', 'disk', 'fleet-metrics', 'operator-status'].includes(String(key[1]))) {
|
|
10
|
+
return key[2] === '' || String(key[2]).split(',').includes(target.namespace)
|
|
11
|
+
}
|
|
12
|
+
if (key[1] === 'capabilities') return key[2] === target.plural && key[3] === target.namespace && key[4] === target.name
|
|
13
|
+
if (key[1] === 'restore-capability') return key[2] === target.namespace
|
|
14
|
+
return key[2] === target.namespace && key[3] === target.name
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { RefreshFailedNotice } from '../workspace/layout'
|
|
4
|
+
|
|
5
|
+
describe('RefreshFailedNotice', () => {
|
|
6
|
+
it('says a refresh failed and how old the data on screen is', () => {
|
|
7
|
+
const html = renderToStaticMarkup(
|
|
8
|
+
<RefreshFailedNotice queries={[{ isRefetchError: true, error: new Error('Gateway Timeout'), dataUpdatedAt: Date.now() - 5 * 60_000 }]} />,
|
|
9
|
+
)
|
|
10
|
+
expect(html).toContain('Last refresh failed: Gateway Timeout')
|
|
11
|
+
expect(html).toContain('showing data from 5m ago')
|
|
12
|
+
})
|
|
13
|
+
it('names the oldest of several failed reads', () => {
|
|
14
|
+
const html = renderToStaticMarkup(
|
|
15
|
+
<RefreshFailedNotice
|
|
16
|
+
queries={[
|
|
17
|
+
{ isRefetchError: true, error: new Error('recent'), dataUpdatedAt: Date.now() - 60_000 },
|
|
18
|
+
{ isRefetchError: true, error: new Error('older'), dataUpdatedAt: Date.now() - 3 * 3_600_000 },
|
|
19
|
+
{ isRefetchError: false, error: null, dataUpdatedAt: Date.now() - 10 * 3_600_000 },
|
|
20
|
+
]}
|
|
21
|
+
/>,
|
|
22
|
+
)
|
|
23
|
+
expect(html).toContain('older')
|
|
24
|
+
expect(html).toContain('3h ago')
|
|
25
|
+
})
|
|
26
|
+
it('renders nothing while refreshes succeed', () => {
|
|
27
|
+
expect(renderToStaticMarkup(<RefreshFailedNotice queries={[{ isRefetchError: false, error: null, dataUpdatedAt: Date.now() }]} />)).toBe('')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { cnpgDetailKindFor, cnpgDetailPath,
|
|
2
|
+
import { cnpgClusterKindListMode, cnpgDetailKindFor, cnpgDetailPath, cnpgViewNamedForKind, parseCNPGRoute } from './routes'
|
|
3
|
+
import { decodeDrawerTrail, encodeDrawerTrail, sameSelectedResource } from '../../utils/drawer-trail'
|
|
3
4
|
|
|
4
5
|
describe('CNPG routes', () => {
|
|
5
6
|
it('parses workspace screens and falls back to Overview for unknown or unavailable ones', () => {
|
|
@@ -30,8 +31,8 @@ describe('CNPG routes', () => {
|
|
|
30
31
|
it('distinguishes same-named kinds from different groups', () => {
|
|
31
32
|
const cnpg = { kind: 'clusters', group: 'postgresql.cnpg.io', namespace: 'a', name: 'x' }
|
|
32
33
|
const capi = { kind: 'clusters', group: 'cluster.x-k8s.io', namespace: 'a', name: 'x' }
|
|
33
|
-
expect(
|
|
34
|
-
expect(
|
|
34
|
+
expect(sameSelectedResource(cnpg, capi)).toBe(false)
|
|
35
|
+
expect(sameSelectedResource(cnpg, { ...cnpg })).toBe(true)
|
|
35
36
|
})
|
|
36
37
|
|
|
37
38
|
it('parses full-detail routes for every CNPG kind and the cluster-scoped placeholder', () => {
|
|
@@ -58,3 +59,33 @@ describe('CNPG routes', () => {
|
|
|
58
59
|
expect(cnpgDetailKindFor('backups', 'velero.io')).toBeNull()
|
|
59
60
|
})
|
|
60
61
|
})
|
|
62
|
+
|
|
63
|
+
describe('cnpgViewNamedForKind', () => {
|
|
64
|
+
it('is true only for the kind the crumb’s view is named after', () => {
|
|
65
|
+
expect(cnpgViewNamedForKind('clusters')).toBe(true)
|
|
66
|
+
expect(cnpgViewNamedForKind('backups')).toBe(true)
|
|
67
|
+
expect(cnpgViewNamedForKind('poolers')).toBe(true)
|
|
68
|
+
// Also under Backups, but not what the view is named after.
|
|
69
|
+
expect(cnpgViewNamedForKind('scheduledbackups')).toBe(false)
|
|
70
|
+
expect(cnpgViewNamedForKind('objectstores')).toBe(false)
|
|
71
|
+
expect(cnpgViewNamedForKind('databases')).toBe(false)
|
|
72
|
+
expect(cnpgViewNamedForKind('pods')).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('cnpgClusterKindListMode', () => {
|
|
77
|
+
const cnpg = { name: 'clusters', group: 'postgresql.cnpg.io' }
|
|
78
|
+
it('shows the Clusters view for the CloudNativePG Cluster kind when this Radar serves it', () => {
|
|
79
|
+
expect(cnpgClusterKindListMode(cnpg, 'supported', false)).toBe('view')
|
|
80
|
+
})
|
|
81
|
+
it('waits, rather than flashing the table, only while capabilities first load', () => {
|
|
82
|
+
expect(cnpgClusterKindListMode(cnpg, 'unknown', true)).toBe('wait')
|
|
83
|
+
expect(cnpgClusterKindListMode(cnpg, 'unknown', false)).toBe('table')
|
|
84
|
+
})
|
|
85
|
+
it('keeps the table on a Radar without the views, and for every other kind', () => {
|
|
86
|
+
expect(cnpgClusterKindListMode(cnpg, 'unsupported', false)).toBe('table')
|
|
87
|
+
expect(cnpgClusterKindListMode({ name: 'clusters', group: 'cluster.x-k8s.io' }, 'supported', false)).toBe('table')
|
|
88
|
+
expect(cnpgClusterKindListMode({ name: 'backups', group: 'postgresql.cnpg.io' }, 'supported', false)).toBe('table')
|
|
89
|
+
expect(cnpgClusterKindListMode(null, 'supported', false)).toBe('table')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { CNPG_KIND_BY_KEY, type CNPGWorkspaceKey } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import type { RadarFeatureSupport } from '../../api/radarFeatures'
|
|
2
3
|
|
|
3
4
|
export type CNPGScreen = 'overview' | 'protection' | 'declarations' | 'pooling' | 'operator'
|
|
4
5
|
|
|
5
6
|
export const CNPG_SCREENS: { id: CNPGScreen; label: string; path: string }[] = [
|
|
6
|
-
{ id: 'overview', label: '
|
|
7
|
-
{ id: 'protection', label: '
|
|
7
|
+
{ id: 'overview', label: 'Clusters', path: '/cnpg' },
|
|
8
|
+
{ id: 'protection', label: 'Backups', path: '/cnpg/protection' },
|
|
8
9
|
{ id: 'declarations', label: 'Declarations', path: '/cnpg/declarations' },
|
|
9
10
|
{ id: 'pooling', label: 'Pooling', path: '/cnpg/pooling' },
|
|
10
11
|
{ id: 'operator', label: 'Operator', path: '/cnpg/operator' },
|
|
@@ -23,18 +24,62 @@ export interface CNPGRoute {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
// The CNPG kinds that have a CNPG-framed full detail, with the workspace
|
|
26
|
-
// destination each one lives under.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
27
|
+
// destination each one lives under. Kind and group come from the workspace's
|
|
28
|
+
// kind table, so the two never disagree.
|
|
29
|
+
const CNPG_DETAIL_HOMES: Partial<Record<CNPGWorkspaceKey, CNPGScreen>> = {
|
|
30
|
+
clusters: 'overview',
|
|
31
|
+
backups: 'protection',
|
|
32
|
+
scheduledBackups: 'protection',
|
|
33
|
+
objectStores: 'protection',
|
|
34
|
+
databases: 'declarations',
|
|
35
|
+
publications: 'declarations',
|
|
36
|
+
subscriptions: 'declarations',
|
|
37
|
+
databaseRoles: 'declarations',
|
|
38
|
+
poolers: 'pooling',
|
|
39
|
+
imageCatalogs: 'operator',
|
|
40
|
+
clusterImageCatalogs: 'operator',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const CNPG_DETAIL_KINDS: Record<string, { group: string; kind: string; home: CNPGScreen; clusterScoped?: boolean }> = Object.fromEntries(
|
|
44
|
+
(Object.entries(CNPG_DETAIL_HOMES) as [CNPGWorkspaceKey, CNPGScreen][]).map(([key, home]) => {
|
|
45
|
+
const k = CNPG_KIND_BY_KEY[key]
|
|
46
|
+
return [k.plural, { group: k.group, kind: k.kind, home, ...(key === 'clusterImageCatalogs' ? { clusterScoped: true } : {}) }]
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// The kind each view is named after.
|
|
51
|
+
const CNPG_VIEW_NAMESAKES: Partial<Record<CNPGScreen, string>> = { overview: 'clusters', protection: 'backups', pooling: 'poolers' }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether a kind is the one its home view is named after (Cluster in
|
|
55
|
+
* Clusters, Backup in Backups, Pooler in Pooling): the crumb then already says
|
|
56
|
+
* what the object is, so the kind badge is left out. Other kinds in the same
|
|
57
|
+
* view, such as ScheduledBackup and ObjectStore under Backups, keep it.
|
|
58
|
+
*/
|
|
59
|
+
export function cnpgViewNamedForKind(plural: string): boolean {
|
|
60
|
+
const home = CNPG_DETAIL_KINDS[plural]?.home
|
|
61
|
+
return !!home && CNPG_VIEW_NAMESAKES[home] === plural
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The CloudNativePG Cluster kind, whose list is the Clusters view. */
|
|
65
|
+
export function isCNPGClusterKind(kind: { name: string; group: string } | null | undefined): boolean {
|
|
66
|
+
return !!kind && kind.name === 'clusters' && kind.group === 'postgresql.cnpg.io'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What the CloudNativePG Cluster kind's Resources route shows: the Clusters
|
|
71
|
+
* view when this Radar serves it; a loader while capabilities load for the
|
|
72
|
+
* first time, so the table never flashes before it; otherwise the table (a
|
|
73
|
+
* Radar older than the views, or capabilities that could not be read).
|
|
74
|
+
*/
|
|
75
|
+
export function cnpgClusterKindListMode(
|
|
76
|
+
kind: { name: string; group: string } | null | undefined,
|
|
77
|
+
support: RadarFeatureSupport,
|
|
78
|
+
capabilitiesPending: boolean,
|
|
79
|
+
): 'view' | 'wait' | 'table' {
|
|
80
|
+
if (!isCNPGClusterKind(kind)) return 'table'
|
|
81
|
+
if (support === 'supported') return 'view'
|
|
82
|
+
return support === 'unknown' && capabilitiesPending ? 'wait' : 'table'
|
|
38
83
|
}
|
|
39
84
|
|
|
40
85
|
export function cnpgDetailKindFor(plural: string, group: string | undefined): string | null {
|
|
@@ -77,36 +122,3 @@ export function cnpgDetailPath(target: Omit<CNPGDetailTarget, 'group'>, ctx?: st
|
|
|
77
122
|
export function cnpgScreenPath(screen: CNPGScreen): string {
|
|
78
123
|
return CNPG_SCREENS.find((s) => s.id === screen)?.path ?? '/cnpg'
|
|
79
124
|
}
|
|
80
|
-
|
|
81
|
-
// Drawer identity in the URL: kind:group:namespace:name, chained with "~" for
|
|
82
|
-
// the in-drawer trail (last entry is the one shown). Kubernetes names and API
|
|
83
|
-
// groups cannot contain ":" or "~", and the group is mandatory — CNPG's Cluster
|
|
84
|
-
// and Backup collide with CAPI, KubeBlocks and Velero kinds.
|
|
85
|
-
export function encodeDrawerRef(r: SelectedResource): string {
|
|
86
|
-
return [r.kind, r.group ?? '', r.namespace ?? '', r.name].join(':')
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function decodeDrawerRef(s: string): SelectedResource | null {
|
|
90
|
-
const parts = s.split(':')
|
|
91
|
-
if (parts.length !== 4 || !parts[0] || !parts[3]) return null
|
|
92
|
-
return { kind: parts[0], group: parts[1], namespace: parts[2], name: parts[3] }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function decodeDrawerTrail(param: string | null): SelectedResource[] {
|
|
96
|
-
if (!param) return []
|
|
97
|
-
return param.split('~').map(decodeDrawerRef).filter((r): r is SelectedResource => r !== null)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function encodeDrawerTrail(trail: SelectedResource[]): string {
|
|
101
|
-
return trail.map(encodeDrawerRef).join('~')
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function sameResource(a: SelectedResource | null | undefined, b: SelectedResource | null | undefined): boolean {
|
|
105
|
-
if (!a || !b) return false
|
|
106
|
-
return (
|
|
107
|
-
a.kind.toLowerCase() === b.kind.toLowerCase() &&
|
|
108
|
-
(a.group ?? '') === (b.group ?? '') &&
|
|
109
|
-
(a.namespace ?? '') === (b.namespace ?? '') &&
|
|
110
|
-
a.name === b.name
|
|
111
|
-
)
|
|
112
|
-
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CNPG_SLOT_RETENTION_WARNING_BYTES,
|
|
3
|
+
cnpgFormatLag,
|
|
4
|
+
cnpgHASlotInstance,
|
|
5
|
+
cnpgReplicationTone,
|
|
6
|
+
cnpgSlotRetentionProblem,
|
|
7
|
+
cnpgStandbyNotReceivingProblem,
|
|
8
|
+
cnpgWithProblems,
|
|
9
|
+
formatGrant,
|
|
10
|
+
type CNPGFleetRow,
|
|
11
|
+
type CNPGProblem,
|
|
12
|
+
type CNPGInstanceLive,
|
|
13
|
+
type CNPGReplicationLive,
|
|
14
|
+
type CNPGSlotRetention,
|
|
15
|
+
type CNPGStandbyGap,
|
|
16
|
+
} from '@skyhook-io/k8s-ui'
|
|
17
|
+
import type { CNPGRuntimeResponse } from '../../api/cnpg'
|
|
18
|
+
|
|
19
|
+
/** The instance-manager facts the HA section shows, from a runtime read. */
|
|
20
|
+
export function cnpgInstanceLive(rt: CNPGRuntimeResponse | undefined): CNPGInstanceLive[] | undefined {
|
|
21
|
+
if (!rt || rt.permission.proxy === 'denied') return undefined
|
|
22
|
+
return rt.instances.map((i) => ({
|
|
23
|
+
pod: i.pod,
|
|
24
|
+
state: i.status.state,
|
|
25
|
+
pendingRestart: i.status.pendingRestart,
|
|
26
|
+
pendingRestartForDecrease: i.status.pendingRestartForDecrease,
|
|
27
|
+
roleDetail: i.status.roleDetail,
|
|
28
|
+
instanceManagerVersion: i.status.instanceManagerVersion,
|
|
29
|
+
timeline: i.status.timeline,
|
|
30
|
+
incomplete: i.status.incomplete,
|
|
31
|
+
reason: i.status.error ?? i.status.reason,
|
|
32
|
+
}))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Why the HA section has no instance-manager facts at all; undefined when it has them. */
|
|
36
|
+
export function cnpgInstanceLiveUnavailable(rt: CNPGRuntimeResponse | undefined, error: unknown): string | undefined {
|
|
37
|
+
if (rt?.permission.proxy === 'denied') return `needs ${formatGrant(rt.permission.grant) ?? 'get pods/proxy'}`
|
|
38
|
+
if (rt) return undefined
|
|
39
|
+
return error instanceof Error ? `the runtime read failed: ${error.message}` : 'instance managers not read yet'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Why cnpgReplicationLive has no answer; undefined when it has one. */
|
|
43
|
+
export function cnpgReplicationGap(rt: CNPGRuntimeResponse | undefined, error: unknown): string | undefined {
|
|
44
|
+
const unavailable = cnpgInstanceLiveUnavailable(rt, error)
|
|
45
|
+
if (unavailable) return unavailable
|
|
46
|
+
const primary = rt?.instances.find((i) => i.role === 'primary')
|
|
47
|
+
if (!primary) return 'no instance reports being the primary'
|
|
48
|
+
if (primary.status.state !== 'ok' && primary.status.state !== 'partial') {
|
|
49
|
+
return `${primary.pod} did not report (${primary.status.error ?? primary.status.reason ?? primary.status.state})`
|
|
50
|
+
}
|
|
51
|
+
if (!primary.status.replication) return `${primary.pod} did not report its replication rows`
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Streaming standbys and the worst replay lag, from the primary's pg_stat_replication. */
|
|
56
|
+
export function cnpgReplicationLive(rt: CNPGRuntimeResponse | undefined): CNPGReplicationLive | undefined {
|
|
57
|
+
const primary = rt?.instances.find((i) => i.role === 'primary')
|
|
58
|
+
if (!primary || (primary.status.state !== 'ok' && primary.status.state !== 'partial')) return undefined
|
|
59
|
+
const reps = primary.status.replication
|
|
60
|
+
if (!reps) return undefined
|
|
61
|
+
const lags = reps.map((r) => r.replayLag).filter((v): v is number => v !== undefined)
|
|
62
|
+
return {
|
|
63
|
+
streaming: reps.filter((r) => r.state === 'streaming').length,
|
|
64
|
+
standbys: rt!.instances.filter((i) => i.role !== 'primary').length,
|
|
65
|
+
maxReplayLagSeconds: lags.length ? Math.max(...lags) : undefined,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const LIVE_STANDBY_SOURCE = 'The primary’s pg_stat_replication and each standby’s /pg/status, read through the instance manager'
|
|
70
|
+
|
|
71
|
+
/** Instances named in the Cluster's cnpg.io/fencedInstances annotation; '*' fences all. */
|
|
72
|
+
function fencedInstances(cluster: any): { all: boolean; pods: Set<string> } {
|
|
73
|
+
const raw = cluster?.metadata?.annotations?.['cnpg.io/fencedInstances']
|
|
74
|
+
try {
|
|
75
|
+
const list = raw ? JSON.parse(raw) : []
|
|
76
|
+
if (!Array.isArray(list)) return { all: false, pods: new Set() }
|
|
77
|
+
return { all: list.includes('*'), pods: new Set(list.filter((x: unknown): x is string => typeof x === 'string')) }
|
|
78
|
+
} catch {
|
|
79
|
+
return { all: false, pods: new Set() }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Standbys the live read shows receiving nothing: no row in the primary's
|
|
85
|
+
* pg_stat_replication although the standby itself answered. A fenced standby
|
|
86
|
+
* is stopped on purpose and one running pg_rewind is rejoining, so neither is
|
|
87
|
+
* a gap.
|
|
88
|
+
*/
|
|
89
|
+
export function cnpgLiveStandbyGaps(row: CNPGFleetRow, rt: CNPGRuntimeResponse | undefined): CNPGStandbyGap[] {
|
|
90
|
+
const primary = rt?.instances.find((i) => i.role === 'primary')
|
|
91
|
+
const reps = primary?.status.state === 'ok' ? primary.status.replication : undefined
|
|
92
|
+
if (!rt || !primary || !reps || row.hibernated) return []
|
|
93
|
+
const fenced = fencedInstances(row.cluster)
|
|
94
|
+
if (fenced.all) return []
|
|
95
|
+
const connected = new Set(reps.map((r) => r.applicationName))
|
|
96
|
+
const standbys = rt.instances.filter((i) => i.role !== 'primary')
|
|
97
|
+
const gaps = standbys.filter(
|
|
98
|
+
(i) => !connected.has(i.pod) && !fenced.pods.has(i.pod) && (i.status.state === 'ok' || i.status.state === 'partial') && i.status.roleDetail !== 'pgRewind',
|
|
99
|
+
)
|
|
100
|
+
const expected = row.instances.desired !== null ? Math.max(0, row.instances.desired - 1) : standbys.length
|
|
101
|
+
return gaps.map((i) => {
|
|
102
|
+
const evidence = ['it has no row in the primary’s pg_stat_replication']
|
|
103
|
+
if (i.status.isWalReceiverActive === false || i.status.roleDetail === 'fileBased') evidence.push('it reports no active WAL receiver')
|
|
104
|
+
if (i.status.replayPaused || i.status.roleDetail === 'replayPaused') evidence.push('its WAL replay is paused')
|
|
105
|
+
if (i.status.timeline !== undefined && primary.status.timeline !== undefined && i.status.timeline !== primary.status.timeline) {
|
|
106
|
+
evidence.push(`it is on timeline ${i.status.timeline} while the primary is on ${primary.status.timeline}`)
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
pod: i.pod,
|
|
110
|
+
evidence,
|
|
111
|
+
measuredBy: 'the instance manager',
|
|
112
|
+
sourceDetail: LIVE_STANDBY_SOURCE,
|
|
113
|
+
noneReceiving: expected > 0 && reps.length === 0,
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Inactive physical slots on the primary that hold at least the warning amount of WAL. */
|
|
119
|
+
export function cnpgLiveSlotRetentions(row: CNPGFleetRow, rt: CNPGRuntimeResponse | undefined): CNPGSlotRetention[] {
|
|
120
|
+
const primary = rt?.instances.find((i) => i.role === 'primary')
|
|
121
|
+
const slots = primary && (primary.status.state === 'ok' || primary.status.state === 'partial') ? primary.status.slots : undefined
|
|
122
|
+
if (!primary || !slots) return []
|
|
123
|
+
const instances = (rt?.instances ?? []).map((i) => i.pod)
|
|
124
|
+
return slots
|
|
125
|
+
.filter((sl): sl is typeof sl & { retainedBytes: number } => sl.active === false && sl.type === 'physical' && sl.retainedBytes !== undefined && sl.retainedBytes >= CNPG_SLOT_RETENTION_WARNING_BYTES)
|
|
126
|
+
.map((sl) => ({
|
|
127
|
+
slot: sl.name,
|
|
128
|
+
pod: primary.pod,
|
|
129
|
+
bytes: sl.retainedBytes,
|
|
130
|
+
standby: cnpgHASlotInstance(row.cluster, sl.name, instances),
|
|
131
|
+
measuredBy: 'the instance manager',
|
|
132
|
+
sourceDetail: 'The primary’s /pg/status replication slots (active) and the exporter’s retained WAL',
|
|
133
|
+
}))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The fleet row with what the instance managers report: the live replication
|
|
138
|
+
* fact, standbys that receive nothing, and inactive slots. A complete live read
|
|
139
|
+
* replaces the fleet's (Prometheus) answer for what it covers, clearing a
|
|
140
|
+
* problem it disproves — a standby now connected, a slot no longer inactive.
|
|
141
|
+
*/
|
|
142
|
+
export function withLiveReplication(row: CNPGFleetRow, rt: CNPGRuntimeResponse | undefined): CNPGFleetRow {
|
|
143
|
+
return withLiveSlots(withLiveStandbys(row, rt), rt)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Replaces the Kubernetes-only replication fact with the primary's
|
|
147
|
+
// pg_stat_replication when it has been read; otherwise keeps "lag unknown".
|
|
148
|
+
// Expected standbys are spec.instances − 1: a standby whose Pod is gone is
|
|
149
|
+
// missing, not absent from the count.
|
|
150
|
+
function withLiveStandbys(row: CNPGFleetRow, rt: CNPGRuntimeResponse | undefined): CNPGFleetRow {
|
|
151
|
+
const primary = rt?.instances.find((i) => i.role === 'primary')
|
|
152
|
+
if (!primary || primary.status.state !== 'ok' || row.instances.desired === 1 || row.hibernated) return row
|
|
153
|
+
const reps = primary.status.replication
|
|
154
|
+
if (!reps) return row
|
|
155
|
+
const streaming = reps.filter((r) => r.state === 'streaming').length
|
|
156
|
+
const lags = reps.map((r) => r.replayLag).filter((v): v is number => v !== undefined)
|
|
157
|
+
const maxLag = lags.length ? Math.max(...lags) : undefined
|
|
158
|
+
const source = 'From the primary’s pg_stat_replication via the instance manager'
|
|
159
|
+
// A standby that isn't connected has no row, so its delay isn't in the max.
|
|
160
|
+
const lagFor = (allConnected: boolean) =>
|
|
161
|
+
maxLag === undefined ? '' : ` · max replay delay ${cnpgFormatLag(maxLag)}${allConnected ? '' : ' (connected standbys only)'}`
|
|
162
|
+
const gaps = cnpgLiveStandbyGaps(row, rt)
|
|
163
|
+
const connected = new Set(reps.map((r) => r.applicationName))
|
|
164
|
+
// The fleet's standby problem for one the primary now streams to is disproven.
|
|
165
|
+
const disproven = (p: CNPGProblem) => p.reason === 'CNPGStandbyNotReceiving' && connected.has(p.subject.name)
|
|
166
|
+
const raised = gaps.map((g) => cnpgStandbyNotReceivingProblem(row, g))
|
|
167
|
+
if (row.instances.desired === null) {
|
|
168
|
+
const live = { ...row, replication: { text: `${streaming} streaming${lagFor(false)}`, tone: 'unknown' as const, source: `${source}; spec.instances is not reported, so the expected standbys are unknown`, at: primary.status.capturedAt } }
|
|
169
|
+
return cnpgWithProblems(live, raised, disproven)
|
|
170
|
+
}
|
|
171
|
+
const expected = Math.max(0, row.instances.desired - 1)
|
|
172
|
+
const lagText = lagFor(reps.length >= expected)
|
|
173
|
+
const tone = cnpgReplicationTone(streaming, expected, maxLag)
|
|
174
|
+
const text = streaming < expected ? `${streaming} of ${expected} expected standbys streaming` : `${streaming}/${expected} streaming`
|
|
175
|
+
const missing = gaps.length > 0 ? ` · ${gaps.map((g) => g.pod).join(', ')} not connected` : ''
|
|
176
|
+
const live = { ...row, replication: { text: `${text}${missing}${lagText}`, tone, source, at: primary.status.capturedAt } }
|
|
177
|
+
return cnpgWithProblems(live, raised, disproven)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Slots are judged from the primary's own slot list whenever it was read,
|
|
181
|
+
// whatever the replication rows say. A fleet warning is cleared only by
|
|
182
|
+
// evidence against it: the slot active, measured below the threshold, or
|
|
183
|
+
// absent from a complete list. An unmeasured slot or a capped, partial list
|
|
184
|
+
// leaves it standing.
|
|
185
|
+
function withLiveSlots(row: CNPGFleetRow, rt: CNPGRuntimeResponse | undefined): CNPGFleetRow {
|
|
186
|
+
const primary = rt?.instances.find((i) => i.role === 'primary')
|
|
187
|
+
const slots = primary && (primary.status.state === 'ok' || primary.status.state === 'partial') ? primary.status.slots : undefined
|
|
188
|
+
if (!primary || !slots) return row
|
|
189
|
+
const complete = primary.status.state === 'ok'
|
|
190
|
+
const byName = new Map(slots.map((sl) => [sl.name, sl]))
|
|
191
|
+
const disproven = (p: CNPGProblem) => {
|
|
192
|
+
if (p.reason !== 'CNPGInactiveSlot' || !p.slot) return false
|
|
193
|
+
const sl = byName.get(p.slot)
|
|
194
|
+
if (!sl) return complete
|
|
195
|
+
return sl.active === true || (sl.retainedBytes !== undefined && sl.retainedBytes < CNPG_SLOT_RETENTION_WARNING_BYTES)
|
|
196
|
+
}
|
|
197
|
+
return cnpgWithProblems(row, cnpgLiveSlotRetentions(row, rt).map((r) => cnpgSlotRetentionProblem(row, r)), disproven)
|
|
198
|
+
}
|