@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,46 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { expect, it, vi } from 'vitest'
3
+ import type { ReactNode } from 'react'
4
+ import { CNPGRestoreDialog } from './CNPGRestoreDialog'
5
+ const state = vi.hoisted(() => ({ coverage: 'denied', destination: false, backups: [] as any[] }))
6
+ vi.mock('../../../api/cnpg', () => ({ useCNPGWorkspace: () => ({ isLoading: false, data: { coverage: { backups: { state: state.coverage } }, objects: { clusters: [{ apiVersion: 'postgresql.cnpg.io/v1', metadata: { name: 'pg', namespace: 'db' }, spec: state.destination ? { backup: { barmanObjectStore: { destinationPath: 's3://archive' } } } : {} }], backups: state.backups } } }), useCNPGRuntime: () => ({}) }))
7
+ vi.mock('../../../api/client', () => ({ useResources: () => ({ data: [] }) }))
8
+ vi.mock('../../../api/cnpg-recovery', () => ({ useCNPGRestoreCapability: () => ({ data: { allowed: true } }) }))
9
+ vi.mock('../../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'test' } }) }))
10
+ vi.mock('../../ui/Toast', () => ({ useToast: () => ({ showSuccess: vi.fn() }) }))
11
+ vi.mock('../useCNPGNavigate', () => ({ useCNPGNavigate: () => vi.fn() }))
12
+ vi.mock('@skyhook-io/k8s-ui', async (original) => ({ ...(await original<typeof import('@skyhook-io/k8s-ui')>()), ActionConfirmDialog: ({ disabledReason, warnings, children }: { disabledReason?: string; warnings?: string[]; children: ReactNode }) => <div>{disabledReason}{warnings?.join(' · ')}{children}</div> }))
13
+ it('says sources could not be read instead of claiming no completed Backup', () => {
14
+ const render = () => renderToStaticMarkup(<CNPGRestoreDialog namespace="db" entry={{ kind: 'cluster', name: 'pg' }} onClose={() => {}} />)
15
+ state.coverage = 'denied'
16
+ expect(render()).toContain('Backup sources could not be read in db')
17
+ expect(render()).not.toContain('no completed Backup')
18
+ state.coverage = 'full'
19
+ expect(render()).toContain('Nothing to restore from yet: no backup destination and no completed Backup')
20
+ })
21
+ it('names unread Backups even when an archive destination can be used', () => {
22
+ state.coverage = 'denied'; state.destination = true
23
+ const html = renderToStaticMarkup(<CNPGRestoreDialog namespace="db" entry={{ kind: 'cluster', name: 'pg' }} onClose={() => {}} />)
24
+ expect(html).toContain('Backup sources could not be read in db')
25
+ expect(html).toContain('Barman object store (in-tree)')
26
+ expect(html).not.toContain('no completed Backup')
27
+ state.destination = false
28
+ })
29
+ it('offers a volume snapshot only its own end and says why, while an in-tree Backup keeps WAL targets', () => {
30
+ const backup = (name: string, method: string) => ({ apiVersion: 'postgresql.cnpg.io/v1', metadata: { name, namespace: 'db' }, spec: { cluster: { name: 'pg' }, method }, status: { phase: 'completed', method, stoppedAt: '2026-09-30T12:00:00Z' } })
31
+ state.coverage = 'full'
32
+ state.backups = [backup('snap', 'volumeSnapshot'), backup('base', 'barmanObjectStore')]
33
+ const render = (name: string) => renderToStaticMarkup(<CNPGRestoreDialog namespace="db" entry={{ kind: 'backup', name }} onClose={() => {}} />)
34
+ const snapshot = render('snap')
35
+ expect(snapshot).toContain('The end of this snapshot (2026-09-30 12:00:00 UTC)')
36
+ expect(snapshot).toMatch(/checked=""\/>The end of this snapshot/)
37
+ expect(snapshot).toContain('needs a WAL archive source, which this restore does not configure')
38
+ expect(snapshot).not.toContain('The latest archived WAL')
39
+ expect(snapshot).not.toContain('A point in time')
40
+ expect(snapshot).not.toContain('Last archived WAL')
41
+ const inTree = render('base')
42
+ expect(inTree).toContain('The latest archived WAL')
43
+ expect(inTree).toContain('A point in time')
44
+ expect(inTree).not.toContain('The end of this')
45
+ state.backups = []
46
+ })
@@ -0,0 +1,333 @@
1
+ import { useMemo, useState } from 'react'
2
+ import yaml from 'yaml'
3
+ import { ActionConfirmDialog, ConfirmDialog, FoldSection, isApiGroup } from '@skyhook-io/k8s-ui'
4
+ import { useCNPGRuntime, useCNPGWorkspace } from '../../../api/cnpg'
5
+ import { useCNPGRestoreCapability } from '../../../api/cnpg-recovery'
6
+ import { useResources } from '../../../api/client'
7
+ import { CNPGTargetFields } from '../creation/CNPGTargetFields'
8
+ import { cnpgTargetDraft, cnpgTargetFromManifest, cnpgTargetIssue, onlyCNPGTargetChanged, sameCNPGRecoveryIdentity, updateCNPGTargetYaml, withCNPGTarget, type CNPGTarget } from '../creation/targetModel'
9
+ import { useConnection } from '../../../context/ConnectionContext'
10
+ import { CreateResourceDialog } from '../../shared/CreateResourceDialog'
11
+ import { useToast } from '../../ui/Toast'
12
+ import { cnpgClusterFullPath } from '../paths'
13
+ import { trackCNPGOperation } from '../operations/store'
14
+ import { CNPG_RESTORE_OPERATION } from './restoreOperation'
15
+ import { CNPGRecoveryPoint } from './CNPGRecoveryPoint'
16
+ import {
17
+ buildRestoreManifest,
18
+ describeSource,
19
+ formatUTC,
20
+ pitrWarnings,
21
+ preflightFacts,
22
+ recoveryEvidenceFor,
23
+ restoreManifestHeader,
24
+ restoreSourceForBackup,
25
+ assessRestoreSources,
26
+ restoreSourcesForStore,
27
+ sourceClusterFor,
28
+ restorePermission,
29
+ restoreTargetKinds,
30
+ sourcePinsBackup,
31
+ targetIsoFrom,
32
+ type RestoreSource,
33
+ type RestoreTarget,
34
+ } from './restoreModel'
35
+ import { useCNPGNavigate } from '../useCNPGNavigate'
36
+
37
+ /** Where the restore was started from; decides the default source. */
38
+ export type CNPGRestoreEntry = { kind: 'cluster'; name: string } | { kind: 'backup'; name: string } | { kind: 'objectStore'; name: string }
39
+
40
+ /**
41
+ * Restore builds a new Cluster manifest, shows the evidence for the recovery
42
+ * target and what the manifest copies from the source, and hands it to
43
+ * Radar's create flow (server dry-run, strict create). After the create the
44
+ * user is offered the new Cluster's page, which follows the restore.
45
+ */
46
+ export function CNPGRestoreDialog({ namespace, entry, onClose }: { namespace: string; entry: CNPGRestoreEntry; onClose: () => void }) {
47
+ const navigate = useCNPGNavigate()
48
+ const { connection } = useConnection()
49
+ const [context] = useState(connection.context)
50
+ const { showSuccess } = useToast()
51
+ const workspace = useCNPGWorkspace([namespace])
52
+ const objects = workspace.data?.objects
53
+ const clusters = useMemo(() => (objects?.clusters ?? []).filter((c: any) => isApiGroup(c.apiVersion, 'postgresql.cnpg.io')), [objects])
54
+ const backups = useMemo(() => (objects?.backups ?? []).filter((b: any) => isApiGroup(b.apiVersion, 'postgresql.cnpg.io')), [objects])
55
+ const stores = useMemo(() => objects?.objectStores ?? [], [objects])
56
+
57
+ const sources = useMemo<RestoreSource[]>(() => {
58
+ if (!objects) return []
59
+ if (entry.kind === 'cluster') {
60
+ const c = clusters.find((x: any) => x.metadata?.namespace === namespace && x.metadata?.name === entry.name)
61
+ return assessRestoreSources(workspace.data, namespace, c).sources
62
+ }
63
+ if (entry.kind === 'backup') {
64
+ const b = backups.find((x: any) => x.metadata?.namespace === namespace && x.metadata?.name === entry.name)
65
+ const owner = clusters.find((x: any) => x.metadata?.namespace === namespace && x.metadata?.name === b?.spec?.cluster?.name) ?? null
66
+ const s = b ? restoreSourceForBackup(b, owner) : null
67
+ return s ? [s] : []
68
+ }
69
+ const store = stores.find((x: any) => x.metadata?.namespace === namespace && x.metadata?.name === entry.name)
70
+ return store ? restoreSourcesForStore(store) : []
71
+ }, [objects, clusters, backups, stores, entry, namespace, workspace.data])
72
+
73
+ const [sourceIdx, setSourceIdx] = useState(0)
74
+ const [confirmedArchive, setConfirmedArchive] = useState<string | null>(null)
75
+ const source = sources[sourceIdx]
76
+ const sourceCluster = useMemo(() => {
77
+ if (!source) return null
78
+ if (entry.kind === 'cluster') return clusters.find((c: any) => c.metadata?.namespace === namespace && c.metadata?.name === entry.name) ?? null
79
+ if (source.kind === 'backup' || (source.kind === 'objectStore' && source.backupName)) {
80
+ const b = backups.find((x: any) => x.metadata?.name === (source.kind === 'backup' ? source.backup : source.backupName))
81
+ return clusters.find((c: any) => c.metadata?.namespace === namespace && c.metadata?.name === b?.spec?.cluster?.name) ?? null
82
+ }
83
+ return sourceClusterFor(source, clusters, namespace)
84
+ }, [source, entry, clusters, backups, namespace])
85
+ const archiveKey = source?.kind === 'objectStore' && source.backupName ? [sourceCluster?.metadata?.uid, source.objectStore, source.serverName, source.backupID, source.majorVersion].join('/') : null
86
+ const archiveConfirmed = archiveKey !== null && confirmedArchive === archiveKey
87
+ const sourceName = sourceCluster?.metadata?.name as string | undefined
88
+ const runtime = useCNPGRuntime(namespace, sourceName ?? '', !!sourceName)
89
+
90
+ const defaultName = `${sourceName ?? (source?.kind === 'objectStore' ? source.serverName : entry.name)}-restore`
91
+ const [newName, setNewName] = useState<string | null>(null)
92
+ const name = newName ?? defaultName
93
+ const pinned = sourcePinsBackup(source)
94
+ const snapshot = source?.kind === 'backup' && !!source.snapshot
95
+ const [targetKind, setTargetKind] = useState<RestoreTarget['kind'] | null>(null)
96
+ const targetKinds = restoreTargetKinds(source)
97
+ const effectiveKind = targetKind && targetKinds.includes(targetKind) ? targetKind : targetKinds[0]
98
+ const [timeValue, setTimeValue] = useState('')
99
+ const [zone, setZone] = useState<'utc' | 'local'>('utc')
100
+ const [manifest, setManifest] = useState<string | null>(null)
101
+ const [manifestBasis, setManifestBasis] = useState<string | null>(null)
102
+ const [editingManifest, setEditingManifest] = useState(false)
103
+ const [replacementManifest, setReplacementManifest] = useState<string | null>(null)
104
+ const [targetOverrides, setTargetOverrides] = useState<Partial<CNPGTarget>>({})
105
+ const [customIdentity, setCustomIdentity] = useState(false)
106
+ const [setupStep, setSetupStep] = useState<'source' | 'target'>('source')
107
+ const storageClasses = useResources<any>('storageclasses', undefined, 'storage.k8s.io')
108
+ const restoreCap = useCNPGRestoreCapability(namespace)
109
+
110
+ const evidence = useMemo(
111
+ () => recoveryEvidenceFor(source, { sourceCluster, stores, backups, runtime: runtime.data, namespace }),
112
+ [source, sourceCluster, stores, backups, runtime.data, namespace],
113
+ )
114
+ const targetIso = effectiveKind === 'time' ? targetIsoFrom(timeValue, zone) : null
115
+ const warnings = effectiveKind === 'time' && targetIso ? pitrWarnings(targetIso, evidence) : []
116
+ const facts = preflightFacts(sourceCluster).filter((fact) => !['Instances', 'Image', 'Data storage'].includes(fact.label))
117
+ const serverName = source && source.kind !== 'backup' ? source.serverName : undefined
118
+ const recoveryTarget: RestoreTarget = effectiveKind === 'time' && targetIso ? { kind: 'time', iso: targetIso } : effectiveKind === 'backupEnd' ? { kind: 'backupEnd' } : { kind: 'latest' }
119
+ const baseManifest = source ? buildRestoreManifest({ sourceCluster, source, namespace, newName: name, target: recoveryTarget }) : { metadata: { name, namespace }, spec: { instances: 1, storage: {} } }
120
+ const targetValues: CNPGTarget = { ...cnpgTargetFromManifest(baseManifest), ...targetOverrides, name, namespace }
121
+ const catalog = baseManifest.spec.imageCatalogRef
122
+ const imageDescription = catalog ? `${catalog.kind ?? 'ImageCatalog'} ${catalog.name}, PostgreSQL ${catalog.major}` : baseManifest.spec.imageName
123
+ const requireImage = !imageDescription
124
+ const prepareManifest = (values: CNPGTarget) => restoreManifestHeader(describeSource(source!), serverName, sourceName ?? null) + yaml.stringify(withCNPGTarget(baseManifest, values), { version: '1.1' })
125
+ const retainingDraft = source && manifest !== null && prepareManifest(targetValues) === manifestBasis
126
+
127
+ if (context !== connection.context) {
128
+ return <ActionConfirmDialog
129
+ open
130
+ onClose={onClose}
131
+ onConfirm={() => {}}
132
+ title="Restore to a new cluster"
133
+ subject={{ kind: 'Cluster', namespace, name }}
134
+ context={context || undefined}
135
+ effect="This restore was prepared in a different Kubernetes context."
136
+ confirmLabel="Review manifest"
137
+ disabledReason="Close this dialog and start again in the current context."
138
+ />
139
+ }
140
+
141
+ if (replacementManifest !== null) {
142
+ return <ConfirmDialog
143
+ open
144
+ onClose={() => setReplacementManifest(null)}
145
+ onConfirm={() => {
146
+ setManifest(replacementManifest)
147
+ setManifestBasis(replacementManifest)
148
+ setCustomIdentity(false)
149
+ setReplacementManifest(null)
150
+ setEditingManifest(true)
151
+ }}
152
+ variant="warning"
153
+ showWarning={false}
154
+ title="Replace edited restore manifest?"
155
+ message="The setup changed. Rebuilding the manifest uses these setup choices and discards your YAML edits."
156
+ confirmLabel="Replace manifest"
157
+ cancelLabel="Keep editing setup"
158
+ />
159
+ }
160
+
161
+ if (editingManifest && manifest !== null) {
162
+ return (
163
+ <CreateResourceDialog
164
+ open
165
+ onClose={onClose}
166
+ onBack={(draft) => {
167
+ setManifest(draft)
168
+ const parsed = cnpgTargetDraft(draft)
169
+ if (parsed && parsed.metadata.namespace === namespace) {
170
+ const values = cnpgTargetFromManifest(parsed)
171
+ setNewName(values.name)
172
+ setTargetOverrides(values)
173
+ setManifestBasis(prepareManifest(values))
174
+ setCustomIdentity(!sameCNPGRecoveryIdentity(parsed, baseManifest))
175
+ } else setCustomIdentity(true)
176
+ setEditingManifest(false)
177
+ }}
178
+ backLabel="Back to restore setup"
179
+ initialYaml={manifest}
180
+ initialMode="create"
181
+ lockMode
182
+ title={`Restore into a new cluster ${name}`}
183
+ onCreated={(created, submittedYaml) => {
184
+ if (created.kind !== 'Cluster' || !isApiGroup(created.apiVersion, 'postgresql.cnpg.io')) return
185
+ const submitted = cnpgTargetDraft(submittedYaml)
186
+ const restoring = submitted?.metadata.name === created.name && submitted.metadata.namespace === created.namespace && !!submitted.spec.bootstrap?.recovery
187
+ if (restoring) trackCNPGOperation({
188
+ kind: CNPG_RESTORE_OPERATION,
189
+ label: `Restore into ${created.name}`,
190
+ context: connection.context,
191
+ namespace: created.namespace || namespace,
192
+ cluster: created.name,
193
+ clusterUID: created.uid,
194
+ baseline: { source: source && sameCNPGRecoveryIdentity(submitted, baseManifest) ? describeSource(source) : undefined },
195
+ })
196
+ const path = cnpgClusterFullPath(created.namespace || namespace, created.name, connection.context || undefined)
197
+ showSuccess(`Cluster ${created.name} created`, restoring ? 'The operator is restoring it from backup.' : 'Review its current state on the Cluster page.', { label: restoring ? 'Follow the restore' : 'Open Cluster', onClick: () => navigate(path) })
198
+ }}
199
+ />
200
+ )
201
+ }
202
+
203
+ const permission = restorePermission(namespace, restoreCap.data, restoreCap.error)
204
+ const availability = entry.kind === 'cluster' ? assessRestoreSources(workspace.data, namespace, clusters.find((c: any) => c.metadata?.namespace === namespace && c.metadata?.name === entry.name)) : undefined
205
+ const noSource = !workspace.isLoading && sources.length === 0
206
+ const disabledReason =
207
+ permission.blocked ??
208
+ (!workspace.isLoading && sources.length === 0
209
+ ? entry.kind === 'backup'
210
+ ? 'This Backup cannot be restored: it has not completed, or its object store and backup ID are not recorded.'
211
+ : entry.kind === 'objectStore'
212
+ ? 'This ObjectStore reports no server with backups yet.'
213
+ : availability?.unreadReason ?? availability?.disabledReason
214
+ : undefined)
215
+ const incompleteReason =
216
+ permission.pending ??
217
+ (workspace.isLoading
218
+ ? 'Loading backups and object stores…'
219
+ : setupStep === 'target' && !retainingDraft && cnpgTargetIssue(targetValues, requireImage)
220
+ ? cnpgTargetIssue(targetValues, requireImage)
221
+ : setupStep === 'target' && clusters.some((c: any) => c.metadata?.namespace === namespace && c.metadata?.name === name)
222
+ ? `A Cluster named ${name} already exists in ${namespace}.`
223
+ : source?.kind === 'objectStore' && source.backupName && !archiveConfirmed
224
+ ? 'Confirm that the selected archive still contains this named backup.'
225
+ : effectiveKind === 'time' && !targetIso
226
+ ? 'Enter the point in time to recover to.'
227
+ : undefined)
228
+
229
+ return (
230
+ <ActionConfirmDialog
231
+ open
232
+ size="wide"
233
+ onClose={onClose}
234
+ onBack={setupStep === 'target' ? () => setSetupStep('source') : undefined}
235
+ backLabel="Back to recovery point"
236
+ onConfirm={() => {
237
+ if (!source) return
238
+ if (setupStep === 'source') { setSetupStep('target'); return }
239
+ // The apiserver reads YAML 1.1, where unquoted on/off/yes are booleans (postgresql parameters are strings).
240
+ const nextManifest = prepareManifest(targetValues)
241
+ const previous = manifestBasis === null ? null : cnpgTargetDraft(manifestBasis)
242
+ const next = cnpgTargetDraft(nextManifest)
243
+ if (manifest !== null && nextManifest !== manifestBasis && previous && next && onlyCNPGTargetChanged(previous, next, targetValues)) {
244
+ const updated = updateCNPGTargetYaml(manifest, targetValues)
245
+ if (updated !== null) {
246
+ setManifest(updated)
247
+ setManifestBasis(nextManifest)
248
+ setEditingManifest(true)
249
+ return
250
+ }
251
+ }
252
+ if (manifest !== null && manifest !== manifestBasis && nextManifest !== manifestBasis) {
253
+ setReplacementManifest(nextManifest)
254
+ return
255
+ }
256
+ if (nextManifest !== manifestBasis) { setManifest(nextManifest); setCustomIdentity(false) }
257
+ setManifestBasis(nextManifest)
258
+ setEditingManifest(true)
259
+ }}
260
+ title="Restore to a new cluster"
261
+ subject={{ kind: entry.kind === 'cluster' ? 'Cluster' : entry.kind === 'backup' ? 'Backup' : 'ObjectStore', namespace, name: entry.name }}
262
+ context={connection.context || undefined}
263
+ effect="Restore backups into a separate Cluster. The source Cluster is left unchanged."
264
+ confirmLabel={setupStep === 'source' ? 'Continue to new Cluster' : customIdentity && retainingDraft ? 'Review current YAML' : 'Review manifest'}
265
+ warnings={noSource ? [] : [
266
+ ...(customIdentity ? ['Advanced YAML changed the recovery source, image or resource shape. The setup shows the original source evidence; review the current YAML for the intended configuration.'] : []),
267
+ ...(availability?.unreadReason ? [availability.unreadReason] : []),
268
+ ...(catalog && (source?.kind === 'backup' || pinned) ? [`Verify that ${catalog.kind ?? 'ImageCatalog'} ${catalog.name} contains a PostgreSQL ${catalog.major} image. Catalog availability has not been checked.`] : []),
269
+ ...(source?.majorVersion ? [`This backup was taken on PostgreSQL ${source.majorVersion}. Physical recovery requires that major version.`] : source?.kind === 'backup' || pinned ? ['This Backup does not record its PostgreSQL major. Choose a matching image; the current source image may have changed since the backup.'] : []),
270
+ ...(setupStep === 'target' ? ['The new cluster has no WAL archiving or backups until you configure them.', ...(serverName ? [`If you add archiving later, do not reuse server name "${serverName}": the new cluster would write into the archive it restores from.`] : [])] : []),
271
+ ...warnings,
272
+ ...(permission.unchecked ? [permission.unchecked] : []),
273
+ ]}
274
+ disabledReason={disabledReason}
275
+ incompleteReason={incompleteReason}
276
+ notes={manifest !== null && manifest !== manifestBasis ? ['Your YAML edits are retained. Name, instance and storage changes preserve them; changing the recovery source or time asks before replacing them.'] : []}
277
+ >
278
+ {noSource ? (
279
+ <p className="text-sm text-theme-text-secondary">
280
+ {availability?.unreadReason ?? 'A restore needs a completed Backup, or WAL archived to a backup destination. The Backups tab shows what this cluster has and where to set it up.'}
281
+ </p>
282
+ ) : (
283
+ <>
284
+ <div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-theme-text-tertiary" aria-label="Restore steps">
285
+ <span className={setupStep === 'source' ? 'font-semibold text-accent-text' : undefined}>1. Recovery point</span><span aria-hidden>→</span>
286
+ <span className={setupStep === 'target' ? 'font-semibold text-accent-text' : undefined}>2. New Cluster</span><span aria-hidden>→</span><span>3. Review & create</span>
287
+ </div>
288
+ {manifest !== null && <button type="button" className="btn-secondary mb-4 px-3 py-1.5 text-xs" onClick={() => setEditingManifest(true)}>Continue editing current YAML</button>}
289
+ {source?.kind === 'objectStore' && source.backupName && <label className="mb-4 flex items-start gap-2 text-sm text-theme-text-secondary">
290
+ <input type="checkbox" checked={archiveConfirmed} onChange={(event) => setConfirmedArchive(event.target.checked ? archiveKey : null)} className="mt-1" />
291
+ <span>I confirm ObjectStore {source.objectStore}, archive server {source.serverName}, still contains backup {source.backupID}. The plugin does not record its archive destination in the Backup; this is the Cluster’s current destination.</span>
292
+ </label>}
293
+ {setupStep === 'source' ? <CNPGRecoveryPoint
294
+ sources={sources}
295
+ sourceIndex={sourceIdx}
296
+ onSourceChange={(index) => { setSourceIdx(index); setTargetKind(null); setTargetOverrides({}); setConfirmedArchive(null) }}
297
+ targetKind={effectiveKind}
298
+ onTargetChange={setTargetKind}
299
+ timeValue={timeValue}
300
+ onTimeChange={setTimeValue}
301
+ zone={zone}
302
+ onZoneChange={setZone}
303
+ targetIso={targetIso}
304
+ evidence={evidence}
305
+ sourceClusterName={sourceName}
306
+ /> : <>
307
+ <div className="mb-4 rounded-lg border border-theme-border bg-theme-base p-3 text-sm">
308
+ <div className="font-medium text-theme-text-primary">{describeSource(source!)}</div>
309
+ <div className="mt-1 text-theme-text-secondary">{effectiveKind === 'time' && targetIso ? `Recover to ${formatUTC(targetIso)}` : effectiveKind === 'backupEnd' ? `Recover to the end of this ${snapshot ? 'snapshot' : 'backup'}` : 'Recover to the latest archived WAL'}</div>
310
+ </div>
311
+ <CNPGTargetFields value={targetValues} onChange={(values) => { setNewName(values.name); setTargetOverrides(values) }} idPrefix="cnpg-restore" namespaceFixed storageClasses={storageClasses.data?.map((item) => item.metadata.name)} imageDescription={imageDescription} imageRequired={requireImage} defaultClassDisabled={(manifest !== null ? cnpgTargetDraft(manifest) : baseManifest)?.spec.storage?.pvcTemplate?.storageClassName === ''} />
312
+ {storageClasses.isError && <p className="text-xs text-theme-text-tertiary">Storage class suggestions could not be read. You can enter a name directly.</p>}
313
+ <div className="mt-4">
314
+ <FoldSection title="Other source settings" summary={sourceName ? `${sourceName} · ${facts.length} settings seed the manifest` : 'Source settings are unavailable'} attention={!sourceName}>
315
+ <p className="mb-2 text-xs text-theme-text-tertiary">{sourceName ? 'These settings seed the manifest. Review Advanced YAML for the final configuration and any edits.' : 'Review Advanced YAML for PostgreSQL settings, resource requests and placement.'}</p>
316
+ <dl className="grid grid-cols-[9rem_minmax(0,1fr)] gap-x-3 gap-y-1 text-sm">
317
+ {facts.map((f) => (
318
+ <div key={f.path} className="contents">
319
+ <dt className="text-xs text-theme-text-secondary">{f.label}</dt>
320
+ <dd className={f.copied ? 'min-w-0 break-words text-theme-text-primary' : 'min-w-0 break-words text-theme-text-tertiary'}>
321
+ {f.value} <span className="font-mono text-[11px] text-theme-text-tertiary">{f.path}</span>
322
+ </dd>
323
+ </div>
324
+ ))}
325
+ </dl>
326
+ </FoldSection>
327
+ </div>
328
+ </>}
329
+ </>
330
+ )}
331
+ </ActionConfirmDialog>
332
+ )
333
+ }
@@ -0,0 +1,182 @@
1
+ import { useMemo } from 'react'
2
+ import { Badge, StatusDot, formatAge, toneTextClass, type HealthLevel, formatGrant } from '@skyhook-io/k8s-ui'
3
+ import { useCNPGRecovery, type CNPGContainerState, type CNPGRecoveryResponse, type CNPGRecoverySpec } from '../../../api/cnpg-recovery'
4
+ import { buildWorkloadPath } from '../../../utils/navigation'
5
+ import { observeRestore, restoreNextSteps, type RestoreBackupDeclared, type RestoreNextStep, type RestoreNextStepId, type RestoreObservation } from './restoreModel'
6
+ import { useCNPGNavigate } from '../useCNPGNavigate'
7
+
8
+ /**
9
+ * The restore observer: the recovery snapshot of one Cluster and where its
10
+ * restore stands. Self-contained so a generic operation tracker can register
11
+ * it as the "restore" operation's observer.
12
+ */
13
+ export function useRestoreObservation(namespace: string, name: string, enabled = true): {
14
+ observation: RestoreObservation | null
15
+ snapshot: CNPGRecoveryResponse | undefined
16
+ error: unknown
17
+ } {
18
+ const probe = useCNPGRecovery(namespace, name, { enabled })
19
+ const observation = useMemo(() => (probe.data?.recovery ? observeRestore(probe.data) : null), [probe.data])
20
+ return { observation, snapshot: probe.data, error: probe.error }
21
+ }
22
+
23
+ export function describeRecoverySource(r: CNPGRecoverySpec): string {
24
+ const target = r.target
25
+ ? Object.entries(r.target)
26
+ .map(([k, v]) => `${k} ${String(v)}`)
27
+ .join(', ')
28
+ : 'latest archived WAL'
29
+ switch (r.sourceKind) {
30
+ case 'objectStore':
31
+ return `ObjectStore ${r.objectStore ?? '?'} · server ${r.serverName ?? '?'} · to ${target}`
32
+ case 'barmanObjectStore':
33
+ return `Barman object store · server ${r.serverName ?? '?'} · to ${target}`
34
+ case 'backup':
35
+ // Without a target, an in-tree Backup replays its archive's WAL but a
36
+ // volume snapshot stops at its own end; the method is not read here.
37
+ return `Backup ${r.backup ?? '?'}${r.target ? ` · to ${target}` : ''}`
38
+ case 'volumeSnapshots':
39
+ return 'Volume snapshots'
40
+ default:
41
+ return 'an external source'
42
+ }
43
+ }
44
+
45
+ const STATE_SEVERITY: Record<RestoreObservation['state'], 'success' | 'error' | 'neutral' | 'warning'> = {
46
+ completed: 'success',
47
+ failed: 'error',
48
+ progressing: 'neutral',
49
+ unobservable: 'neutral',
50
+ }
51
+
52
+ function ContainerChip({ c, init }: { c: CNPGContainerState; init?: boolean }) {
53
+ const tone: HealthLevel =
54
+ c.state === 'running' ? 'healthy' : c.state === 'terminated' ? ((c.exitCode ?? 0) === 0 ? 'neutral' : 'unhealthy') : c.reason && c.reason !== 'PodInitializing' && c.reason !== 'ContainerCreating' ? 'alert' : 'unknown'
55
+ const label = c.state === 'terminated' ? (c.exitCode === 0 ? 'done' : `exit ${c.exitCode}`) : c.reason ?? c.state
56
+ return (
57
+ <span className="inline-flex items-center gap-1 rounded border border-theme-border px-1.5 py-0.5 text-[11px] text-theme-text-secondary">
58
+ <StatusDot tone={tone} />
59
+ {init && <span className="text-theme-text-tertiary">init</span>}
60
+ <span className="font-mono">{c.name}</span>
61
+ <span>{label}</span>
62
+ {c.restarts > 0 && <span className="text-theme-text-tertiary">· {c.restarts} restarts</span>}
63
+ </span>
64
+ )
65
+ }
66
+
67
+ /**
68
+ * "Restore in progress" on a Cluster bootstrapped from backups: phase, the
69
+ * recovery Job's Pods with their init containers, links to their logs, and
70
+ * the Warning events about them. Once complete it collapses to one line.
71
+ */
72
+ export interface CNPGRestoreNextStepsHost {
73
+ /** What the Cluster spec declares for backups; undefined when not read. */
74
+ backup?: RestoreBackupDeclared
75
+ onOpen: (step: RestoreNextStepId) => void
76
+ }
77
+
78
+ const STEP_TONE: Record<RestoreNextStep['state'], HealthLevel> = { done: 'healthy', partial: 'degraded', todo: 'degraded', unknown: 'unknown' }
79
+ const STEP_TEXT: Record<RestoreNextStep['state'], string> = { done: 'done', partial: 'partly done', todo: 'to do', unknown: '' }
80
+
81
+ function NextSteps({ validationRecorded, host }: { validationRecorded: boolean; host: CNPGRestoreNextStepsHost }) {
82
+ const steps = restoreNextSteps({ validationRecorded, backup: host.backup })
83
+ return (
84
+ <div className="mt-2 border-t border-theme-border pt-2">
85
+ <div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Next steps</div>
86
+ <ul className="space-y-1">
87
+ {steps.map((s) => (
88
+ <li key={s.id} className="flex flex-wrap items-center gap-x-2 text-xs">
89
+ <StatusDot tone={STEP_TONE[s.state]} />
90
+ <button type="button" onClick={() => host.onOpen(s.id)} className="text-accent-text hover:underline">
91
+ {s.label}
92
+ </button>
93
+ {s.state !== 'unknown' && <span className={toneTextClass(STEP_TONE[s.state])}>{STEP_TEXT[s.state]}</span>}
94
+ {s.note && <span className="text-theme-text-tertiary">· {s.note}</span>}
95
+ </li>
96
+ ))}
97
+ </ul>
98
+ </div>
99
+ )
100
+ }
101
+
102
+ export function CNPGRestoreProgress({ namespace, name, nextSteps }: { namespace: string; name: string; nextSteps?: CNPGRestoreNextStepsHost }) {
103
+ const navigate = useCNPGNavigate()
104
+ const { observation, snapshot } = useRestoreObservation(namespace, name)
105
+ if (!observation || !snapshot?.recovery) return null
106
+ const openLogs = (pod: string) => navigate(buildWorkloadPath({ kind: 'pods', namespace, name: pod, tab: 'logs' }))
107
+ const source = describeRecoverySource(snapshot.recovery)
108
+
109
+ if (observation.state === 'completed') {
110
+ return (
111
+ <div className="mb-3 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm">
112
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
113
+ <Badge severity="success" size="sm">Restored</Badge>
114
+ <span className="text-theme-text-secondary">from {source}</span>
115
+ <span className="text-theme-text-tertiary">· {observation.detail}</span>
116
+ </div>
117
+ {nextSteps && <NextSteps validationRecorded={!!snapshot.validation} host={nextSteps} />}
118
+ </div>
119
+ )
120
+ }
121
+
122
+ const warnings = snapshot.events.filter((e) => e.type === 'Warning').slice(0, 5)
123
+ return (
124
+ <section aria-label="Restore progress" className="mb-3 overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
125
+ <div className="flex flex-wrap items-center gap-2 border-b border-theme-border px-4 py-2.5">
126
+ <Badge severity={STATE_SEVERITY[observation.state]} size="sm">
127
+ {observation.state === 'failed' ? 'Restore failed' : observation.state === 'unobservable' ? 'Restore' : 'Restore in progress'}
128
+ </Badge>
129
+ <span className="text-sm font-medium text-theme-text-primary">{observation.title}</span>
130
+ <span className="text-xs text-theme-text-tertiary">from {source}</span>
131
+ </div>
132
+ <div className="space-y-3 px-4 py-3 text-sm">
133
+ {observation.detail && <div className="text-theme-text-secondary">{observation.detail}</div>}
134
+ {observation.recoveryPods.length > 0 && (
135
+ <div className="space-y-2">
136
+ {observation.recoveryPods.map((p) => (
137
+ <div key={p.uid} className="flex flex-wrap items-center gap-2">
138
+ <span className="font-mono text-[12.5px] text-theme-text-primary">{p.name}</span>
139
+ <span className="text-xs text-theme-text-tertiary">
140
+ {p.phase}
141
+ {p.job ? ` · Job ${p.job}` : ''}
142
+ {!p.ownerVerified ? ' · Job ownership not verified' : ''}
143
+ {p.startedAt ? ` · started ${formatAge(p.startedAt)} ago` : ''}
144
+ </span>
145
+ {p.initContainers.map((c) => <ContainerChip key={`i-${c.name}`} c={c} init />)}
146
+ {p.containers.map((c) => <ContainerChip key={c.name} c={c} />)}
147
+ <button type="button" onClick={() => openLogs(p.name)} className="text-xs text-accent-text hover:underline">
148
+ Logs
149
+ </button>
150
+ </div>
151
+ ))}
152
+ </div>
153
+ )}
154
+ {observation.logsPod && !observation.recoveryPods.some((p) => p.name === observation.logsPod?.name) && (
155
+ <button type="button" onClick={() => openLogs(observation.logsPod!.name)} className="text-xs text-accent-text hover:underline">
156
+ Logs of {observation.logsPod.name}
157
+ </button>
158
+ )}
159
+ {snapshot.coverage.pods?.state !== 'ok' && (
160
+ <div className="text-xs text-theme-text-tertiary">Recovery Pods not visible: {snapshot.coverage.pods?.grant ? `needs ${formatGrant(snapshot.coverage.pods.grant)}` : snapshot.coverage.pods?.reason}</div>
161
+ )}
162
+ {warnings.length > 0 && (
163
+ <div>
164
+ <div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Warning events</div>
165
+ <ul className="space-y-1">
166
+ {warnings.map((e, i) => (
167
+ <li key={`${e.kind}/${e.name}/${e.reason}/${i}`} className="text-xs text-theme-text-secondary">
168
+ <span className="font-medium text-theme-text-primary">{e.reason}</span> on {e.kind} {e.name}
169
+ {e.lastSeen ? ` · ${formatAge(e.lastSeen)} ago` : ''}
170
+ {e.count > 1 ? ` · ×${e.count}` : ''}: {e.message}
171
+ </li>
172
+ ))}
173
+ </ul>
174
+ </div>
175
+ )}
176
+ <div className="text-[11px] text-theme-text-tertiary">
177
+ Complete once a full recovery Job completes or a restored instance is ready. Sampled {formatAge(snapshot.capturedAt)} ago.
178
+ </div>
179
+ </div>
180
+ </section>
181
+ )
182
+ }