@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,499 @@
|
|
|
1
|
+
import type { CNPGClusterHA } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import type { CNPGClusterFacts, CNPGRuntimeResponse } from '../../../api/cnpg'
|
|
3
|
+
import { formatGrant } from '@skyhook-io/k8s-ui'
|
|
4
|
+
|
|
5
|
+
// Follow-through for CloudNativePG actions. A successful POST only means the
|
|
6
|
+
// write was accepted; the operator acts on it later. A tracked operation is
|
|
7
|
+
// bound to the Radar API base, context, Cluster UID and target it was
|
|
8
|
+
// requested for, carries a baseline taken before the write, and is judged by
|
|
9
|
+
// a per-kind observer against what the cluster reports now. Missing telemetry
|
|
10
|
+
// is "unobservable", never "stalled": stalled needs evidence that nothing is
|
|
11
|
+
// moving.
|
|
12
|
+
|
|
13
|
+
export type CNPGOpState = 'requested' | 'observed' | 'progressing' | 'completed' | 'failed' | 'stalled' | 'superseded' | 'unobservable'
|
|
14
|
+
|
|
15
|
+
export const CNPG_OP_TERMINAL: ReadonlySet<CNPGOpState> = new Set(['completed', 'failed', 'superseded'])
|
|
16
|
+
|
|
17
|
+
export interface CNPGOpStep {
|
|
18
|
+
label: string
|
|
19
|
+
/** true done, false not yet, null not observable with the caller's access. */
|
|
20
|
+
done: boolean | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CNPGTrackedOperation {
|
|
24
|
+
id: string
|
|
25
|
+
kind: string
|
|
26
|
+
label: string
|
|
27
|
+
/**
|
|
28
|
+
* The Radar API base the request went to. Every in-cluster Radar reports
|
|
29
|
+
* the context "in-cluster", so in Radar Hub this is what tells two
|
|
30
|
+
* clusters apart.
|
|
31
|
+
*/
|
|
32
|
+
apiBase: string
|
|
33
|
+
context: string
|
|
34
|
+
namespace: string
|
|
35
|
+
cluster: string
|
|
36
|
+
/** Absent when the requester could not read the Cluster identity. */
|
|
37
|
+
clusterUID?: string
|
|
38
|
+
target?: { name: string; uid?: string }
|
|
39
|
+
startedAt: number
|
|
40
|
+
baseline: Record<string, unknown>
|
|
41
|
+
state: CNPGOpState
|
|
42
|
+
detail?: string
|
|
43
|
+
steps?: CNPGOpStep[]
|
|
44
|
+
/** When the observer last saw its progress key change. */
|
|
45
|
+
lastProgressAt: number
|
|
46
|
+
/** Last assessment in this tab, including an assessment with unavailable evidence. */
|
|
47
|
+
lastCheckedAt?: number
|
|
48
|
+
progressKey?: string
|
|
49
|
+
/** A related object to open, e.g. the Backup. */
|
|
50
|
+
link?: { kind: string; group: string; name: string }
|
|
51
|
+
finishedAt?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Everything an observer may look at. Any source can be missing. */
|
|
55
|
+
export interface CNPGObservation {
|
|
56
|
+
now: number
|
|
57
|
+
/** The Cluster's UID now, from whichever source read it. */
|
|
58
|
+
clusterUID?: string
|
|
59
|
+
/** The first live identity check after mounting is still in flight. */
|
|
60
|
+
identityPending?: boolean
|
|
61
|
+
/** The latest fetch of a source polled for the Cluster identity failed. */
|
|
62
|
+
identityReadFailed?: boolean
|
|
63
|
+
facts?: CNPGClusterFacts
|
|
64
|
+
/** The Cluster object from the workspace (status.conditions, readyInstances). */
|
|
65
|
+
cluster?: any
|
|
66
|
+
ha?: CNPGClusterHA
|
|
67
|
+
runtime?: CNPGRuntimeResponse
|
|
68
|
+
/** Backups of the namespace, or undefined when they are not readable. */
|
|
69
|
+
backups?: any[]
|
|
70
|
+
/**
|
|
71
|
+
* When each source was last fetched successfully and whether its latest
|
|
72
|
+
* fetch failed. A source without a record here, not fetched successfully
|
|
73
|
+
* since the operation started, or whose refresh failed is withheld from
|
|
74
|
+
* its observer. Cluster-scoped sources also carry the UID they read, so
|
|
75
|
+
* facts from a replaced Cluster cannot certify the operation's outcome.
|
|
76
|
+
*/
|
|
77
|
+
freshness?: Partial<Record<CNPGObservedSource, { updatedAt: number; failed: boolean; clusterUID?: string }>>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type CNPGObservedSource = 'clusterUID' | 'facts' | 'cluster' | 'ha' | 'runtime' | 'backups'
|
|
81
|
+
|
|
82
|
+
/** The observation with every stale or failed source removed. */
|
|
83
|
+
export function freshObservation(op: CNPGTrackedOperation, obs: CNPGObservation): CNPGObservation {
|
|
84
|
+
const out: CNPGObservation = { ...obs }
|
|
85
|
+
for (const source of CNPG_OBSERVED_SOURCES) {
|
|
86
|
+
const f = obs.freshness?.[source]
|
|
87
|
+
const differentCluster = op.clusterUID && source !== 'clusterUID' && source !== 'backups' && f?.clusterUID !== op.clusterUID
|
|
88
|
+
if (!f || f.failed || f.updatedAt < op.startedAt || differentCluster) out[source] = undefined
|
|
89
|
+
}
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const CNPG_OBSERVED_SOURCES: readonly CNPGObservedSource[] = ['clusterUID', 'facts', 'cluster', 'ha', 'runtime', 'backups']
|
|
94
|
+
|
|
95
|
+
export interface CNPGObserverResult {
|
|
96
|
+
state: Exclude<CNPGOpState, 'superseded' | 'stalled'>
|
|
97
|
+
detail?: string
|
|
98
|
+
steps?: CNPGOpStep[]
|
|
99
|
+
/** Changes whenever the operation moves; drives stall detection. */
|
|
100
|
+
progressKey?: string
|
|
101
|
+
/** Nothing will ever report more: stop following now. */
|
|
102
|
+
final?: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type CNPGOperationObserver = (op: CNPGTrackedOperation, obs: CNPGObservation) => CNPGObserverResult
|
|
106
|
+
|
|
107
|
+
const observers = new Map<string, CNPGOperationObserver>()
|
|
108
|
+
|
|
109
|
+
/** Plug in the observer for a new operation kind. */
|
|
110
|
+
export function registerCNPGOperationObserver(kind: string, observer: CNPGOperationObserver) {
|
|
111
|
+
observers.set(kind, observer)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** No progress for this long, with telemetry present, reads as stalled. */
|
|
115
|
+
export const CNPG_OP_STALL_MS = 10 * 60_000
|
|
116
|
+
|
|
117
|
+
/** An operation Radar cannot observe is followed this long, then left alone. */
|
|
118
|
+
export const CNPG_OP_UNOBSERVABLE_MS = 15 * 60_000
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Just after a request the sources an observer needs may not have been re-read
|
|
122
|
+
* yet; until one fails, or this long has passed, that is waiting, not
|
|
123
|
+
* "cannot be followed".
|
|
124
|
+
*/
|
|
125
|
+
export const CNPG_OP_FIRST_READ_MS = 20_000
|
|
126
|
+
|
|
127
|
+
/** Whether the tracker should still poll for this operation. */
|
|
128
|
+
export function cnpgOperationFollowed(op: CNPGTrackedOperation): boolean {
|
|
129
|
+
return !CNPG_OP_TERMINAL.has(op.state) && !op.finishedAt
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Advance one operation against an observation. Pure. */
|
|
133
|
+
export function advanceCNPGOperation(op: CNPGTrackedOperation, obs: CNPGObservation): CNPGTrackedOperation {
|
|
134
|
+
if (CNPG_OP_TERMINAL.has(op.state)) return op
|
|
135
|
+
op = { ...op, lastCheckedAt: obs.now }
|
|
136
|
+
const current = freshObservation(op, obs)
|
|
137
|
+
if (op.clusterUID && current.clusterUID && current.clusterUID !== op.clusterUID) {
|
|
138
|
+
return { ...op, state: 'superseded', detail: 'The Cluster was deleted and recreated; this operation no longer applies to it', finishedAt: obs.now }
|
|
139
|
+
}
|
|
140
|
+
if (op.clusterUID && !current.clusterUID) {
|
|
141
|
+
const identity = obs.freshness?.clusterUID
|
|
142
|
+
const expired = obs.now - op.startedAt > CNPG_OP_UNOBSERVABLE_MS
|
|
143
|
+
const unread = !identity || identity.updatedAt < op.startedAt
|
|
144
|
+
// An identity from before the request cannot hold "waiting" while the
|
|
145
|
+
// reads that would replace it fail.
|
|
146
|
+
const failed = !!identity?.failed || (unread && !!obs.identityReadFailed)
|
|
147
|
+
if (!expired && !failed && unread) {
|
|
148
|
+
return { ...op, state: 'requested', detail: 'Waiting for the first read since the request' }
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...op,
|
|
152
|
+
state: 'unobservable',
|
|
153
|
+
detail: 'The Cluster identity has not been verified since this operation was requested',
|
|
154
|
+
finishedAt: !obs.identityPending && expired ? obs.now : undefined,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const observer = observers.get(op.kind)
|
|
158
|
+
if (!observer) return { ...op, state: 'unobservable', detail: 'Radar has no way to follow this operation' }
|
|
159
|
+
const res = observer(op, current)
|
|
160
|
+
const moved = res.progressKey !== undefined && res.progressKey !== op.progressKey
|
|
161
|
+
const lastProgressAt = moved ? obs.now : op.lastProgressAt
|
|
162
|
+
let state: CNPGOpState = res.state
|
|
163
|
+
let detail = res.detail
|
|
164
|
+
if (state === 'unobservable' && !res.final && obs.now - op.startedAt < CNPG_OP_FIRST_READ_MS && !Object.values(obs.freshness ?? {}).some((f) => f?.failed)) {
|
|
165
|
+
state = 'requested'
|
|
166
|
+
detail = 'Waiting for the first read since the request'
|
|
167
|
+
}
|
|
168
|
+
if ((state === 'progressing' || state === 'observed' || state === 'requested') && res.progressKey !== undefined && obs.now - lastProgressAt > CNPG_OP_STALL_MS) {
|
|
169
|
+
state = 'stalled'
|
|
170
|
+
detail = `No progress for ${Math.round((obs.now - lastProgressAt) / 60_000)} min${res.detail ? ` · ${res.detail}` : ''}`
|
|
171
|
+
}
|
|
172
|
+
const givenUp = state === 'unobservable' && (res.final || obs.now - op.startedAt > CNPG_OP_UNOBSERVABLE_MS)
|
|
173
|
+
return {
|
|
174
|
+
...op,
|
|
175
|
+
state,
|
|
176
|
+
detail,
|
|
177
|
+
steps: res.steps ?? op.steps,
|
|
178
|
+
progressKey: res.progressKey ?? op.progressKey,
|
|
179
|
+
lastProgressAt,
|
|
180
|
+
finishedAt: CNPG_OP_TERMINAL.has(state) || givenUp ? obs.now : undefined,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A newer operation of a kind that changes the same thing makes an older,
|
|
186
|
+
* unfinished one moot: its expectation will never be met.
|
|
187
|
+
*/
|
|
188
|
+
const CONFLICTS: Record<string, string[]> = {
|
|
189
|
+
switchover: ['switchover', 'hibernate', 'rehydrate'],
|
|
190
|
+
restart: ['restart', 'hibernate', 'fence'],
|
|
191
|
+
restartInstance: ['hibernate', 'restart', 'fence'],
|
|
192
|
+
fence: ['fence', 'unfence', 'hibernate'],
|
|
193
|
+
unfence: ['fence', 'unfence', 'hibernate'],
|
|
194
|
+
hibernate: ['hibernate', 'rehydrate'],
|
|
195
|
+
rehydrate: ['hibernate', 'rehydrate'],
|
|
196
|
+
setMaintenance: ['setMaintenance', 'unsetMaintenance'],
|
|
197
|
+
unsetMaintenance: ['setMaintenance', 'unsetMaintenance'],
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function supersedeFor(existing: CNPGTrackedOperation[], next: CNPGTrackedOperation): CNPGTrackedOperation[] {
|
|
201
|
+
return existing.map((op) => {
|
|
202
|
+
if (CNPG_OP_TERMINAL.has(op.state)) return op
|
|
203
|
+
if (op.apiBase !== next.apiBase || op.context !== next.context || op.namespace !== next.namespace || op.cluster !== next.cluster) return op
|
|
204
|
+
if (!(CONFLICTS[op.kind] ?? []).includes(next.kind)) return op
|
|
205
|
+
if (op.kind === 'restartInstance' && next.kind === 'fence' && !fencedBy(next, op.target?.name)) return op
|
|
206
|
+
return { ...op, state: 'superseded', detail: `Superseded by ${next.label}`, finishedAt: next.startedAt }
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// A fence of all instances records every instance it fenced.
|
|
211
|
+
function fencedBy(fence: CNPGTrackedOperation, pod: string | undefined): boolean {
|
|
212
|
+
return !!pod && ((fence.baseline.instances as string[] | undefined) ?? []).includes(pod)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Evidence helpers
|
|
217
|
+
|
|
218
|
+
function haInstance(obs: CNPGObservation, pod: string) {
|
|
219
|
+
return obs.ha?.pods.state === 'ok' ? obs.ha.instances.find((i) => i.pod === pod) : undefined
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function runtimeInstance(obs: CNPGObservation, pod: string) {
|
|
223
|
+
return obs.runtime?.instances.find((i) => i.pod === pod)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Whether `pod` restarted after `since`: the Pod was recreated, its postgres
|
|
228
|
+
* container started again, or PostgreSQL's own start time moved (an in-place
|
|
229
|
+
* restart keeps the container). null when none of those is readable.
|
|
230
|
+
*/
|
|
231
|
+
export function restartedSince(obs: CNPGObservation, pod: string, since: number, baselineUID?: string): boolean | null {
|
|
232
|
+
let known = false
|
|
233
|
+
const h = haInstance(obs, pod)
|
|
234
|
+
if (h) {
|
|
235
|
+
known = true
|
|
236
|
+
if (baselineUID && h.podUID !== baselineUID) return true
|
|
237
|
+
if (h.podCreatedAt && Date.parse(h.podCreatedAt) > since) return true
|
|
238
|
+
if (h.postgresStartedAt && Date.parse(h.postgresStartedAt) > since) return true
|
|
239
|
+
} else if (obs.ha?.pods.state === 'ok') {
|
|
240
|
+
known = true
|
|
241
|
+
}
|
|
242
|
+
const r = runtimeInstance(obs, pod)
|
|
243
|
+
const pm = r?.metrics.postmasterStartTime
|
|
244
|
+
if (pm !== undefined) {
|
|
245
|
+
known = true
|
|
246
|
+
if (pm * 1000 > since) return true
|
|
247
|
+
}
|
|
248
|
+
return known ? false : null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readyNow(obs: CNPGObservation, pod: string): boolean | null {
|
|
252
|
+
const h = haInstance(obs, pod)
|
|
253
|
+
if (h) return h.ready
|
|
254
|
+
const f = obs.facts?.instances.find((i) => i.pod === pod)
|
|
255
|
+
return f && f.podReadable ? f.ready : null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Whether `pod` streams from the primary, from its own report or the
|
|
260
|
+
* primary's pg_stat_replication. With the proxy allowed, an instance that does
|
|
261
|
+
* not answer yet (restarting) is "not yet"; without it the answer is unknown.
|
|
262
|
+
*/
|
|
263
|
+
function streamingStandby(obs: CNPGObservation, pod: string): boolean | null {
|
|
264
|
+
if (!obs.runtime || obs.runtime.permission.proxy === 'denied') return null
|
|
265
|
+
const own = runtimeInstance(obs, pod)?.status
|
|
266
|
+
if (own && (own.state === 'ok' || own.state === 'partial') && own.roleDetail) return own.roleDetail === 'streaming'
|
|
267
|
+
const primaryName = obs.facts?.currentPrimary
|
|
268
|
+
const primary = obs.runtime.instances.find((i) => (primaryName ? i.pod === primaryName : i.role === 'primary'))?.status
|
|
269
|
+
if (primary && (primary.state === 'ok' || primary.state === 'partial')) {
|
|
270
|
+
if (!primary.replication) return null
|
|
271
|
+
return primary.replication.some((r) => r.applicationName === pod && r.state === 'streaming')
|
|
272
|
+
}
|
|
273
|
+
return own?.state === 'denied' ? null : false
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function condition(cluster: any, type: string): { status?: string; reason?: string; message?: string } | undefined {
|
|
277
|
+
return (cluster?.status?.conditions ?? []).find((c: any) => c?.type === type)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Completed only when every step is seen done. A step Radar cannot see keeps
|
|
282
|
+
* the operation unobservable rather than completed — or stalled.
|
|
283
|
+
*/
|
|
284
|
+
export function summarizeSteps(steps: CNPGOpStep[]): CNPGObserverResult['state'] {
|
|
285
|
+
const done = steps.filter((s) => s.done === true).length
|
|
286
|
+
const pending = steps.filter((s) => s.done === false).length
|
|
287
|
+
if (done === steps.length) return 'completed'
|
|
288
|
+
if (pending > 0) return done > 0 ? 'progressing' : 'requested'
|
|
289
|
+
return 'unobservable'
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function key(steps: CNPGOpStep[], extra = ''): string {
|
|
293
|
+
return steps.map((s) => (s.done === true ? '1' : s.done === false ? '0' : '?')).join('') + extra
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Built-in observers
|
|
298
|
+
|
|
299
|
+
registerCNPGOperationObserver('switchover', (op, obs) => {
|
|
300
|
+
const target = op.target?.name ?? ''
|
|
301
|
+
const oldPrimary = String(op.baseline.currentPrimary ?? '')
|
|
302
|
+
const f = obs.facts
|
|
303
|
+
if (!f) return { state: 'unobservable', detail: 'The Cluster is not readable' }
|
|
304
|
+
if (f.currentPrimary && f.currentPrimary !== target && f.currentPrimary !== oldPrimary) {
|
|
305
|
+
return { state: 'failed', detail: `${f.currentPrimary} became primary instead of ${target}` }
|
|
306
|
+
}
|
|
307
|
+
const promoted = f.currentPrimary === target
|
|
308
|
+
const requested = promoted || f.targetPrimary === target
|
|
309
|
+
if (!requested && f.targetPrimary === oldPrimary && f.phase === 'Cluster in healthy state' && obs.now - op.startedAt > 60_000) {
|
|
310
|
+
return { state: 'failed', detail: `The operator kept ${oldPrimary} as primary${f.phaseReason ? `: ${f.phaseReason}` : ''}` }
|
|
311
|
+
}
|
|
312
|
+
const rejoined = promoted ? streamingStandby(obs, oldPrimary) : false
|
|
313
|
+
const rw = obs.ha?.rwEndpoints
|
|
314
|
+
const steps: CNPGOpStep[] = [
|
|
315
|
+
{ label: `Operator accepted ${target} as target`, done: requested },
|
|
316
|
+
{ label: `${target} is the primary`, done: promoted },
|
|
317
|
+
{ label: `${oldPrimary} is back as a streaming standby`, done: rejoined },
|
|
318
|
+
]
|
|
319
|
+
// Clients reach the new primary only through the read-write Service; when
|
|
320
|
+
// its endpoints are not readable that step stays unverified, so the
|
|
321
|
+
// switchover can end "unobservable" but never "completed".
|
|
322
|
+
const rwReadable = rw?.state === 'ok'
|
|
323
|
+
steps.push({
|
|
324
|
+
label: rwReadable ? `Read-write Service points at ${target}` : `Read-write Service points at ${target} (endpoints not readable)`,
|
|
325
|
+
done: rwReadable ? promoted && rw.pods.includes(target) && !rw.pods.includes(oldPrimary) : null,
|
|
326
|
+
})
|
|
327
|
+
steps.push({ label: 'Cluster reports a healthy state again', done: promoted && f.phase === 'Cluster in healthy state' })
|
|
328
|
+
let state = summarizeSteps(steps)
|
|
329
|
+
if (requested && !promoted) state = 'observed'
|
|
330
|
+
const rwUnverified = `the read-write Service is unverified${rw?.grant ? ` (needs ${formatGrant(rw.grant)})` : rw?.reason ? ` (${rw.reason})` : ''}`
|
|
331
|
+
const detail =
|
|
332
|
+
rejoined === null && promoted
|
|
333
|
+
? `Primary changed; whether ${oldPrimary} rejoined needs runtime access (get pods/proxy)${rwReadable ? '' : `; ${rwUnverified}`}`
|
|
334
|
+
: f.phase && f.phase !== 'Cluster in healthy state'
|
|
335
|
+
? f.phase + (f.phaseReason ? `: ${f.phaseReason}` : '')
|
|
336
|
+
: !rwReadable && promoted
|
|
337
|
+
? `Primary changed; ${rwUnverified}`
|
|
338
|
+
: undefined
|
|
339
|
+
return { state, steps, detail, progressKey: key(steps, f.phase ?? '') }
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
function restartObserver(podsOf: (op: CNPGTrackedOperation, obs: CNPGObservation) => string[]): CNPGOperationObserver {
|
|
343
|
+
return (op, obs) => {
|
|
344
|
+
const pods = podsOf(op, obs)
|
|
345
|
+
if (pods.length === 0) return { state: 'unobservable', detail: 'No instances to follow' }
|
|
346
|
+
const uids = (op.baseline.podUIDs ?? {}) as Record<string, string>
|
|
347
|
+
const steps: CNPGOpStep[] = pods.map((p) => {
|
|
348
|
+
const restarted = restartedSince(obs, p, op.startedAt, uids[p])
|
|
349
|
+
const ready = restarted ? readyNow(obs, p) : false
|
|
350
|
+
return { label: `${p} restarted and ready`, done: restarted === null ? null : restarted ? ready : false }
|
|
351
|
+
})
|
|
352
|
+
const done = steps.filter((s) => s.done).length
|
|
353
|
+
const state = summarizeSteps(steps)
|
|
354
|
+
const detail =
|
|
355
|
+
state === 'unobservable'
|
|
356
|
+
? 'Restart or readiness evidence is not readable with your access'
|
|
357
|
+
: `${done} of ${pods.length} restarted${obs.facts?.phase && obs.facts.phase !== 'Cluster in healthy state' ? ` · ${obs.facts.phase}` : ''}`
|
|
358
|
+
return { state, steps, detail, progressKey: key(steps) }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
registerCNPGOperationObserver(
|
|
363
|
+
'restart',
|
|
364
|
+
restartObserver((op) => {
|
|
365
|
+
const fenced = new Set((op.baseline.fenced as string[] | undefined) ?? [])
|
|
366
|
+
return ((op.baseline.instances as string[]) ?? []).filter((p) => !fenced.has(p))
|
|
367
|
+
}),
|
|
368
|
+
)
|
|
369
|
+
registerCNPGOperationObserver(
|
|
370
|
+
'restartInstance',
|
|
371
|
+
restartObserver((op) => (op.target ? [op.target.name] : [])),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
registerCNPGOperationObserver('destroyInstance', (op, obs) => {
|
|
375
|
+
const f = obs.facts
|
|
376
|
+
if (!f) return { state: 'unobservable', detail: 'The Cluster is not readable' }
|
|
377
|
+
const destroyed = op.target?.name ?? ''
|
|
378
|
+
const before = new Set((op.baseline.instances as string[]) ?? [])
|
|
379
|
+
const gone = !f.instances.some((i) => i.pod === destroyed && (!op.target?.uid || i.podUID === op.target.uid))
|
|
380
|
+
const replacement = f.instances.find((i) => !before.has(i.pod))
|
|
381
|
+
const replacementReady = replacement ? readyNow(obs, replacement.pod) : false
|
|
382
|
+
const steps: CNPGOpStep[] = [
|
|
383
|
+
{ label: `${destroyed} removed`, done: gone },
|
|
384
|
+
{ label: replacement ? `Replacement ${replacement.pod} ready` : 'Operator created a replacement instance', done: replacement ? replacementReady : false },
|
|
385
|
+
{
|
|
386
|
+
label: replacement ? `${replacement.pod} streaming from the primary` : 'Replacement streaming from the primary',
|
|
387
|
+
done: !replacement ? false : replacementReady === null ? null : replacementReady ? streamingStandby(obs, replacement.pod) : false,
|
|
388
|
+
},
|
|
389
|
+
{ label: 'Cluster reports a healthy state again', done: gone && !!replacement && f.phase === 'Cluster in healthy state' },
|
|
390
|
+
]
|
|
391
|
+
const detail = f.phase && f.phase !== 'Cluster in healthy state' ? f.phase + (f.phaseReason ? `: ${f.phaseReason}` : '') : undefined
|
|
392
|
+
return { state: summarizeSteps(steps), steps, detail, progressKey: key(steps, `${replacement?.pod ?? ''}${f.phase ?? ''}`) }
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
registerCNPGOperationObserver('reload', () => ({
|
|
396
|
+
state: 'unobservable',
|
|
397
|
+
final: true,
|
|
398
|
+
detail: 'Requested. Nothing in the cluster reports when a configuration reload completes; check the instance logs for "received SIGHUP".',
|
|
399
|
+
}))
|
|
400
|
+
|
|
401
|
+
// CloudNativePG reports no shutdown signal for a fenced instance: /pg/status
|
|
402
|
+
// never carries isFenced, and a masked status error (mightBeUnavailable) also
|
|
403
|
+
// arrives without WAL positions. So the fence being recorded is the last thing
|
|
404
|
+
// Radar can verify; the shutdown itself stays unverified.
|
|
405
|
+
registerCNPGOperationObserver('fence', (op, obs) => {
|
|
406
|
+
const pods = ((op.baseline.instances as string[]) ?? [])
|
|
407
|
+
const f = obs.facts
|
|
408
|
+
const recorded = (p: string): boolean | null => (f ? f.fencedInstances.all || f.fencedInstances.instances.includes(p) : null)
|
|
409
|
+
const steps: CNPGOpStep[] = pods.flatMap((p) => [
|
|
410
|
+
{ label: `${p}: fence recorded on the Cluster`, done: recorded(p) },
|
|
411
|
+
{ label: `${p}: PostgreSQL stopped (CloudNativePG reports no signal for this)`, done: null },
|
|
412
|
+
])
|
|
413
|
+
const allRecorded = pods.length > 0 && pods.every((p) => recorded(p) === true)
|
|
414
|
+
return {
|
|
415
|
+
state: allRecorded ? 'unobservable' : summarizeSteps(steps.filter((_, i) => i % 2 === 0)),
|
|
416
|
+
steps,
|
|
417
|
+
final: allRecorded,
|
|
418
|
+
detail: allRecorded
|
|
419
|
+
? 'Fence recorded. The operator stops PostgreSQL on a fenced instance, but nothing in the cluster confirms the shutdown; the instance logs show it.'
|
|
420
|
+
: 'Waiting for the fence to be recorded on the Cluster',
|
|
421
|
+
progressKey: key(steps),
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
registerCNPGOperationObserver('unfence', (op, obs) => {
|
|
426
|
+
const pods = ((op.baseline.instances as string[]) ?? [])
|
|
427
|
+
const primary = obs.facts?.currentPrimary
|
|
428
|
+
const steps: CNPGOpStep[] = pods.flatMap((p) => {
|
|
429
|
+
const ready = readyNow(obs, p)
|
|
430
|
+
const out: CNPGOpStep[] = [{ label: `${p}: ready again`, done: ready }]
|
|
431
|
+
if (p !== primary) out.push({ label: `${p}: streaming from the primary`, done: ready === null ? null : ready ? streamingStandby(obs, p) : false })
|
|
432
|
+
return out
|
|
433
|
+
})
|
|
434
|
+
return { state: summarizeSteps(steps), steps, progressKey: key(steps) }
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
registerCNPGOperationObserver('hibernate', (_op, obs) => {
|
|
438
|
+
const c = condition(obs.cluster, 'cnpg.io/hibernation')
|
|
439
|
+
if (!obs.cluster) return { state: 'unobservable', detail: 'The Cluster is not readable' }
|
|
440
|
+
const steps: CNPGOpStep[] = [
|
|
441
|
+
{ label: 'Hibernation condition true', done: c?.status === 'True' },
|
|
442
|
+
{ label: 'Instance Pods removed', done: obs.ha?.pods.state === 'ok' ? obs.ha.instances.length === 0 : null },
|
|
443
|
+
]
|
|
444
|
+
return { state: summarizeSteps(steps), steps, detail: c?.message, progressKey: key(steps, c?.reason ?? '') }
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
registerCNPGOperationObserver('rehydrate', (_op, obs) => {
|
|
448
|
+
const c = condition(obs.cluster, 'cnpg.io/hibernation')
|
|
449
|
+
if (!obs.cluster) return { state: 'unobservable', detail: 'The Cluster is not readable' }
|
|
450
|
+
const desired = obs.cluster?.spec?.instances
|
|
451
|
+
const ready = obs.cluster?.status?.readyInstances ?? 0
|
|
452
|
+
const steps: CNPGOpStep[] = [
|
|
453
|
+
{ label: 'Hibernation condition cleared', done: !c || c.status !== 'True' },
|
|
454
|
+
{ label: `All ${desired ?? ''} instances ready`.replace(' ', ' '), done: typeof desired === 'number' ? ready >= desired : null },
|
|
455
|
+
]
|
|
456
|
+
return { state: summarizeSteps(steps), steps, detail: `${ready}/${desired ?? '?'} ready`, progressKey: key(steps, String(ready)) }
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
function backupObserver(op: CNPGTrackedOperation, obs: CNPGObservation): CNPGObserverResult {
|
|
460
|
+
if (!op.clusterUID) return { state: 'unobservable', detail: 'The source Cluster identity was not recorded for this backup' }
|
|
461
|
+
const name = op.target?.name
|
|
462
|
+
if (!name) return { state: 'unobservable', detail: 'No Backup name recorded' }
|
|
463
|
+
if (!obs.backups) return { state: 'unobservable', detail: 'Backups are not readable with your access' }
|
|
464
|
+
const b = obs.backups.find((x) => x?.metadata?.name === name && x?.metadata?.namespace === op.namespace)
|
|
465
|
+
if (!b) {
|
|
466
|
+
return obs.now - op.startedAt > 60_000
|
|
467
|
+
? { state: 'unobservable', detail: `Backup ${name} is not visible (deleted, or outside the backups Radar keeps)`, progressKey: 'absent' }
|
|
468
|
+
: { state: 'requested', detail: `Waiting for Backup ${name} to appear`, progressKey: 'absent' }
|
|
469
|
+
}
|
|
470
|
+
const phase: string | undefined = b.status?.phase
|
|
471
|
+
switch (phase) {
|
|
472
|
+
case 'completed':
|
|
473
|
+
return { state: 'completed', detail: `Backup ${name} completed` }
|
|
474
|
+
case 'failed':
|
|
475
|
+
case 'walArchivingFailing':
|
|
476
|
+
return { state: 'failed', detail: `Backup ${name} ${phase}${b.status?.error ? `: ${b.status.error}` : ''}` }
|
|
477
|
+
case undefined:
|
|
478
|
+
case '':
|
|
479
|
+
return { state: 'observed', detail: `Backup ${name} created; the operator has not picked it up yet`, progressKey: 'new' }
|
|
480
|
+
default:
|
|
481
|
+
// The phase holds for as long as the data takes to copy, so an
|
|
482
|
+
// unchanged phase is no evidence of a stall.
|
|
483
|
+
return { state: 'progressing', detail: `Backup ${name}: ${phase}` }
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
registerCNPGOperationObserver('backup', backupObserver)
|
|
487
|
+
registerCNPGOperationObserver('run', backupObserver)
|
|
488
|
+
|
|
489
|
+
function maintenanceObserver(want: boolean): CNPGOperationObserver {
|
|
490
|
+
return (_op, obs) => {
|
|
491
|
+
const m = obs.facts?.maintenance ?? obs.ha?.maintenance
|
|
492
|
+
if (!m) return { state: 'unobservable', detail: 'The Cluster is not readable' }
|
|
493
|
+
return m.inProgress === want
|
|
494
|
+
? { state: 'completed', detail: want ? 'Maintenance window is in progress' : 'Maintenance window lifted' }
|
|
495
|
+
: { state: 'requested', detail: 'Waiting for the Cluster spec to reflect it', progressKey: String(m.inProgress) }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
registerCNPGOperationObserver('setMaintenance', maintenanceObserver(true))
|
|
499
|
+
registerCNPGOperationObserver('unsetMaintenance', maintenanceObserver(false))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { advanceCNPGOperation, type CNPGTrackedOperation } from './model'
|
|
3
|
+
import { cnpgOperationHandoff, cnpgOperationsForCluster, latestCNPGOperation } from './presentation'
|
|
4
|
+
|
|
5
|
+
const operation: CNPGTrackedOperation = {
|
|
6
|
+
id: 'local',
|
|
7
|
+
kind: 'reload',
|
|
8
|
+
label: 'Reload configuration',
|
|
9
|
+
apiBase: '/api',
|
|
10
|
+
context: 'kind-db',
|
|
11
|
+
namespace: 'db',
|
|
12
|
+
cluster: 'pg',
|
|
13
|
+
clusterUID: 'uid',
|
|
14
|
+
startedAt: Date.parse('2026-10-07T00:00:00Z'),
|
|
15
|
+
lastProgressAt: 0,
|
|
16
|
+
state: 'requested',
|
|
17
|
+
baseline: { secret: 'must not be copied' },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('local operation presentation', () => {
|
|
21
|
+
it('keeps completed predecessor operations out of the header, count and handoff list', () => {
|
|
22
|
+
const completed = { ...operation, state: 'completed' as const, finishedAt: operation.startedAt + 1000 }
|
|
23
|
+
const subject = { apiBase: '/api', context: 'kind-db', namespace: 'db', name: 'pg', uid: 'replacement' }
|
|
24
|
+
expect(cnpgOperationsForCluster([completed], subject)).toEqual([])
|
|
25
|
+
const current = { ...completed, id: 'replacement-restore', clusterUID: 'replacement', startedAt: completed.startedAt + 2000 }
|
|
26
|
+
expect(cnpgOperationsForCluster([completed, current], subject)).toEqual([current])
|
|
27
|
+
expect(latestCNPGOperation([completed, current], subject)).toBe(current)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('never joins a record across contexts, namespaces or a recreated Cluster', () => {
|
|
31
|
+
const subject = { apiBase: '/api', context: 'kind-db', namespace: 'db', name: 'pg', uid: 'uid' }
|
|
32
|
+
expect(latestCNPGOperation([operation], subject)).toBe(operation)
|
|
33
|
+
for (const changed of [{ apiBase: '/c/other/api' }, { context: 'prod' }, { namespace: 'elsewhere' }, { uid: 'new-uid' }]) {
|
|
34
|
+
expect(latestCNPGOperation([operation], { ...subject, ...changed })).toBeUndefined()
|
|
35
|
+
}
|
|
36
|
+
expect(
|
|
37
|
+
latestCNPGOperation([operation, { ...operation, id: 'newer', startedAt: operation.startedAt + 1000 }], subject)
|
|
38
|
+
?.id,
|
|
39
|
+
).toBe('newer')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('keeps Radar Hub clusters apart although every in-cluster Radar reports the same context', () => {
|
|
43
|
+
const hub = { ...operation, context: 'in-cluster', apiBase: '/c/east/api' }
|
|
44
|
+
const west = { apiBase: '/c/west/api', context: 'in-cluster', namespace: 'db', name: 'pg', uid: 'uid' }
|
|
45
|
+
expect(cnpgOperationsForCluster([hub], west)).toEqual([])
|
|
46
|
+
expect(cnpgOperationsForCluster([hub], { ...west, apiBase: '/c/east/api' })).toEqual([hub])
|
|
47
|
+
const unstamped = { ...hub } as Partial<CNPGTrackedOperation>
|
|
48
|
+
delete unstamped.apiBase
|
|
49
|
+
expect(cnpgOperationsForCluster([unstamped as CNPGTrackedOperation], { ...west, apiBase: '/c/east/api' })).toEqual([])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('records a check even when the result is unobservable; terminal records retain their assessment time', () => {
|
|
53
|
+
const checked = advanceCNPGOperation(operation, { now: operation.startedAt + 1000, freshness: { clusterUID: { updatedAt: 0, failed: true } } })
|
|
54
|
+
expect(checked.state).toBe('unobservable')
|
|
55
|
+
expect(checked.lastCheckedAt).toBe(operation.startedAt + 1000)
|
|
56
|
+
const terminal = { ...checked, state: 'completed' as const }
|
|
57
|
+
expect(advanceCNPGOperation(terminal, { now: operation.startedAt + 2000 })).toBe(terminal)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('copies verification intent and a pinned live link without the private baseline', () => {
|
|
61
|
+
const text = cnpgOperationHandoff(
|
|
62
|
+
{
|
|
63
|
+
...operation,
|
|
64
|
+
steps: [
|
|
65
|
+
{ label: 'Verify uploads', done: null },
|
|
66
|
+
{ label: 'Backup finished', done: false },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
'https://radar.example/c/db/cnpg/clusters/db/pg?ctx=kind-db',
|
|
70
|
+
)
|
|
71
|
+
expect(text).toContain('Context: kind-db')
|
|
72
|
+
expect(text).toContain('Cluster UID: uid')
|
|
73
|
+
expect(text).toContain('[unknown] Verify uploads')
|
|
74
|
+
expect(text).toContain('[pending] Backup finished')
|
|
75
|
+
expect(text).toContain('not a shared operation history')
|
|
76
|
+
expect(text).toContain('/c/db/cnpg/clusters/db/pg?ctx=kind-db')
|
|
77
|
+
expect(text).not.toContain('must not be copied')
|
|
78
|
+
expect(text).toContain('not yet checked')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { HealthLevel } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import type { CNPGOpState, CNPGTrackedOperation } from './model'
|
|
3
|
+
|
|
4
|
+
export const CNPG_OP_STATE_TEXT: Record<CNPGOpState, string> = {
|
|
5
|
+
requested: 'requested',
|
|
6
|
+
observed: 'observed by the operator',
|
|
7
|
+
progressing: 'in progress',
|
|
8
|
+
completed: 'completed',
|
|
9
|
+
failed: 'failed',
|
|
10
|
+
stalled: 'stalled',
|
|
11
|
+
superseded: 'superseded',
|
|
12
|
+
unobservable: 'cannot be followed',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const CNPG_OP_STATE_TONE: Record<CNPGOpState, HealthLevel> = {
|
|
16
|
+
requested: 'neutral',
|
|
17
|
+
observed: 'neutral',
|
|
18
|
+
progressing: 'neutral',
|
|
19
|
+
completed: 'healthy',
|
|
20
|
+
failed: 'unhealthy',
|
|
21
|
+
stalled: 'degraded',
|
|
22
|
+
superseded: 'unknown',
|
|
23
|
+
unobservable: 'unknown',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const CNPG_OP_STATE_SEVERITY: Record<CNPGOpState, 'success' | 'error' | 'warning' | 'info' | 'neutral'> = {
|
|
27
|
+
requested: 'info',
|
|
28
|
+
observed: 'info',
|
|
29
|
+
progressing: 'info',
|
|
30
|
+
completed: 'success',
|
|
31
|
+
failed: 'error',
|
|
32
|
+
stalled: 'warning',
|
|
33
|
+
superseded: 'neutral',
|
|
34
|
+
unobservable: 'neutral',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CNPGOperationSubject {
|
|
38
|
+
apiBase: string
|
|
39
|
+
context: string
|
|
40
|
+
namespace: string
|
|
41
|
+
name: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function cnpgOperationsForCluster(
|
|
45
|
+
ops: CNPGTrackedOperation[],
|
|
46
|
+
subject: CNPGOperationSubject & { uid?: string },
|
|
47
|
+
): CNPGTrackedOperation[] {
|
|
48
|
+
return ops.filter(
|
|
49
|
+
(op) =>
|
|
50
|
+
op.apiBase === subject.apiBase &&
|
|
51
|
+
op.context === subject.context &&
|
|
52
|
+
op.namespace === subject.namespace &&
|
|
53
|
+
op.cluster === subject.name &&
|
|
54
|
+
(subject.uid === undefined || !op.clusterUID || op.clusterUID === subject.uid),
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function latestCNPGOperation(
|
|
59
|
+
ops: CNPGTrackedOperation[],
|
|
60
|
+
subject: CNPGOperationSubject & { uid: string },
|
|
61
|
+
): CNPGTrackedOperation | undefined {
|
|
62
|
+
return cnpgOperationsForCluster(ops, subject).reduce<CNPGTrackedOperation | undefined>(
|
|
63
|
+
(latest, op) => (!latest || op.startedAt > latest.startedAt ? op : latest),
|
|
64
|
+
undefined,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function cnpgOperationHandoff(op: CNPGTrackedOperation, liveURL: string): string {
|
|
69
|
+
return [
|
|
70
|
+
`CloudNativePG: ${op.namespace}/${op.cluster}`,
|
|
71
|
+
`Context: ${op.context}`,
|
|
72
|
+
`Cluster UID: ${op.clusterUID || 'not recorded; confirm the live identity'}`,
|
|
73
|
+
`Action: ${op.label}`,
|
|
74
|
+
`Requested: ${new Date(op.startedAt).toISOString()}`,
|
|
75
|
+
`State in this tab: ${CNPG_OP_STATE_TEXT[op.state]}`,
|
|
76
|
+
`Last checked: ${op.lastCheckedAt ? new Date(op.lastCheckedAt).toISOString() : 'not yet checked'}`,
|
|
77
|
+
...(op.detail ? [`Observation: ${op.detail}`] : []),
|
|
78
|
+
...(op.steps?.map(
|
|
79
|
+
(step) => `[${step.done === true ? 'observed' : step.done === false ? 'pending' : 'unknown'}] ${step.label}`,
|
|
80
|
+
) ?? []),
|
|
81
|
+
`Live Cluster: ${liveURL}`,
|
|
82
|
+
'This is a local observation record, not a shared operation history. Open the link to inspect current Kubernetes facts and verify the remaining steps.',
|
|
83
|
+
].join('\n')
|
|
84
|
+
}
|