@skyhook-io/radar-app 1.17.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +125 -87
  4. package/src/api/actions.errorCode.test.ts +40 -0
  5. package/src/api/actions.test.ts +48 -0
  6. package/src/api/actions.ts +79 -0
  7. package/src/api/apiResources.navigation.test.tsx +232 -0
  8. package/src/api/apiResources.ts +46 -3
  9. package/src/api/client.helmSource.test.ts +39 -0
  10. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  11. package/src/api/client.namespaceSwitch.test.ts +107 -0
  12. package/src/api/client.ts +266 -39
  13. package/src/api/cnpg-ha.live.test.ts +140 -0
  14. package/src/api/cnpg-ha.test.ts +78 -0
  15. package/src/api/cnpg-ha.ts +19 -0
  16. package/src/api/cnpg-history.ts +123 -0
  17. package/src/api/cnpg-inspect.ts +102 -0
  18. package/src/api/cnpg-protection.ts +112 -0
  19. package/src/api/cnpg-recovery.test.tsx +60 -0
  20. package/src/api/cnpg-recovery.ts +262 -0
  21. package/src/api/cnpg-sessions.ts +175 -0
  22. package/src/api/cnpg-storage.ts +138 -0
  23. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  24. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  25. package/src/api/cnpg.ts +428 -10
  26. package/src/api/radarFeatures.render.test.tsx +47 -3
  27. package/src/api/radarFeatures.ts +4 -1
  28. package/src/api/reviewedActionMutation.test.tsx +164 -0
  29. package/src/api/reviewedActionMutation.ts +66 -0
  30. package/src/api/timelineSource.test.ts +9 -0
  31. package/src/api/timelineSource.ts +8 -0
  32. package/src/components/audit/AuditView.tsx +9 -0
  33. package/src/components/capacity/CapacityActivity.tsx +3 -31
  34. package/src/components/capacity/CapacityDemand.tsx +4 -36
  35. package/src/components/capacity/CapacityOverview.tsx +217 -56
  36. package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
  37. package/src/components/capacity/CapacityView.test.tsx +323 -0
  38. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  39. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  40. package/src/components/capacity/routes.ts +42 -0
  41. package/src/components/capacity/shared.tsx +11 -129
  42. package/src/components/capacity/workspaceRoute.ts +18 -0
  43. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  44. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  45. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  46. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  47. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  48. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  49. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  50. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  51. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  52. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  53. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  54. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  55. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  56. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  57. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  58. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  59. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  60. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  61. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  62. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  63. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  64. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  65. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  66. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  67. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  68. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  69. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  70. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  71. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  72. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  73. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  74. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  75. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  76. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  77. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  78. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  79. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  80. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  81. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  82. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  83. package/src/components/cnpg/CNPGProtection.tsx +228 -122
  84. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  85. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  86. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  87. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  88. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  89. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  90. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  91. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  92. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  93. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  94. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  95. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  96. package/src/components/cnpg/CNPGView.tsx +34 -21
  97. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  98. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  99. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  100. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  101. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  102. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  103. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  104. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  105. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  106. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  107. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  108. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  109. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  110. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  111. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  112. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  113. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  114. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  115. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  116. package/src/components/cnpg/actions/actionModel.ts +127 -0
  117. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  118. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  119. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  120. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  121. package/src/components/cnpg/archivingRepair.ts +145 -0
  122. package/src/components/cnpg/baseBackup.test.ts +36 -0
  123. package/src/components/cnpg/baseBackup.ts +36 -0
  124. package/src/components/cnpg/blocking.test.ts +59 -0
  125. package/src/components/cnpg/blocking.ts +131 -0
  126. package/src/components/cnpg/coverageText.test.ts +27 -0
  127. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  128. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  129. package/src/components/cnpg/creation/targetModel.ts +149 -0
  130. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  131. package/src/components/cnpg/fleetStatus.ts +43 -0
  132. package/src/components/cnpg/formFields.ts +2 -0
  133. package/src/components/cnpg/grantText.test.tsx +11 -0
  134. package/src/components/cnpg/host.test.tsx +24 -0
  135. package/src/components/cnpg/host.tsx +101 -0
  136. package/src/components/cnpg/inspectModel.test.ts +120 -0
  137. package/src/components/cnpg/inspectModel.ts +140 -0
  138. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  139. package/src/components/cnpg/logicalSlots.ts +29 -0
  140. package/src/components/cnpg/lsn.test.ts +40 -0
  141. package/src/components/cnpg/lsn.ts +43 -0
  142. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  143. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  144. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  145. package/src/components/cnpg/operations/model.test.ts +510 -0
  146. package/src/components/cnpg/operations/model.ts +499 -0
  147. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  148. package/src/components/cnpg/operations/presentation.ts +84 -0
  149. package/src/components/cnpg/operations/store.ts +98 -0
  150. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  151. package/src/components/cnpg/operatorStatus.ts +194 -0
  152. package/src/components/cnpg/paths.test.ts +54 -0
  153. package/src/components/cnpg/paths.ts +47 -4
  154. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  155. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  156. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  157. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  158. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  159. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  160. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  161. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  162. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  163. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  164. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  165. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  166. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  167. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  168. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  169. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  172. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  173. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  174. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  175. package/src/components/cnpg/refresh.test.ts +38 -0
  176. package/src/components/cnpg/refresh.ts +17 -0
  177. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  178. package/src/components/cnpg/routes.test.ts +34 -3
  179. package/src/components/cnpg/routes.ts +60 -48
  180. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  181. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  182. package/src/components/cnpg/runtimeModel.ts +196 -0
  183. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  184. package/src/components/cnpg/runtimeParts.tsx +79 -0
  185. package/src/components/cnpg/shared.test.tsx +59 -0
  186. package/src/components/cnpg/shared.tsx +100 -182
  187. package/src/components/cnpg/storageModel.test.ts +107 -0
  188. package/src/components/cnpg/storageModel.ts +101 -0
  189. package/src/components/cnpg/trendSamples.test.ts +148 -0
  190. package/src/components/cnpg/trendSamples.ts +222 -0
  191. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  192. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  193. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  194. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  195. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  196. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  197. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  198. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  199. package/src/components/cnpg/workspaceRoute.ts +23 -0
  200. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  201. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  202. package/src/components/cost/CostView.test.ts +2 -1
  203. package/src/components/cost/CostView.tsx +0 -2
  204. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  205. package/src/components/cost/errors.ts +3 -1
  206. package/src/components/cost/source.test.ts +10 -0
  207. package/src/components/cost/source.ts +5 -1
  208. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  209. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  210. package/src/components/dock/BottomDock.tsx +2 -0
  211. package/src/components/dock/TerminalTab.test.tsx +17 -0
  212. package/src/components/dock/TerminalTab.tsx +7 -3
  213. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  214. package/src/components/gitops/GitOpsView.tsx +16 -23
  215. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  216. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  217. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  218. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  219. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  220. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  221. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  222. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  223. package/src/components/helm/ValuesViewer.tsx +169 -78
  224. package/src/components/home/ActivitySummary.tsx +2 -1
  225. package/src/components/home/HomeView.tsx +20 -9
  226. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  227. package/src/components/home/TopologyPreview.tsx +2 -2
  228. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  229. package/src/components/issues/IssuesPane.test.ts +65 -1
  230. package/src/components/issues/IssuesPane.tsx +188 -25
  231. package/src/components/issues/uncheckedSources.ts +21 -0
  232. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  233. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  234. package/src/components/resource/PVCUsageBar.tsx +15 -6
  235. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  236. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  237. package/src/components/resources/CompositeRenderer.tsx +4 -4
  238. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  239. package/src/components/resources/ResourcesView.tsx +26 -12
  240. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  241. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  242. package/src/components/settings/SettingsDialog.tsx +28 -15
  243. package/src/components/timeline/TimelineList.tsx +22 -24
  244. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  245. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  246. package/src/components/timeline/TimelineView.tsx +18 -45
  247. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  248. package/src/components/timeline/useStickyError.test.tsx +47 -0
  249. package/src/components/timeline/useStickyError.ts +15 -0
  250. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  251. package/src/components/ui/Markdown.test.tsx +65 -0
  252. package/src/components/ui/Markdown.tsx +144 -127
  253. package/src/components/whats-new/releaseNotes.ts +63 -1
  254. package/src/components/workload/WorkloadView.test.ts +10 -1
  255. package/src/components/workload/WorkloadView.tsx +27 -239
  256. package/src/components/workspace/layout.test.tsx +29 -0
  257. package/src/components/workspace/layout.tsx +259 -0
  258. package/src/components/workspace/table.ts +7 -0
  259. package/src/contexts/CapabilitiesContext.tsx +1 -1
  260. package/src/integrations/resourceHost.test.tsx +55 -0
  261. package/src/integrations/resourceHost.ts +99 -0
  262. package/src/integrations/resourceHosts.test.tsx +141 -0
  263. package/src/integrations/resourceHosts.tsx +119 -0
  264. package/src/integrations/workspaceRoute.ts +20 -0
  265. package/src/integrations/workspaceRoutes.test.ts +40 -0
  266. package/src/integrations/workspaceRoutes.ts +22 -0
  267. package/src/integrations/workspaceScreens.tsx +14 -0
  268. package/src/utils/drawer-trail.ts +35 -0
  269. package/src/utils/kindFilterStore.test.ts +147 -0
  270. package/src/utils/kindFilterStore.ts +91 -0
  271. package/src/utils/navigation.test.ts +13 -1
  272. package/src/utils/navigation.ts +39 -2
  273. package/src/utils/page-links.ts +21 -0
  274. package/src/utils/tabStorage.ts +40 -0
  275. package/src/utils/viewMemory.test.ts +78 -0
  276. package/src/utils/viewMemory.ts +54 -0
