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