@@ -0,0 +1,685 @@
1
+ import { CNPG_BARMAN_PLUGIN_NAME, cnpgBackupDeclaration, cnpgBarmanPlugin, getCNPGClusterBarmanPlugin, getCNPGPostgresMajor, cnpgBackupMatchesCluster, cnpgArchiveMatchesCluster, getCNPGBackupPlugin, objectStoreForBackup, getCNPGObjectStoreRecoveryWindows, isApiGroup, type HealthLevel, type CNPGWorkspaceResponse, coverageReadable, formatGrant } from '@skyhook-io/k8s-ui'
2
+ import type { CNPGRuntimeResponse } from '../../../api/cnpg'
3
+ import type { ActionCapability } from '../../../api/actions'
4
+ import type { CNPGRecoveryResponse, CNPGRecoveryPod } from '../../../api/cnpg-recovery'
5
+
6
+ export const RESTORE_VALIDATION_ANNOTATION = 'radar.skyhook.io/restore-validation'
7
+ const HEALTHY_PHASE = 'Cluster in healthy state'
8
+
9
+ /**
10
+ * Where a restore reads from. Plugin backups are restored through the
11
+ * ObjectStore with the backup's ID pinned: CloudNativePG restores a
12
+ * `bootstrap.recovery.backup` reference only from in-tree or snapshot backups.
13
+ * A `snapshot` Backup restores its own consistent point; replaying WAL past it
14
+ * needs a WAL archive `source`, which this restore does not configure.
15
+ */
16
+ export type RestoreSource = (
17
+ | { kind: 'objectStore'; objectStore: string; serverName: string; backupID?: string; backupName?: string; backupEnd?: string }
18
+ | { kind: 'inTree'; barmanObjectStore: Record<string, unknown>; serverName: string }
19
+ | { kind: 'backup'; backup: string; backupEnd?: string; snapshot?: boolean }
20
+ ) & { majorVersion?: number }
21
+
22
+ export type RestoreTarget = { kind: 'latest' } | { kind: 'time'; iso: string } | { kind: 'backupEnd' }
23
+
24
+ type InTreeArchive = Extract<RestoreSource, { kind: 'inTree' }>
25
+
26
+ function isPluginBackup(b: any): boolean {
27
+ return b?.spec?.method === 'plugin' || b?.status?.method === 'plugin'
28
+ }
29
+
30
+ function isSnapshotBackup(b: any): boolean {
31
+ return b?.spec?.method === 'volumeSnapshot' || b?.status?.method === 'volumeSnapshot'
32
+ }
33
+
34
+ function backupEnd(b: any): string | undefined {
35
+ return b?.status?.stoppedAt || b?.status?.startedAt
36
+ }
37
+
38
+ /**
39
+ * The in-tree archive a Backup recorded writing to. A restore of it reads WAL
40
+ * from there too, whatever the Cluster declares today.
41
+ */
42
+ function recordedInTreeArchive(b: any): InTreeArchive | null {
43
+ const s = b?.status
44
+ const serverName = s?.serverName || b?.spec?.cluster?.name
45
+ if (isPluginBackup(b) || isSnapshotBackup(b) || typeof s?.destinationPath !== 'string' || !s.destinationPath || !serverName) return null
46
+ return { kind: 'inTree', barmanObjectStore: { destinationPath: s.destinationPath, endpointURL: s.endpointURL }, serverName }
47
+ }
48
+
49
+ function sameInTreeArchive(a: InTreeArchive, b: InTreeArchive): boolean {
50
+ return cnpgArchiveMatchesCluster({ spec: { backup: { barmanObjectStore: { ...a.barmanObjectStore, serverName: a.serverName } } } }, b)
51
+ }
52
+
53
+ /** Whether the Cluster's WAL archiving writes the WAL a restore from this source replays. */
54
+ function clusterArchivesInto(source: RestoreSource, cluster: any, namespace: string, completed: any[]): boolean {
55
+ if (!cluster) return false
56
+ switch (source.kind) {
57
+ case 'inTree':
58
+ return true
59
+ case 'objectStore':
60
+ return cluster.metadata?.namespace === namespace && cnpgArchiveMatchesCluster(cluster, source) && getCNPGClusterBarmanPlugin(cluster)?.isWALArchiver === true
61
+ default: {
62
+ const archive = source.snapshot ? null : recordedInTreeArchive(completed.find((b) => b?.metadata?.name === source.backup))
63
+ return !!archive && cnpgArchiveMatchesCluster(cluster, archive)
64
+ }
65
+ }
66
+ }
67
+
68
+ /** The source a Backup restores from, given the cluster that took it (if it still exists). */
69
+ export function restoreSourceForBackup(backup: any, sourceCluster: any | null): RestoreSource | null {
70
+ if (backup?.status?.phase !== 'completed') return null
71
+ const name = backup.metadata?.name
72
+ const majorVersion = backup.status?.majorVersion > 0 ? backup.status.majorVersion : undefined
73
+ const version = majorVersion ? { majorVersion } : {}
74
+ if (!isPluginBackup(backup)) return { kind: 'backup', backup: name, backupEnd: backupEnd(backup), ...(isSnapshotBackup(backup) ? { snapshot: true } : {}), ...version }
75
+ if (getCNPGBackupPlugin(backup)?.name !== CNPG_BARMAN_PLUGIN_NAME) return null
76
+ if (!cnpgBackupMatchesCluster(backup, sourceCluster)) return null
77
+ const plugin = sourceCluster ? getCNPGClusterBarmanPlugin(sourceCluster) : null
78
+ const objectStore = plugin?.barmanObjectName
79
+ const serverName = plugin?.serverName
80
+ const backupID = backup.status?.backupId
81
+ if (!objectStore || !serverName || !backupID) return null
82
+ return { kind: 'objectStore', objectStore, serverName, backupID, backupName: name, backupEnd: backupEnd(backup), ...version }
83
+ }
84
+
85
+ /** Where a Cluster's backups can be restored from, in order of preference. */
86
+ export function restoreSourcesFor(cluster: any, backups: any[]): RestoreSource[] {
87
+ const name = cluster?.metadata?.name
88
+ const out: RestoreSource[] = []
89
+ const plugin = cluster ? getCNPGClusterBarmanPlugin(cluster) : null
90
+ if (plugin?.barmanObjectName) {
91
+ out.push({ kind: 'objectStore', objectStore: plugin.barmanObjectName, serverName: plugin.serverName || name })
92
+ }
93
+ const inTree = cluster?.spec?.backup?.barmanObjectStore
94
+ if (inTree) out.push({ kind: 'inTree', barmanObjectStore: inTree, serverName: inTree.serverName || name })
95
+ const completed = backups
96
+ .filter((b) => b?.spec?.cluster?.name === name && b?.metadata?.namespace === cluster?.metadata?.namespace && isApiGroup(b?.apiVersion ?? 'postgresql.cnpg.io/v1', 'postgresql.cnpg.io'))
97
+ .sort((a, b) => Date.parse(backupEnd(b) ?? '') - Date.parse(backupEnd(a) ?? ''))
98
+ for (const b of completed) {
99
+ const s = restoreSourceForBackup(b, cluster)
100
+ if (s) out.push(s)
101
+ }
102
+ return out
103
+ }
104
+
105
+ export type CNPGRestoreSourceState = 'available' | 'none' | 'unknown'
106
+
107
+ export function assessRestoreSources(data: CNPGWorkspaceResponse | undefined, namespace: string, cluster: any): { sources: RestoreSource[]; recoveryState: CNPGRestoreSourceState; disabledReason?: string; unreadReason?: string } {
108
+ const readable = !!data && coverageReadable(data.coverage.backups ?? { state: 'notInstalled' }, namespace)
109
+ const backups = readable ? data!.objects.backups ?? [] : []
110
+ const sources = cluster ? restoreSourcesFor(cluster, backups) : []
111
+ const unread: string[] = []
112
+ if (!readable) unread.push(`Backup sources could not be read in ${namespace}.`)
113
+ if (!cluster) unread.push('The source Cluster could not be read.')
114
+ const storesReadable = !!data && coverageReadable(data.coverage.objectStores ?? { state: 'notInstalled' }, namespace)
115
+ const stores = storesReadable ? data!.objects.objectStores ?? [] : []
116
+ const recordedRecovery = sources.some((source) => {
117
+ if (source.kind === 'backup') return true
118
+ const evidence = recoveryEvidenceFor(source, { sourceCluster: cluster, stores, backups, namespace })
119
+ return !!(evidence.lastBackup || evidence.firstPoint)
120
+ })
121
+ for (const source of sources) {
122
+ if (source.kind !== 'objectStore') continue
123
+ if (!stores.some((store) => store.metadata?.namespace === namespace && store.metadata?.name === source.objectStore)) {
124
+ unread.push(`ObjectStore ${source.objectStore} could not be read in ${namespace}, so its recovery evidence is unknown.`)
125
+ }
126
+ }
127
+ for (const plugin of cluster?.spec?.plugins ?? []) {
128
+ if (plugin.enabled !== false && plugin.name !== CNPG_BARMAN_PLUGIN_NAME) unread.push(`Recovery sources for plugin ${plugin.name} cannot be assessed.`)
129
+ }
130
+ // Schedules are not consulted: barman-cloud takes the destination from the
131
+ // Cluster, so a schedule neither adds a source nor hides one.
132
+ for (const backup of backups) {
133
+ if (backup.spec?.cluster?.name !== cluster?.metadata?.name || backup.metadata?.namespace !== namespace || !isApiGroup(backup.apiVersion, 'postgresql.cnpg.io') || backup.spec?.method !== 'plugin') continue
134
+ const pluginName = getCNPGBackupPlugin(backup)?.name
135
+ if (pluginName !== CNPG_BARMAN_PLUGIN_NAME) {
136
+ unread.push(`Recovery sources for Backup ${backup.metadata?.name} using plugin ${pluginName ?? '(not named)'} cannot be assessed.`)
137
+ } else if (backup.status?.phase === 'completed' && !cnpgBackupMatchesCluster(backup, cluster)) {
138
+ unread.push(`Backup ${backup.metadata?.name} belongs to a previous Cluster incarnation, so its archive cannot be inferred from this Cluster.`)
139
+ } else if (backup.status?.phase === 'completed' && !objectStoreForBackup(backup, [cluster])) {
140
+ unread.push(`Backup ${backup.metadata?.name} completed with the barman-cloud plugin, but the Cluster no longer names an ObjectStore, so where it is stored is unknown.`)
141
+ }
142
+ }
143
+ const recoveryState: CNPGRestoreSourceState = recordedRecovery ? 'available' : unread.length ? 'unknown' : 'none'
144
+ return {
145
+ sources,
146
+ recoveryState,
147
+ unreadReason: unread.length ? unread.join(' ') : undefined,
148
+ disabledReason: recoveryState === 'none' && sources.length === 0 ? 'Nothing to restore from yet: no backup destination and no completed Backup.' : undefined,
149
+ }
150
+ }
151
+
152
+ function restoreImage(cluster: any, source?: RestoreSource): { imageCatalogRef?: any; imageName?: string } {
153
+ const spec = cluster?.spec
154
+ if (source && (source.kind === 'backup' || sourcePinsBackup(source))) {
155
+ if (!source.majorVersion) return {}
156
+ if (spec?.imageCatalogRef) return { imageCatalogRef: { ...spec.imageCatalogRef, major: source.majorVersion } }
157
+ const running = cluster?.status?.pgDataImageInfo
158
+ return getCNPGPostgresMajor(cluster) === source.majorVersion && running?.image ? { imageName: running.image } : {}
159
+ }
160
+ if (spec?.imageCatalogRef) return { imageCatalogRef: spec.imageCatalogRef }
161
+ const imageName = spec?.imageName || cluster?.status?.image
162
+ return imageName ? { imageName } : {}
163
+ }
164
+
165
+ /** One ObjectStore source per server its status reports, newest evidence first. */
166
+ export function restoreSourcesForStore(store: any): RestoreSource[] {
167
+ const name = store?.metadata?.name
168
+ return getCNPGObjectStoreRecoveryWindows(store)
169
+ .sort((a, b) => Date.parse(b.lastSuccessfulBackupTime ?? '') - Date.parse(a.lastSuccessfulBackupTime ?? ''))
170
+ .map((w) => ({ kind: 'objectStore' as const, objectStore: name, serverName: w.server }))
171
+ }
172
+
173
+ /** The live Cluster with this archive declaration, when there is one. */
174
+ export function sourceClusterFor(source: RestoreSource, clusters: any[], namespace: string): any | null {
175
+ if (source.kind === 'backup') return null
176
+ return (
177
+ clusters.find((c) => {
178
+ if (c?.metadata?.namespace !== namespace) return false
179
+ return cnpgArchiveMatchesCluster(c, source)
180
+ }) ?? null
181
+ )
182
+ }
183
+
184
+ export function describeSource(s: RestoreSource): string {
185
+ switch (s.kind) {
186
+ case 'objectStore':
187
+ return s.backupName ? `Backup ${s.backupName} (ID ${s.backupID}) in ObjectStore ${s.objectStore}` : `ObjectStore ${s.objectStore} · server ${s.serverName}`
188
+ case 'inTree':
189
+ return `Barman object store (in-tree) · server ${s.serverName}`
190
+ default:
191
+ return s.snapshot ? `Backup ${s.backup} (volume snapshot)` : `Backup ${s.backup}`
192
+ }
193
+ }
194
+
195
+ /** A source pinned to one base backup can also stop at that backup's end. */
196
+ export function sourcePinsBackup(s: RestoreSource | undefined): boolean {
197
+ return s?.kind === 'objectStore' && !!s.backupID
198
+ }
199
+
200
+ /** The recovery targets the manifest can deliver from a source, the default first. */
201
+ export function restoreTargetKinds(s: RestoreSource | undefined): RestoreTarget['kind'][] {
202
+ if (s?.kind === 'backup' && s.snapshot) return ['backupEnd']
203
+ return sourcePinsBackup(s) ? ['backupEnd', 'latest', 'time'] : ['latest', 'time']
204
+ }
205
+
206
+ export interface EvidencePoint {
207
+ at: string
208
+ source: string
209
+ }
210
+
211
+ /**
212
+ * What Radar can show about how far back and how far forward a restore can
213
+ * reach. Each point names where it came from; a missing point is unknown,
214
+ * never "none".
215
+ */
216
+ export interface RecoveryEvidence {
217
+ firstPoint?: EvidencePoint
218
+ lastBackup?: EvidencePoint
219
+ lastArchived?: EvidencePoint & { wal?: string }
220
+ lastArchiveFailure?: EvidencePoint & { wal?: string }
221
+ archiving: { text: string; tone: HealthLevel; source: string; detail?: string }
222
+ gaps: string[]
223
+ }
224
+
225
+ function newest(points: (EvidencePoint | undefined)[]): EvidencePoint | undefined {
226
+ return points.filter((p): p is EvidencePoint => !!p && Number.isFinite(Date.parse(p.at))).sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0]
227
+ }
228
+
229
+ export function recoveryEvidenceFor(
230
+ source: RestoreSource | undefined,
231
+ ctx: { sourceCluster: any | null; stores: any[]; backups: any[]; runtime?: CNPGRuntimeResponse; namespace: string },
232
+ ): RecoveryEvidence {
233
+ const gaps: string[] = []
234
+ const out: RecoveryEvidence = { archiving: { text: 'Not reported', tone: 'unknown', source: 'No source cluster to read' }, gaps }
235
+ if (!source) return out
236
+ const cluster = ctx.sourceCluster
237
+ const clusterName = cluster?.metadata?.name
238
+
239
+ const completed = ctx.backups.filter((b) => b?.metadata?.namespace === ctx.namespace && b?.status?.phase === 'completed')
240
+ const point = (b: any): EvidencePoint | undefined => (backupEnd(b) ? { at: backupEnd(b)!, source: `Backup ${b.metadata?.name}` } : undefined)
241
+ const sameNamespace = cluster?.metadata?.namespace === ctx.namespace
242
+
243
+ if (source.kind === 'objectStore') {
244
+ const store = ctx.stores.find((s) => s?.metadata?.namespace === ctx.namespace && s?.metadata?.name === source.objectStore)
245
+ if (!store) gaps.push(`ObjectStore ${source.objectStore} is not readable, so its recovery window is unknown`)
246
+ const w = store ? getCNPGObjectStoreRecoveryWindows(store).find((x) => x.server === source.serverName) : undefined
247
+ if (store && !w) gaps.push(`ObjectStore ${source.objectStore} reports no recovery window for server ${source.serverName}`)
248
+ const where = `ObjectStore ${source.objectStore} status, server ${source.serverName}`
249
+ if (w?.firstRecoverabilityPoint) out.firstPoint = { at: w.firstRecoverabilityPoint, source: where }
250
+ if (w?.lastSuccessfulBackupTime) out.lastBackup = { at: w.lastSuccessfulBackupTime, source: where }
251
+ // barman-cloud writes a Backup where its Cluster's plugin points, so a
252
+ // Backup counts only while its Cluster still points at this archive.
253
+ const intoStore = cluster && sameNamespace && cnpgArchiveMatchesCluster(cluster, source)
254
+ ? completed.filter((b) => isPluginBackup(b) && getCNPGBackupPlugin(b)?.name === CNPG_BARMAN_PLUGIN_NAME && cnpgBackupMatchesCluster(b, cluster))
255
+ : []
256
+ out.lastBackup = newest([out.lastBackup, ...intoStore.map(point)])
257
+ } else if (source.kind === 'inTree') {
258
+ if (cluster) {
259
+ // An operator that reports per-method times folds volume snapshots into
260
+ // the plain fields; one that does not predates counting them there.
261
+ const st = cluster.status ?? {}
262
+ const first = st.firstRecoverabilityPointByMethod ? st.firstRecoverabilityPointByMethod.barmanObjectStore : st.firstRecoverabilityPoint
263
+ const last = st.lastSuccessfulBackupByMethod ? st.lastSuccessfulBackupByMethod.barmanObjectStore : st.lastSuccessfulBackup
264
+ if (first) out.firstPoint = { at: first, source: `Cluster ${clusterName} status` }
265
+ if (last) out.lastBackup = { at: last, source: `Cluster ${clusterName} status` }
266
+ }
267
+ const intoArchive = completed.filter((b) => {
268
+ const archive = recordedInTreeArchive(b)
269
+ return !!archive && sameInTreeArchive(archive, source)
270
+ })
271
+ out.lastBackup = newest([out.lastBackup, ...intoArchive.map(point)])
272
+ } else if (source.backupEnd) {
273
+ out.lastBackup = { at: source.backupEnd, source: `Backup ${source.backup}` }
274
+ }
275
+
276
+ if (source.kind === 'backup' && source.snapshot) {
277
+ out.archiving = { text: 'Not replayed', tone: 'neutral', source: 'A volume snapshot restores to its own end; no archived WAL is applied' }
278
+ } else if (clusterArchivesInto(source, cluster, ctx.namespace, completed)) {
279
+ const cond = (cluster.status?.conditions ?? []).find((c: any) => c?.type === 'ContinuousArchiving')
280
+ if (!cond) out.archiving = { text: 'Not reported', tone: 'unknown', source: `Cluster ${clusterName} has no ContinuousArchiving condition` }
281
+ else if (cond.status === 'True') out.archiving = { text: 'Archiving', tone: 'healthy', source: `ContinuousArchiving condition on ${clusterName}` }
282
+ else if (cond.status === 'False') out.archiving = { text: 'Failing', tone: 'unhealthy', source: `ContinuousArchiving condition on ${clusterName}`, ...(cond.message ? { detail: cond.message } : {}) }
283
+ else out.archiving = { text: 'Unknown', tone: 'unknown', source: `ContinuousArchiving condition on ${clusterName}` }
284
+
285
+ const primary = ctx.runtime?.instances.find((i) => i.role === 'primary')
286
+ const arch = primary?.status.state === 'ok' ? primary.status.archiving : undefined
287
+ if (arch?.lastArchivedAt) out.lastArchived = { at: arch.lastArchivedAt, wal: arch.lastArchivedWal, source: `instance manager on ${primary!.pod}` }
288
+ if (arch?.lastFailedAt) out.lastArchiveFailure = { at: arch.lastFailedAt, wal: arch.lastFailedWal, source: `instance manager on ${primary!.pod}` }
289
+ if (!arch) {
290
+ gaps.push(
291
+ !ctx.runtime
292
+ ? 'The last archived WAL time is not loaded'
293
+ : ctx.runtime.permission.proxy === 'denied'
294
+ ? `The last archived WAL time needs ${formatGrant(ctx.runtime.permission.grant) ?? 'get pods/proxy'}`
295
+ : !primary
296
+ ? 'No primary is reported, so the last archived WAL time is unknown'
297
+ : primary.status.state !== 'ok'
298
+ ? `The primary’s instance manager could not be read${primary.status.error ? ` (${primary.status.error})` : ''}, so the last archived WAL time is unknown`
299
+ : 'The primary’s instance manager did not report archiving',
300
+ )
301
+ }
302
+ } else {
303
+ out.archiving = { text: 'Unknown', tone: 'unknown', source: 'No live cluster archives into this source' }
304
+ gaps.push('No live cluster archives into this source, so WAL archived after the last backup is unknown')
305
+ }
306
+ if (!out.firstPoint && source.kind !== 'backup') gaps.push('The first recoverability point is not reported')
307
+ return out
308
+ }
309
+
310
+ /** The newest moment any evidence says the archive reaches. */
311
+ export function latestEvidence(e: RecoveryEvidence): EvidencePoint | undefined {
312
+ return newest([e.lastBackup, e.lastArchived])
313
+ }
314
+
315
+ /**
316
+ * Warnings for a point-in-time target. Never a block: the evidence can be
317
+ * stale or partial, and the operator is the authority on what the archive
318
+ * holds.
319
+ */
320
+ export function pitrWarnings(targetIso: string, e: RecoveryEvidence, now = Date.now()): string[] {
321
+ const t = Date.parse(targetIso)
322
+ if (!Number.isFinite(t)) return ['The target time is not a valid date.']
323
+ const out: string[] = []
324
+ if (t > now) out.push('The target is in the future; recovery cannot reach it and will fail.')
325
+ if (e.firstPoint && t < Date.parse(e.firstPoint.at)) {
326
+ out.push(`The target is before the first recoverability point (${formatUTC(e.firstPoint.at)}, from ${e.firstPoint.source}); no base backup is old enough and recovery will fail.`)
327
+ }
328
+ const latest = latestEvidence(e)
329
+ if (latest && t > Date.parse(latest.at) && t <= now) {
330
+ out.push(`The target is after the newest evidence Radar has (${formatUTC(latest.at)}, from ${latest.source}). If WAL up to the target was not archived, PostgreSQL stops recovery with an error.`)
331
+ }
332
+ if (!latest && !e.firstPoint) out.push('Radar has no evidence of what this source holds, so the target cannot be checked.')
333
+ return out
334
+ }
335
+
336
+ export function formatUTC(iso: string): string {
337
+ const d = new Date(iso)
338
+ if (Number.isNaN(d.getTime())) return iso
339
+ return `${d.toISOString().slice(0, 19).replace('T', ' ')} UTC`
340
+ }
341
+
342
+ export function formatLocal(iso: string): string {
343
+ const d = new Date(iso)
344
+ if (Number.isNaN(d.getTime())) return iso
345
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
346
+ return `${d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' })} ${tz}`
347
+ }
348
+
349
+ /** A datetime-local value read in UTC or the browser's zone, as RFC 3339 without milliseconds. */
350
+ export function targetIsoFrom(value: string, zone: 'utc' | 'local'): string | null {
351
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/.test(value)) return null
352
+ const withSeconds = value.length === 16 ? `${value}:00` : value
353
+ const d = zone === 'utc' ? new Date(`${withSeconds}Z`) : new Date(withSeconds)
354
+ if (Number.isNaN(d.getTime())) return null
355
+ return d.toISOString().replace(/\.\d{3}Z$/, 'Z')
356
+ }
357
+
358
+ export interface PreflightFact {
359
+ label: string
360
+ value: string
361
+ path: string
362
+ copied: boolean
363
+ }
364
+
365
+ /** What the manifest copies from the source cluster, to review before creating. */
366
+ export function preflightFacts(cluster: any | null): PreflightFact[] {
367
+ const spec = cluster?.spec ?? {}
368
+ const facts: PreflightFact[] = []
369
+ const add = (label: string, path: string, value: string | undefined, fallback: string) =>
370
+ facts.push({ label, path, value: value ?? fallback, copied: value !== undefined })
371
+ add('Instances', 'spec.instances', spec.instances !== undefined ? String(spec.instances) : undefined, '1 (default; no source cluster to copy from)')
372
+ const image = restoreImage(cluster)
373
+ if (image.imageCatalogRef) {
374
+ const ref = image.imageCatalogRef
375
+ add('Image', 'spec.imageCatalogRef', `${ref.kind ?? 'ImageCatalog'} ${ref.name}, major ${ref.major}`, '')
376
+ } else {
377
+ add('Image', 'spec.imageName', image.imageName ? `${image.imageName}${spec.imageName ? '' : ' (from status.image)'}` : undefined, 'operator default — set it to the source’s PostgreSQL major')
378
+ }
379
+ add('Data storage', 'spec.storage', spec.storage ? storageText(spec.storage) : undefined, 'not set — set spec.storage.size to at least the source’s data size')
380
+ if (spec.walStorage) add('WAL storage', 'spec.walStorage', storageText(spec.walStorage), '')
381
+ if (Array.isArray(spec.tablespaces) && spec.tablespaces.length > 0) {
382
+ add('Tablespaces', 'spec.tablespaces', spec.tablespaces.map((t: any) => `${t.name} (${storageText(t.storage ?? {})})`).join(', '), '')
383
+ }
384
+ const params = spec.postgresql?.parameters
385
+ if (params && Object.keys(params).length > 0) {
386
+ const keys = Object.keys(params).sort()
387
+ add('PostgreSQL parameters', 'spec.postgresql.parameters', `${keys.length}: ${keys.slice(0, 6).join(', ')}${keys.length > 6 ? ', …' : ''}`, '')
388
+ }
389
+ if (spec.resources && Object.keys(spec.resources).length > 0) add('Resources', 'spec.resources', resourcesText(spec.resources), '')
390
+ return facts
391
+ }
392
+
393
+ /** "requests: cpu 50m, memory 128Mi · limits: memory 256Mi" */
394
+ export function resourcesText(r: any): string {
395
+ const part = (label: string, m: Record<string, unknown> | undefined) =>
396
+ m && Object.keys(m).length > 0 ? `${label}: ${Object.entries(m).map(([k, v]) => `${k} ${String(v)}`).join(', ')}` : null
397
+ return [part('requests', r?.requests), part('limits', r?.limits)].filter(Boolean).join(' · ') || 'set'
398
+ }
399
+
400
+ function storageText(s: any): string {
401
+ const parts = [s.size ?? s.pvcTemplate?.resources?.requests?.storage ?? 'size from pvcTemplate', s.storageClass ? `class ${s.storageClass}` : null]
402
+ return parts.filter(Boolean).join(', ')
403
+ }
404
+
405
+ /**
406
+ * A new Cluster that bootstraps from `source`. It copies what the restored
407
+ * data needs from the source cluster and nothing that would make it write to
408
+ * the source's backup location: no plugins, no backup section. A target the
409
+ * source cannot deliver (a time past a volume snapshot) is not written.
410
+ */
411
+ export function buildRestoreManifest(args: {
412
+ sourceCluster: any | null
413
+ source: RestoreSource
414
+ namespace: string
415
+ newName: string
416
+ target: RestoreTarget
417
+ }): Record<string, any> {
418
+ const { sourceCluster, source, namespace, newName, target } = args
419
+ const spec = sourceCluster?.spec ?? {}
420
+ const recovery: Record<string, any> = source.kind === 'backup' ? { backup: { name: source.backup } } : { source: 'origin' }
421
+ const pinned = source.kind === 'objectStore' && source.backupID ? { backupID: source.backupID } : {}
422
+ if (target.kind === 'backupEnd' && 'backupID' in pinned) recovery.recoveryTarget = { ...pinned, targetImmediate: true }
423
+ else if (target.kind === 'time' && restoreTargetKinds(source).includes('time')) recovery.recoveryTarget = { ...pinned, targetTime: target.iso }
424
+ else if ('backupID' in pinned) recovery.recoveryTarget = pinned
425
+ const out: Record<string, any> = {
426
+ apiVersion: 'postgresql.cnpg.io/v1',
427
+ kind: 'Cluster',
428
+ metadata: { name: newName, namespace },
429
+ spec: {
430
+ instances: spec.instances ?? 1,
431
+ ...restoreImage(sourceCluster, source),
432
+ ...(spec.postgresql?.parameters ? { postgresql: { parameters: spec.postgresql.parameters } } : {}),
433
+ ...(spec.resources ? { resources: spec.resources } : {}),
434
+ storage: spec.storage ?? {},
435
+ ...(spec.walStorage ? { walStorage: spec.walStorage } : {}),
436
+ ...(spec.tablespaces ? { tablespaces: spec.tablespaces } : {}),
437
+ bootstrap: { recovery },
438
+ },
439
+ }
440
+ if (source.kind === 'objectStore') {
441
+ out.spec.externalClusters = [
442
+ { name: 'origin', plugin: { name: CNPG_BARMAN_PLUGIN_NAME, parameters: { barmanObjectName: source.objectStore, serverName: source.serverName } } },
443
+ ]
444
+ } else if (source.kind === 'inTree') {
445
+ out.spec.externalClusters = [{ name: 'origin', barmanObjectStore: { ...source.barmanObjectStore, serverName: source.serverName } }]
446
+ }
447
+ return out
448
+ }
449
+
450
+ export function restoreManifestHeader(sourceLabel: string, sourceServer: string | undefined, copiedFrom: string | null): string {
451
+ const lines = [
452
+ `# Restores ${sourceLabel} into a new cluster. Review before creating:`,
453
+ copiedFrom
454
+ ? `# - instances, image, storage, WAL storage, tablespaces, PostgreSQL parameters and resources are copied from ${copiedFrom}; edit them here.`
455
+ : '# - no source cluster was found: set the image and storage for the source before creating.',
456
+ '# - the new cluster has no WAL archiving or backups until you configure them.',
457
+ sourceServer
458
+ ? `# When you do, use a new serverName: archiving under "${sourceServer}" would write into the archive this restores from.`
459
+ : '# When you do, use a new destination: never the location this restores from.',
460
+ '# - the dry-run checks the manifest, not whether the backups are readable.',
461
+ ]
462
+ return `${lines.join('\n')}\n`
463
+ }
464
+
465
+ // ---------- restore follow-through ----------
466
+
467
+ export type RestoreState = 'progressing' | 'completed' | 'failed' | 'unobservable'
468
+
469
+ export interface RestoreObservation {
470
+ state: RestoreState
471
+ title: string
472
+ detail?: string
473
+ tone: HealthLevel
474
+ /** The Pod whose logs explain the current step. */
475
+ logsPod?: { name: string; container?: string }
476
+ recoveryPods: CNPGRecoveryPod[]
477
+ }
478
+
479
+ const FAILING_PHASE = /unrecoverable|unable to|cannot|error|failed/i
480
+ const CRASH_REASONS = new Set(['CrashLoopBackOff', 'Error', 'ImagePullBackOff', 'ErrImagePull', 'CreateContainerConfigError', 'CreateContainerError'])
481
+
482
+ function failingContainer(p: CNPGRecoveryPod) {
483
+ return [...p.initContainers, ...p.containers].find(
484
+ (c) => (c.state === 'waiting' && c.reason && CRASH_REASONS.has(c.reason)) || (c.state === 'terminated' && c.exitCode !== undefined && c.exitCode !== 0),
485
+ )
486
+ }
487
+
488
+ // A running init container is a restartable sidecar (the plugin's), not the
489
+ // step in progress; an init container still waiting is.
490
+ function activeContainer(p: CNPGRecoveryPod) {
491
+ const init = p.initContainers.find((c) => c.state === 'waiting' || c.state === 'unknown' || (c.state === 'terminated' && (c.exitCode ?? 0) !== 0))
492
+ return init ?? p.containers.find((c) => c.state === 'running') ?? p.containers[0]
493
+ }
494
+
495
+ const RECOVERY_JOB_ROLES = ['full-recovery', 'snapshot-recovery']
496
+
497
+ /**
498
+ * Whether a Cluster's Job restores data: by its cnpg.io/jobRole, or, read
499
+ * without the role, by the `<instance>-<role>` name CloudNativePG gives it.
500
+ * The Cluster's other Jobs (a replica's join, initdb, a major upgrade) run
501
+ * long after a restore and say nothing about it.
502
+ */
503
+ export function isCNPGRecoveryJob(job: { name: string; role?: string }): boolean {
504
+ return job.role ? RECOVERY_JOB_ROLES.includes(job.role) : RECOVERY_JOB_ROLES.some((role) => job.name.endsWith(`-${role}`))
505
+ }
506
+
507
+ /**
508
+ * Whether the restored primary is up: an instance Pod is ready. CloudNativePG
509
+ * starts the first instance only after the recovery Job succeeded, so this
510
+ * needs no look at Jobs (a replica's join Job later is not recovery). Unlike
511
+ * a completed restore, it stays true when the cluster later loses a replica
512
+ * or is mid-switchover.
513
+ */
514
+ export function cnpgRestoredPrimaryUp(snap: CNPGRecoveryResponse): boolean {
515
+ return snap.coverage.pods?.state === 'ok' && snap.pods.some((p) => p.kind === 'instance' && p.ready)
516
+ }
517
+
518
+ /**
519
+ * Where a restore stands, from the recovery snapshot alone. The panel stays
520
+ * mounted for the Cluster's whole life, so only recovery Jobs and their Pods
521
+ * count. Completion is a completed recovery Job, which stays recorded while
522
+ * every instance is down (hibernated, or restarting), or a restored instance
523
+ * being ready (a ready instance Pod, or the Cluster's readyInstances, which
524
+ * needs no Pod access): CloudNativePG starts one only after recovery
525
+ * succeeded, so a later switchover, scale-up or failed join never reopens the
526
+ * restore. Failure is a failed recovery Job, or a failing phase before any
527
+ * instance was ready. Missing Pod access never reads as progress or failure.
528
+ */
529
+ export function observeRestore(snap: CNPGRecoveryResponse): RestoreObservation {
530
+ const recoveryJobs = snap.jobs.filter(isCNPGRecoveryJob)
531
+ const recoveryPods = snap.pods.filter((p) => p.kind === 'job' && !!p.job && isCNPGRecoveryJob(snap.jobs.find((j) => j.name === p.job) ?? { name: p.job }))
532
+ const instances = snap.pods.filter((p) => p.kind === 'instance')
533
+ const c = snap.cluster
534
+ const base = { recoveryPods }
535
+ const podsVisible = snap.coverage.pods?.state === 'ok'
536
+
537
+ const failedJob = recoveryJobs.find((j) => j.failedWith)
538
+ if (failedJob) {
539
+ const pod = recoveryPods.find((p) => p.job === failedJob.name)
540
+ return { ...base, state: 'failed', tone: 'unhealthy', title: `Recovery Job ${failedJob.name} failed`, detail: failedJob.failedWith, logsPod: pod ? { name: pod.name, container: failingContainer(pod)?.name } : undefined }
541
+ }
542
+ // A full recovery Job runs PostgreSQL until recovery ends. A snapshot
543
+ // recovery Job without a WAL source only puts the files in place; PostgreSQL
544
+ // first starts on the instance, so that Job proves nothing.
545
+ const provesRestore = (j: { name: string; role?: string }) =>
546
+ (j.role ?? (j.name.endsWith('-full-recovery') ? 'full-recovery' : '')) === 'full-recovery' || !!snap.recovery?.source
547
+ const finishedJob = recoveryJobs.find((j) => (j.complete || j.succeeded > 0) && provesRestore(j))
548
+ const primaryUp = cnpgRestoredPrimaryUp(snap) || (c.readyInstances ?? 0) >= 1
549
+ if (finishedJob || primaryUp) {
550
+ return {
551
+ ...base,
552
+ state: 'completed',
553
+ tone: 'healthy',
554
+ title: 'Restore completed',
555
+ detail: [
556
+ !primaryUp && finishedJob ? `Recovery Job ${finishedJob.name} completed` : null,
557
+ c.readyInstances !== null && c.instances !== null ? `${c.readyInstances}/${c.instances} instances ready` : null,
558
+ c.phase,
559
+ ].filter(Boolean).join(' · '),
560
+ }
561
+ }
562
+ if (c.phase && c.phase !== HEALTHY_PHASE && FAILING_PHASE.test(c.phase)) {
563
+ return { ...base, state: 'failed', tone: 'unhealthy', title: c.phase, detail: c.phaseReason || c.ready?.message }
564
+ }
565
+
566
+ const crashing = recoveryPods.map((p) => ({ p, c: failingContainer(p) })).find((x) => x.c)
567
+ if (crashing) {
568
+ return {
569
+ ...base,
570
+ state: 'progressing',
571
+ tone: 'alert',
572
+ title: `Recovery attempt failing in ${crashing.p.name}`,
573
+ detail: `${crashing.c!.name}: ${[crashing.c!.reason, crashing.c!.message].filter(Boolean).join(' · ') || 'exited with an error'}. The Job retries; its logs say why.`,
574
+ logsPod: { name: crashing.p.name, container: crashing.c!.name },
575
+ }
576
+ }
577
+ const running = recoveryPods.find((p) => p.phase === 'Running' || p.phase === 'Pending')
578
+ if (running) {
579
+ const active = activeContainer(running)
580
+ return {
581
+ ...base,
582
+ state: 'progressing',
583
+ tone: 'neutral',
584
+ title: 'Restoring the base backup and replaying WAL',
585
+ detail: `${running.name}${active ? ` · ${active.name} ${active.state}` : ''}${c.phase ? ` · ${c.phase}` : ''}`,
586
+ logsPod: { name: running.name, container: active?.name },
587
+ }
588
+ }
589
+ if (!podsVisible && !c.phase) {
590
+ return { ...base, state: 'unobservable', tone: 'unknown', title: 'Progress not visible', detail: snap.coverage.pods?.grant ? `Following the restore needs ${formatGrant(snap.coverage.pods.grant)}` : 'The Cluster has not reported a phase yet' }
591
+ }
592
+ const starting = instances.find((p) => !p.ready)
593
+ return {
594
+ ...base,
595
+ state: 'progressing',
596
+ tone: 'neutral',
597
+ title: recoveryPods.some((p) => p.phase === 'Succeeded') ? 'Recovery finished; starting the restored instances' : 'Waiting for the recovery to start',
598
+ detail: [c.phase, c.readyInstances !== null && c.instances !== null ? `${c.readyInstances}/${c.instances} ready` : null, !podsVisible ? `Pods not visible${snap.coverage.pods?.grant ? ` (needs ${formatGrant(snap.coverage.pods.grant)})` : ''}` : null]
599
+ .filter(Boolean)
600
+ .join(' · '),
601
+ logsPod: starting ? { name: starting.name, container: 'postgres' } : undefined,
602
+ }
603
+ }
604
+
605
+ /**
606
+ * What the restore capability means for the dialog: `blocked` disables Review
607
+ * with an alert (the grant, or the operator's webhook refusing writes);
608
+ * `pending` only while it is being checked. A failed check blocks nothing: the
609
+ * server dry-run at review still has the final say, and `unchecked` says so.
610
+ */
611
+ export function restorePermission(
612
+ namespace: string,
613
+ cap: ActionCapability | undefined,
614
+ error: unknown,
615
+ ): { blocked?: string; pending?: string; unchecked?: string } {
616
+ if (cap) {
617
+ if (cap.allowed) return {}
618
+ if (cap.permission === 'denied') return { blocked: `Restoring creates a Cluster in ${namespace}, which needs ${formatGrant(cap.grant) ?? 'create clusters (postgresql.cnpg.io)'}.` }
619
+ return { blocked: cap.reason ?? 'Restoring is not available right now.' }
620
+ }
621
+ if (error) return { unchecked: `Whether you may create a Cluster in ${namespace} could not be checked (${error instanceof Error ? error.message : 'unknown error'}); the review step will tell.` }
622
+ return { pending: `Checking whether you may create a Cluster in ${namespace}…` }
623
+ }
624
+
625
+ export type RestoreNextStepId = 'connect' | 'validate' | 'backup'
626
+
627
+ export interface RestoreNextStep {
628
+ id: RestoreNextStepId
629
+ label: string
630
+ /** Set only where Radar can tell; `unknown` otherwise (whether applications moved is never known). */
631
+ state: 'done' | 'partial' | 'todo' | 'unknown'
632
+ note?: string
633
+ }
634
+
635
+ /**
636
+ * What the Cluster spec declares for protection: WAL archiving (the Barman
637
+ * plugin as WAL archiver with its ObjectStore named, or barmanObjectStore),
638
+ * the plugin marked as WAL archiver but naming no ObjectStore to archive to,
639
+ * base backups without WAL archiving (the plugin not marked isWALArchiver),
640
+ * volume snapshots only, or nothing.
641
+ */
642
+ export type RestoreBackupDeclared = 'walArchiving' | 'archiverWithoutDestination' | 'backupsNoArchiving' | 'snapshotsOnly' | 'none'
643
+
644
+ const BACKUP_STEP: Record<RestoreBackupDeclared | 'unread', Pick<RestoreNextStep, 'state' | 'note'>> = {
645
+ unread: { state: 'unknown', note: 'Its backup configuration was not read' },
646
+ walArchiving: { state: 'partial', note: 'WAL archiving is configured. Verify uploads and complete a new base backup; configuration alone does not prove protection.' },
647
+ archiverWithoutDestination: {
648
+ state: 'partial',
649
+ note: 'The Barman plugin is set as WAL archiver but names no ObjectStore (parameters.barmanObjectName), so WAL has nowhere to go',
650
+ },
651
+ backupsNoArchiving: { state: 'partial', note: 'An ObjectStore is declared, but not as the WAL archiver, so no point-in-time recovery' },
652
+ snapshotsOnly: { state: 'partial', note: 'Volume snapshots declared, but no WAL archiving, so no point-in-time recovery' },
653
+ none: { state: 'todo', note: 'It has no backup destination or WAL archiving yet' },
654
+ }
655
+
656
+ /**
657
+ * The checklist a restored cluster shows once it is healthy. Links only;
658
+ * Radar writes nothing. Validation reads the recorded note; backups read the
659
+ * Cluster spec (undefined when not read). Snapshots alone are partial: they
660
+ * give no point-in-time recovery.
661
+ */
662
+ export function restoreNextSteps(input: { validationRecorded: boolean; backup: RestoreBackupDeclared | undefined }): RestoreNextStep[] {
663
+ const label = 'Set up and verify backups and WAL archiving'
664
+ const backup: RestoreNextStep = { id: 'backup', label, ...BACKUP_STEP[input.backup ?? 'unread'] }
665
+ return [
666
+ { id: 'connect', label: 'Point applications at it', state: 'unknown', note: 'Radar cannot tell which applications use it' },
667
+ input.validationRecorded
668
+ ? { id: 'validate', label: 'Record what you checked', state: 'done', note: 'A validation note is recorded' }
669
+ : { id: 'validate', label: 'Record what you checked', state: 'todo' },
670
+ backup,
671
+ ]
672
+ }
673
+
674
+ /** What a Cluster spec declares for backups and WAL archiving. */
675
+ export function restoreBackupDeclared(cluster: any): RestoreBackupDeclared {
676
+ const declaration = cnpgBackupDeclaration(cluster)
677
+ const plugin = cnpgBarmanPlugin(declaration)
678
+ // plugin-barman-cloud reads the archive destination from the plugin's own
679
+ // parameters; the recovery source's ObjectStore is a separate setting.
680
+ if ((plugin?.isWALArchiver && plugin.barmanObjectName) || declaration.inTreeDestination) return 'walArchiving'
681
+ if (plugin?.isWALArchiver) return 'archiverWithoutDestination'
682
+ if (plugin?.barmanObjectName) return 'backupsNoArchiving'
683
+ if (declaration.snapshotsConfigured) return 'snapshotsOnly'
684
+ return 'none'
685
+ }