@skyhook-io/radar-app 1.16.3 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +138 -93
  4. package/src/RadarApp.tsx +27 -17
  5. package/src/api/actions.errorCode.test.ts +40 -0
  6. package/src/api/actions.test.ts +48 -0
  7. package/src/api/actions.ts +79 -0
  8. package/src/api/apiResources.navigation.test.tsx +232 -0
  9. package/src/api/apiResources.ts +46 -3
  10. package/src/api/client.goneResource.test.ts +49 -0
  11. package/src/api/client.helmSource.test.ts +39 -0
  12. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  13. package/src/api/client.namespaceSwitch.test.ts +107 -0
  14. package/src/api/client.ts +381 -47
  15. package/src/api/cnpg-ha.live.test.ts +140 -0
  16. package/src/api/cnpg-ha.test.ts +78 -0
  17. package/src/api/cnpg-ha.ts +19 -0
  18. package/src/api/cnpg-history.ts +123 -0
  19. package/src/api/cnpg-inspect.ts +102 -0
  20. package/src/api/cnpg-protection.ts +112 -0
  21. package/src/api/cnpg-recovery.test.tsx +60 -0
  22. package/src/api/cnpg-recovery.ts +262 -0
  23. package/src/api/cnpg-sessions.ts +175 -0
  24. package/src/api/cnpg-storage.ts +138 -0
  25. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  26. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  27. package/src/api/cnpg.ts +428 -10
  28. package/src/api/diagnose.test.ts +40 -0
  29. package/src/api/diagnose.ts +12 -1
  30. package/src/api/radarFeatures.render.test.tsx +65 -3
  31. package/src/api/radarFeatures.ts +6 -0
  32. package/src/api/reviewedActionMutation.test.tsx +164 -0
  33. package/src/api/reviewedActionMutation.ts +66 -0
  34. package/src/api/timelineSource.test.ts +9 -0
  35. package/src/api/timelineSource.ts +8 -0
  36. package/src/api/traffic.sources.test.ts +55 -0
  37. package/src/api/traffic.ts +173 -14
  38. package/src/components/applications/ApplicationsView.tsx +3 -0
  39. package/src/components/audit/AuditView.tsx +9 -0
  40. package/src/components/capacity/CapacityActivity.tsx +3 -31
  41. package/src/components/capacity/CapacityDemand.tsx +4 -36
  42. package/src/components/capacity/CapacityOverview.tsx +217 -56
  43. package/src/components/capacity/CapacityPoolDetail.tsx +24 -62
  44. package/src/components/capacity/CapacityView.test.tsx +354 -1
  45. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  46. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  47. package/src/components/capacity/routes.ts +42 -0
  48. package/src/components/capacity/shared.tsx +23 -150
  49. package/src/components/capacity/workspaceRoute.ts +18 -0
  50. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  51. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  52. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  53. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  54. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  55. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  56. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  57. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  58. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  59. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  60. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  61. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  62. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  63. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  64. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  65. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  66. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  67. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  68. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  69. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  70. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  71. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  72. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  73. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  74. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  75. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  76. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  77. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  78. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  79. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  80. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  81. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  82. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  83. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  84. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  85. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  86. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  87. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  88. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  89. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  90. package/src/components/cnpg/CNPGProtection.tsx +230 -124
  91. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  92. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  93. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  94. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  95. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  96. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  97. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  98. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  99. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  100. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  101. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  102. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  103. package/src/components/cnpg/CNPGView.tsx +34 -21
  104. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  105. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  106. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  107. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  108. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  109. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  110. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  111. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  112. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  113. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  114. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  115. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  116. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  117. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  118. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  119. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  120. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  121. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  122. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  123. package/src/components/cnpg/actions/actionModel.ts +127 -0
  124. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  125. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  126. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  127. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  128. package/src/components/cnpg/archivingRepair.ts +145 -0
  129. package/src/components/cnpg/baseBackup.test.ts +36 -0
  130. package/src/components/cnpg/baseBackup.ts +36 -0
  131. package/src/components/cnpg/blocking.test.ts +59 -0
  132. package/src/components/cnpg/blocking.ts +131 -0
  133. package/src/components/cnpg/coverageText.test.ts +27 -0
  134. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  135. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  136. package/src/components/cnpg/creation/targetModel.ts +149 -0
  137. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  138. package/src/components/cnpg/fleetStatus.ts +43 -0
  139. package/src/components/cnpg/formFields.ts +2 -0
  140. package/src/components/cnpg/grantText.test.tsx +11 -0
  141. package/src/components/cnpg/host.test.tsx +24 -0
  142. package/src/components/cnpg/host.tsx +101 -0
  143. package/src/components/cnpg/inspectModel.test.ts +120 -0
  144. package/src/components/cnpg/inspectModel.ts +140 -0
  145. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  146. package/src/components/cnpg/logicalSlots.ts +29 -0
  147. package/src/components/cnpg/lsn.test.ts +40 -0
  148. package/src/components/cnpg/lsn.ts +43 -0
  149. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  150. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  151. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  152. package/src/components/cnpg/operations/model.test.ts +510 -0
  153. package/src/components/cnpg/operations/model.ts +499 -0
  154. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  155. package/src/components/cnpg/operations/presentation.ts +84 -0
  156. package/src/components/cnpg/operations/store.ts +98 -0
  157. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  158. package/src/components/cnpg/operatorStatus.ts +194 -0
  159. package/src/components/cnpg/paths.test.ts +54 -0
  160. package/src/components/cnpg/paths.ts +47 -4
  161. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  162. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  163. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  164. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  165. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  166. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  167. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  168. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  169. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  172. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  173. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  174. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  175. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  176. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  177. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  178. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  179. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  180. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  181. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  182. package/src/components/cnpg/refresh.test.ts +38 -0
  183. package/src/components/cnpg/refresh.ts +17 -0
  184. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  185. package/src/components/cnpg/routes.test.ts +34 -3
  186. package/src/components/cnpg/routes.ts +60 -48
  187. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  188. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  189. package/src/components/cnpg/runtimeModel.ts +196 -0
  190. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  191. package/src/components/cnpg/runtimeParts.tsx +79 -0
  192. package/src/components/cnpg/shared.test.tsx +59 -0
  193. package/src/components/cnpg/shared.tsx +100 -182
  194. package/src/components/cnpg/storageModel.test.ts +107 -0
  195. package/src/components/cnpg/storageModel.ts +101 -0
  196. package/src/components/cnpg/trendSamples.test.ts +148 -0
  197. package/src/components/cnpg/trendSamples.ts +222 -0
  198. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  199. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  200. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  201. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  202. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  203. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  204. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  205. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  206. package/src/components/cnpg/workspaceRoute.ts +23 -0
  207. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  208. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  209. package/src/components/cost/CostView.test.ts +2 -1
  210. package/src/components/cost/CostView.tsx +0 -2
  211. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  212. package/src/components/cost/errors.ts +3 -1
  213. package/src/components/cost/source.test.ts +10 -0
  214. package/src/components/cost/source.ts +5 -1
  215. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  216. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  217. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  218. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  219. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  220. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  221. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  222. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  223. package/src/components/diagnose/InvestigationView.tsx +39 -2
  224. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  225. package/src/components/diagnose/agentCatalog.ts +2 -2
  226. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  227. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  228. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  229. package/src/components/dock/BottomDock.tsx +2 -0
  230. package/src/components/dock/TerminalTab.test.tsx +17 -0
  231. package/src/components/dock/TerminalTab.tsx +7 -3
  232. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  233. package/src/components/execution/BatchExecutionView.tsx +11 -11
  234. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  235. package/src/components/gitops/GitOpsView.tsx +16 -23
  236. package/src/components/helm/ChartBrowser.tsx +3 -3
  237. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  238. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  239. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  240. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  241. package/src/components/helm/RevisionHistory.tsx +3 -2
  242. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  243. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  244. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  245. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  246. package/src/components/helm/ValuesViewer.tsx +169 -78
  247. package/src/components/home/ActivitySummary.tsx +2 -1
  248. package/src/components/home/ClusterHealthCard.tsx +7 -0
  249. package/src/components/home/HomeView.tsx +20 -9
  250. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  251. package/src/components/home/TopologyPreview.tsx +2 -2
  252. package/src/components/home/mcpToolCatalog.ts +1 -1
  253. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  254. package/src/components/issues/IssuesPane.test.ts +65 -1
  255. package/src/components/issues/IssuesPane.tsx +188 -25
  256. package/src/components/issues/uncheckedSources.ts +21 -0
  257. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  258. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  259. package/src/components/resource/PVCUsageBar.tsx +15 -6
  260. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  261. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  262. package/src/components/resources/CompositeRenderer.tsx +4 -4
  263. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  264. package/src/components/resources/ResourcesView.tsx +26 -12
  265. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  266. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  267. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  268. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  269. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  270. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  271. package/src/components/settings/SettingsDialog.tsx +150 -34
  272. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  273. package/src/components/timeline/TimelineList.tsx +25 -24
  274. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  275. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  276. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  277. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  278. package/src/components/timeline/TimelineView.tsx +25 -45
  279. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  280. package/src/components/timeline/useStickyError.test.tsx +47 -0
  281. package/src/components/timeline/useStickyError.ts +15 -0
  282. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  283. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  284. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  285. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  286. package/src/components/traffic/TrafficGraph.tsx +109 -26
  287. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  288. package/src/components/traffic/TrafficView.tsx +582 -149
  289. package/src/components/traffic/trafficFilters.test.ts +398 -1
  290. package/src/components/traffic/trafficFilters.ts +408 -0
  291. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  292. package/src/components/ui/Markdown.test.tsx +65 -0
  293. package/src/components/ui/Markdown.tsx +144 -127
  294. package/src/components/whats-new/releaseNotes.ts +63 -1
  295. package/src/components/workload/WorkloadView.test.ts +11 -1
  296. package/src/components/workload/WorkloadView.tsx +162 -529
  297. package/src/components/workspace/layout.test.tsx +29 -0
  298. package/src/components/workspace/layout.tsx +259 -0
  299. package/src/components/workspace/table.ts +7 -0
  300. package/src/context/TimelineCustomization.tsx +15 -0
  301. package/src/contexts/CapabilitiesContext.tsx +1 -1
  302. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  303. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  304. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  305. package/src/integrations/resourceHost.test.tsx +55 -0
  306. package/src/integrations/resourceHost.ts +99 -0
  307. package/src/integrations/resourceHosts.test.tsx +141 -0
  308. package/src/integrations/resourceHosts.tsx +119 -0
  309. package/src/integrations/workspaceRoute.ts +20 -0
  310. package/src/integrations/workspaceRoutes.test.ts +40 -0
  311. package/src/integrations/workspaceRoutes.ts +22 -0
  312. package/src/integrations/workspaceScreens.tsx +14 -0
  313. package/src/utils/drawer-trail.ts +35 -0
  314. package/src/utils/kindFilterStore.test.ts +147 -0
  315. package/src/utils/kindFilterStore.ts +91 -0
  316. package/src/utils/navigation.test.ts +13 -1
  317. package/src/utils/navigation.ts +39 -2
  318. package/src/utils/page-links.ts +21 -0
  319. package/src/utils/tabStorage.ts +40 -0
  320. package/src/utils/viewMemory.test.ts +78 -0
  321. package/src/utils/viewMemory.ts +54 -0
@@ -1,34 +1,38 @@
1
- import { useMemo } from 'react'
1
+ import { useId, useMemo, useState } from 'react'
2
+ import { X } from 'lucide-react'
3
+ import { formatRelativeAgeTime } from '@skyhook-io/k8s-ui/utils/format'
2
4
  import {
3
5
  Badge,
6
+ DialogPortal,
7
+ SectionHeading,
8
+ CNPGClusterBackupFacts,
9
+ backupsForScheduledBackup,
10
+ cnpgScheduleDestinationBlocker,
11
+ refToSelectedResource,
4
12
  FactValue,
5
- formatAge,
6
13
  formatDuration,
7
14
  getCNPGBackupStatus,
8
15
  getCNPGClusterBarmanPlugin,
9
16
  getCNPGObjectStoreDestination,
10
- getCNPGScheduledBackupStatus,
17
+ getCNPGScheduledBackupStatus, getCNPGScheduledBackupOverdueMs,
11
18
  inferredObjectStoreHealth,
12
- isApiGroup,
19
+ cnpgBackupRunsInWindow,
20
+ cnpgBackupRunInFlight,
21
+ CNPG_BACKUP_RUN_WINDOW_MS,
22
+ coverageReadable,
23
+ cnpgCoverageGap,
24
+ getCNPGScheduledBackupNextSchedule,
13
25
  toneTextClass,
14
26
  usersOfObjectStore,
15
27
  type CNPGFleetRow,
16
28
  type HealthLevel,
29
+ Tooltip,
17
30
  } from '@skyhook-io/k8s-ui'
18
- import {
19
- CNPGWorkspaceHeader,
20
- CoverageNotice,
21
- FilterChips,
22
- Mono,
23
- ScreenBody,
24
- SectionTable,
25
- Sub,
26
- clusterResource,
27
- coverageEmpty,
28
- cnpgResource,
29
- namespaceChip,
30
- type CNPGScreenProps,
31
- } from './shared'
31
+ import { CNPGWorkspaceHeader, CoverageNotice, clusterResource, coverageEmpty, cnpgResource, type CNPGScreenProps } from './shared'
32
+ import { BreakText, FilterChips, Mono, namespaceChip, PathText, ScreenBody, SectionTable, Segments, Sub } from '../workspace/layout'
33
+ import { useCNPGNavigate } from './useCNPGNavigate'
34
+ import { cnpgClusterFullPath } from './paths'
35
+ import { currentPageLabel } from '../../utils/page-links'
32
36
 
33
37
  const SEVERITY: Record<HealthLevel, 'success' | 'warning' | 'alert' | 'error' | 'neutral'> = {
34
38
  healthy: 'success',
@@ -39,18 +43,12 @@ const SEVERITY: Record<HealthLevel, 'success' | 'warning' | 'alert' | 'error' |
39
43
  neutral: 'neutral',
40
44
  }
41
45
 
42
- const WEEK_MS = 7 * 24 * 60 * 60 * 1000
43
-
44
- function backupTime(b: any): string | undefined {
45
- return b?.status?.stoppedAt || b?.status?.startedAt || b?.metadata?.creationTimestamp
46
- }
47
-
48
46
  function backupStart(b: any): string | undefined {
49
47
  return b?.status?.startedAt || b?.metadata?.creationTimestamp
50
48
  }
51
49
 
52
50
  function ageText(ts?: string): string {
53
- return ts ? `${formatAge(ts)} ago` : '—'
51
+ return formatRelativeAgeTime(ts, '—')
54
52
  }
55
53
 
56
54
  interface StoreRow {
@@ -90,6 +88,60 @@ function storeHealth(store: any, users: CNPGFleetRow[]): StoreRow['health'] {
90
88
  }
91
89
  }
92
90
 
91
+ // Enough to see the pattern of recent runs without pushing schedules and
92
+ // destinations off the page.
93
+ const RUNS_SHOWN = 10
94
+
95
+ function isFailedRun(b: any): boolean {
96
+ const level = getCNPGBackupStatus(b).level
97
+ return level === 'unhealthy' || level === 'alert'
98
+ }
99
+
100
+ function runDetail(b: any): string {
101
+ const started = Date.parse(backupStart(b) ?? '')
102
+ const stopped = Date.parse(b?.status?.stoppedAt ?? '')
103
+ const method = b?.status?.method || b?.spec?.method
104
+ const took = Number.isFinite(started) && Number.isFinite(stopped) && stopped >= started ? `took ${formatDuration(stopped - started)}` : null
105
+ return [method, took].filter(Boolean).join(' · ') || '—'
106
+ }
107
+
108
+ // The newest Backup the schedule created and how it ended; the schedule's own
109
+ // lastScheduleTime says only that it fired.
110
+ function LastRun({ schedule, backups, readable }: { schedule: any; backups: any[]; readable: boolean }) {
111
+ const fired = schedule?.status?.lastScheduleTime
112
+ const last = backupsForScheduledBackup(schedule, backups)[0]
113
+ if (!fired && !last) return <span className="text-theme-text-tertiary">Not yet</span>
114
+ if (!last) {
115
+ return (
116
+ <>
117
+ {ageText(fired)}
118
+ <Sub>{readable ? 'no Backup found for it' : 'outcome needs list backups'}</Sub>
119
+ </>
120
+ )
121
+ }
122
+ // Settled Backups older than 7 days are not loaded, so the newest one here
123
+ // can be from an earlier run than the schedule's last firing.
124
+ const firedAt = Date.parse(fired ?? '')
125
+ const startedAt = Date.parse(backupStart(last) ?? '')
126
+ if (Number.isFinite(firedAt) && Number.isFinite(startedAt) && startedAt < firedAt - 2 * 60_000) {
127
+ return (
128
+ <>
129
+ {ageText(fired)}
130
+ <Sub>its Backup is not loaded</Sub>
131
+ </>
132
+ )
133
+ }
134
+ const st = getCNPGBackupStatus(last)
135
+ return (
136
+ <>
137
+ {ageText(backupStart(last) ?? fired)}
138
+ <Sub>
139
+ {st.level === 'healthy' ? <Badge severity="success" size="sm">{st.text.toLowerCase()}</Badge> : <span className={toneTextClass(st.level)}>{st.text.toLowerCase()}</span>}
140
+ </Sub>
141
+ </>
142
+ )
143
+ }
144
+
93
145
  export function CNPGProtection({
94
146
  data,
95
147
  fleet,
@@ -101,25 +153,26 @@ export function CNPGProtection({
101
153
  onClearNamespaces,
102
154
  scopeCluster,
103
155
  }: CNPGScreenProps & { scopeCluster?: { namespace: string; name: string } }) {
156
+ const evidenceTitleId = useId()
157
+ const navigate = useCNPGNavigate()
158
+ const [evidenceKey, setEvidenceKey] = useState<string | null>(null)
159
+ const evidence = fleet.rows.find((row) => row.key === evidenceKey)
104
160
  const clusterFilter = scopeCluster ? `${scopeCluster.namespace}/${scopeCluster.name}` : searchParams.get('cluster')
105
161
  const rows = useMemo(
106
162
  () => fleet.rows.filter((r) => !clusterFilter || `${r.namespace}/${r.name}` === clusterFilter),
107
163
  [fleet.rows, clusterFilter],
108
164
  )
109
165
 
110
- const failed = useMemo(() => {
111
- const now = Date.now()
112
- return (data.objects.backups ?? [])
113
- .filter((b) => isApiGroup(b.apiVersion, 'postgresql.cnpg.io'))
114
- .filter((b) => {
115
- const level = getCNPGBackupStatus(b).level
116
- if (level !== 'unhealthy' && level !== 'alert') return false
117
- const t = Date.parse(backupTime(b) ?? '')
118
- return Number.isFinite(t) && now - t <= WEEK_MS
119
- })
120
- .filter((b) => !clusterFilter || `${b.metadata?.namespace}/${b.spec?.cluster?.name}` === clusterFilter)
121
- .sort((a, b) => Date.parse(backupTime(b) ?? '') - Date.parse(backupTime(a) ?? ''))
122
- }, [data.objects.backups, clusterFilter])
166
+ const runs = useMemo(
167
+ () => cnpgBackupRunsInWindow(data.objects.backups ?? [])
168
+ .filter((b) => !clusterFilter || `${b.metadata?.namespace}/${b.spec?.cluster?.name}` === clusterFilter),
169
+ [data.objects.backups, clusterFilter],
170
+ )
171
+ const failedRuns = useMemo(() => runs.filter((b) => isFailedRun(b)), [runs])
172
+ const [runFilter, setRunFilter] = useState<'failed' | 'all' | null>(null)
173
+ const showRuns = runFilter ?? (scopeCluster || failedRuns.length === 0 ? 'all' : 'failed')
174
+ const [allRuns, setAllRuns] = useState(false)
175
+ const shownRuns = showRuns === 'failed' ? failedRuns : runs
123
176
 
124
177
  const stores = useMemo<StoreRow[]>(() => {
125
178
  return (data.objects.objectStores ?? []).map((s) => {
@@ -144,73 +197,59 @@ export function CNPGProtection({
144
197
  ...(clusterFilter ? [{ label: `Cluster: ${clusterFilter}`, onClear: () => onSetParams({ cluster: null }) }] : []),
145
198
  ...namespaceChip(namespaces, onClearNamespaces),
146
199
  ]
147
- const backupsReadable = data.coverage.backups?.state === 'full'
200
+ const backupsCoverage = data.coverage.backups!
201
+ const scopedNamespace = scopeCluster?.namespace ?? clusterFilter?.split('/')[0]
202
+ const backupsReadable = coverageReadable(backupsCoverage, scopedNamespace)
203
+ const countText = (count: number) => backupsReadable ? String(count) : count > 0 ? `≥${count}` : '?'
204
+ const backupsGap = backupsReadable ? undefined : cnpgCoverageGap(backupsCoverage, 'Backups', scopedNamespace)
148
205
 
149
- return (
150
- <div className="flex min-h-0 flex-1 flex-col">
151
- {!scopeCluster && (
152
- <CNPGWorkspaceHeader
153
- title="Protection"
154
- subtitle="Backup outcomes, schedules, destinations and recovery evidence for every cluster. Configured, backed up and restore-tested are separate facts."
155
- />
156
- )}
157
- <ScreenBody>
158
- <CoverageNotice fleet={fleet} data={data} />
206
+ const body = (
207
+ <>
208
+ <DialogPortal open={!!evidence} onClose={() => setEvidenceKey(null)} ariaLabelledBy={evidenceTitleId} className="w-full max-w-3xl">
209
+ <div className="flex items-start justify-between gap-3 border-b border-theme-border p-4"><div><h3 id={evidenceTitleId} className="text-lg font-semibold text-theme-text-primary">Recovery evidence</h3><p className="text-xs text-theme-text-tertiary">{evidence?.namespace}/{evidence?.name}</p></div><button type="button" onClick={() => setEvidenceKey(null)} aria-label="Close recovery evidence" className="rounded p-1 text-theme-text-secondary hover:bg-theme-hover"><X className="h-5 w-5" /></button></div>
210
+ <div className="max-h-[70vh] overflow-y-auto p-4">{evidence && <CNPGClusterBackupFacts row={evidence} onNavigate={(ref) => { setEvidenceKey(null); onInspect(refToSelectedResource(ref)) }} />}</div>
211
+ <div className="flex justify-end border-t border-theme-border p-4"><button type="button" onClick={() => { if (evidence) navigate(cnpgClusterFullPath(evidence.namespace, evidence.name, data.context, 'backups'), { state: { returnLabel: currentPageLabel(), returnCtx: data.context } }); setEvidenceKey(null) }} className="btn-secondary px-3 py-1.5 text-sm">Open Cluster Backups →</button></div>
212
+ </DialogPortal>
213
+ <CoverageNotice fleet={fleet} data={data} kinds={['clusters', 'backups', 'scheduledBackups', 'objectStores']} namespace={scopeCluster?.namespace} />
159
214
  <FilterChips chips={chips} />
160
215
 
161
- <SectionTable
162
- title={scopeCluster ? 'Recovery evidence' : 'Recovery evidence by cluster'}
216
+ {scopeCluster && rows[0] && (
217
+ <section className="rounded-xl border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
218
+ <SectionHeading>Recovery evidence</SectionHeading>
219
+ <CNPGClusterBackupFacts row={rows[0]} onNavigate={(ref) => onInspect(refToSelectedResource(ref))} />
220
+ {stores.length > 0 && <p className="mt-2 text-xs text-theme-text-tertiary">Recovery windows come from ObjectStore status.</p>}
221
+ </section>
222
+ )}
223
+ {!scopeCluster && <SectionTable
224
+ title="Recovery evidence by cluster"
163
225
  columns={[
164
226
  {
165
227
  header: 'Cluster',
166
- width: '14%',
228
+ width: '20%',
167
229
  cell: (r: CNPGFleetRow) => (
168
230
  <>
169
- <div className="truncate font-medium">{r.name}</div>
231
+ <BreakText value={r.name} after="-" className="font-medium" />
170
232
  <Sub>{r.namespace}</Sub>
171
233
  </>
172
234
  ),
173
235
  },
174
- { header: 'Schedule', width: '13%', cell: (r) => <FactValue fact={r.protection.schedule} /> },
175
236
  {
176
- header: 'Last successful backup',
177
- width: '16%',
178
- cell: (r) => (
179
- <>
180
- <FactValue fact={r.protection.lastSuccessfulBackup} />
181
- {r.protection.lastSuccessfulBackup.source && <Sub>{r.protection.lastSuccessfulBackup.source}</Sub>}
182
- </>
183
- ),
237
+ header: 'Archive & schedule', width: '27%', cell: (r) => <>
238
+ <Tooltip content={[r.protection.walArchiving.source, r.protection.walArchiving.detail].filter(Boolean).join(' · ')}><FactValue fact={r.protection.walArchiving} /></Tooltip>
239
+ <Sub><FactValue fact={r.protection.schedule} /></Sub>
240
+ </>,
184
241
  },
185
- { header: 'WAL archiving', width: '16%', cell: (r) => <FactValue fact={r.protection.walArchiving} className="line-clamp-2 break-words" /> },
186
242
  {
187
- header: 'Recovery window',
188
- width: '13%',
189
- cell: (r) =>
190
- r.protection.recoveryWindow.from ? (
191
- <>
192
- <span className={toneTextClass(r.protection.recoveryWindow.tone)}>
193
- from {ageText(r.protection.recoveryWindow.from)}
194
- </span>
195
- <Sub>
196
- {r.protection.recoveryWindow.tone === 'degraded' ? 'not advancing: archiving failing' : 'to the newest archived WAL'}
197
- </Sub>
198
- </>
199
- ) : (
200
- <FactValue fact={r.protection.recoveryWindow} />
201
- ),
243
+ header: 'Successful backup', width: '19%', cell: (r) => <Tooltip content={r.protection.lastSuccessfulBackup.source}><FactValue fact={r.protection.lastSuccessfulBackup} /></Tooltip>,
202
244
  },
203
245
  {
204
- header: 'Restore validation',
205
- width: '13%',
206
- cell: (r) => (
207
- <FactValue fact={r.protection.restoreValidation} />
208
- ),
246
+ header: 'Recovery boundary', width: '21%', cell: (r) => r.protection.recoveryWindow.from ? <>
247
+ <span className={toneTextClass(r.protection.recoveryWindow.tone)}>from {ageText(r.protection.recoveryWindow.from)}</span>
248
+ <Sub>{r.protection.recoveryWindow.tone === 'degraded' ? 'not advancing' : 'to newest archived WAL'}</Sub>
249
+ </> : <FactValue fact={r.protection.recoveryWindow} />,
209
250
  },
210
251
  {
211
- header: 'Destination',
212
- width: '15%',
213
- cell: (r) => <FactValue fact={r.protection.destination} className={r.protection.destination.method === 'barmanObjectStore' ? 'break-all font-mono text-[12.5px]' : 'break-words'} />,
252
+ header: 'Evidence', width: '13%', cell: (r) => <button type="button" aria-label={`Recovery evidence for ${r.namespace}/${r.name}`} onClick={(event) => { event.stopPropagation(); setEvidenceKey(r.key) }} className="rounded text-accent-text hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">Details…</button>,
214
253
  },
215
254
  ]}
216
255
  rows={rows}
@@ -218,38 +257,89 @@ export function CNPGProtection({
218
257
  rowResource={(r) => clusterResource(r.namespace, r.name)}
219
258
  onInspect={onInspect}
220
259
  inspected={inspected}
221
- minWidth={1000}
260
+ minWidth={800}
222
261
  empty={coverageEmpty(data.coverage.clusters, 'PostgreSQL clusters')}
223
- footer="Kubernetes records no restore tests, so restore validation is never shown as passed. Recovery windows come from ObjectStore status."
224
- />
262
+ footer={`${rows.some((r) => r.protection.walArchiving.state === 'no_destination' && r.protection.walArchiving.operatorCondition?.status === 'True') ? "With no backup destination, CloudNativePG reports archiving as working because it accepts each WAL file without keeping it. " : ""}Kubernetes records no restore tests, so restore validation is never shown as passed.${stores.length > 0 ? " Recovery windows come from ObjectStore status." : ""}`}
263
+ />}
225
264
 
226
265
  <SectionTable
227
- title="Failed backups"
228
- subtitle="last 7 days"
266
+ title="Backup runs"
267
+ subtitle={
268
+ <span className="inline-flex flex-wrap items-center gap-2">
269
+ last 7 days · all in-flight runs
270
+ <Segments
271
+ label="Backup runs shown"
272
+ value={showRuns}
273
+ onChange={(v) => setRunFilter(v)}
274
+ options={[
275
+ { id: 'failed', label: `Failed ${countText(failedRuns.length)}` },
276
+ { id: 'all', label: `All ${countText(runs.length)}` },
277
+ ]}
278
+ />
279
+ {backupsGap && <span className="text-theme-text-tertiary">{backupsGap}</span>}
280
+ </span>
281
+ }
229
282
  columns={[
230
- { header: 'Backup', width: '28%', cell: (b: any) => <Mono>{b.metadata?.name}</Mono> },
231
- { header: 'Cluster', width: '16%', cell: (b) => <>{b.spec?.cluster?.name ?? '—'}<Sub>{b.metadata?.namespace}</Sub></> },
232
- { header: 'Started', width: '12%', cell: (b) => ageText(backupStart(b)) },
283
+ { header: 'Backup', width: scopeCluster ? '32%' : '26%', cell: (b: any) => <Mono>{b.metadata?.name}</Mono> },
284
+ ...(scopeCluster ? [] : [{ header: 'Cluster', width: '14%', cell: (b: any) => <>{b.spec?.cluster?.name ?? '—'}<Sub>{b.metadata?.namespace}</Sub></> }]),
285
+ { header: 'Started', width: '12%', cell: (b: any) => <>{ageText(backupStart(b))}{cnpgBackupRunInFlight(b) && Date.now() - Date.parse(backupStart(b) ?? '') > CNPG_BACKUP_RUN_WINDOW_MS && <Sub>started more than a week ago</Sub>}</> },
286
+ {
287
+ header: 'Outcome',
288
+ width: '12%',
289
+ cell: (b: any) => {
290
+ const st = getCNPGBackupStatus(b)
291
+ return <Badge severity={SEVERITY[st.level]} size="sm">{st.text}</Badge>
292
+ },
293
+ },
233
294
  {
234
- header: 'Error',
235
- width: '44%',
236
- cell: (b) => <span className={toneTextClass('unhealthy')}>{b.status?.error || getCNPGBackupStatus(b).text}</span>,
295
+ header: 'Detail',
296
+ width: scopeCluster ? '44%' : '36%',
297
+ cell: (b: any) =>
298
+ isFailedRun(b) ? (
299
+ <span className={toneTextClass('unhealthy')}>{b.status?.error || getCNPGBackupStatus(b).text}</span>
300
+ ) : (
301
+ <span className="text-theme-text-secondary">{runDetail(b)}</span>
302
+ ),
237
303
  },
238
304
  ]}
239
- rows={failed}
305
+ rows={allRuns ? shownRuns : shownRuns.slice(0, RUNS_SHOWN)}
240
306
  rowKey={(b) => `${b.metadata?.namespace}/${b.metadata?.name}`}
241
307
  rowResource={(b) => cnpgResource('backups', b.metadata?.namespace, b.metadata?.name)}
242
308
  onInspect={onInspect}
243
309
  inspected={inspected}
244
- empty={backupsReadable && data.coverage.backups?.state === 'full' ? 'No failed backups in the last 7 days.' : coverageEmpty(data.coverage.backups, 'failed backups')}
310
+ empty={
311
+ backupsReadable
312
+ ? showRuns === 'failed'
313
+ ? 'No failed backups in the last 7 days.'
314
+ : 'No backups in the last 7 days.'
315
+ : coverageEmpty(data.coverage.backups, 'backups')
316
+ }
317
+ footer={
318
+ shownRuns.length > RUNS_SHOWN ? (
319
+ <button type="button" onClick={() => setAllRuns((v) => !v)} className="text-accent-text hover:underline">
320
+ {allRuns ? `Show the newest ${RUNS_SHOWN}` : `Show all ${shownRuns.length} runs`}
321
+ </button>
322
+ ) : undefined
323
+ }
245
324
  />
246
325
 
247
326
  <SectionTable
248
327
  title="Destinations"
249
328
  subtitle="ObjectStores (barman-cloud plugin)"
250
329
  columns={[
251
- { header: 'ObjectStore', width: '18%', cell: (s: StoreRow) => <>{s.name}<Sub>{s.namespace}</Sub></> },
252
- { header: 'Destination', width: '30%', cell: (s) => <Mono>{s.destination}</Mono> },
330
+ {
331
+ header: 'ObjectStore',
332
+ width: '18%',
333
+ cell: (s: StoreRow) => (
334
+ <>
335
+ <Tooltip content={s.name} wrapperClassName="max-w-full">
336
+ <span className="block truncate">{s.name}</span>
337
+ </Tooltip>
338
+ <Sub>{s.namespace}</Sub>
339
+ </>
340
+ ),
341
+ },
342
+ { header: 'Destination', width: '30%', cell: (s) => (s.destination ? <PathText value={s.destination} /> : '—') },
253
343
  { header: 'Used by', width: '20%', cell: (s) => (s.users.length ? s.users.map((u) => u.name).join(', ') : <span className="text-theme-text-tertiary">None visible</span>) },
254
344
  {
255
345
  header: 'Upload health (inferred)',
@@ -268,42 +358,53 @@ export function CNPGProtection({
268
358
  onInspect={onInspect}
269
359
  inspected={inspected}
270
360
  empty={data.coverage.objectStores?.state === 'notInstalled' ? 'The barman-cloud plugin’s ObjectStore kind is not installed.' : coverageEmpty(data.coverage.objectStores, 'ObjectStores')}
271
- footer="ObjectStore has no health status of its own; upload health is inferred from its clusters’ WAL archiving and backup results."
361
+ footer={stores.length > 0 ? "ObjectStore has no health status of its own; upload health is inferred from its clusters’ WAL archiving and backup results." : undefined}
272
362
  />
273
363
 
274
364
  <SectionTable
275
365
  title="Schedules"
366
+ minWidth={820}
276
367
  columns={[
277
- { header: 'ScheduledBackup', width: '24%', cell: (s: any) => <>{s.metadata?.name}<Sub>{s.metadata?.namespace}</Sub></> },
278
- { header: 'Cluster', width: '16%', cell: (s) => s.spec?.cluster?.name ?? '—' },
368
+ { header: 'ScheduledBackup', width: scopeCluster ? '22%' : '20%', cell: (s: any) => <>{s.metadata?.name}<Sub>{s.metadata?.namespace}</Sub></> },
369
+ ...(scopeCluster ? [] : [{ header: 'Cluster', width: '12%', cell: (s: any) => s.spec?.cluster?.name ?? '—' }]),
279
370
  {
280
371
  header: 'Schedule',
281
- width: '24%',
282
- cell: (s) => (
283
- <>
284
- <Mono>{s.spec?.schedule ?? '—'}</Mono>
285
- <Sub>CNPG cron, seconds first</Sub>
286
- </>
287
- ),
372
+ width: scopeCluster ? '24%' : '20%',
373
+ cell: (s) => {
374
+ const reading = data.scheduleReadings?.[`${s.metadata?.namespace}/${s.metadata?.name}`]
375
+ return reading ? (
376
+ <>
377
+ {reading}
378
+ <Sub>
379
+ <Mono>{s.spec?.schedule}</Mono>
380
+ </Sub>
381
+ </>
382
+ ) : (
383
+ <>
384
+ <Mono>{s.spec?.schedule ?? '—'}</Mono>
385
+ <Sub>CNPG cron, seconds first</Sub>
386
+ </>
387
+ )
388
+ },
288
389
  },
289
390
  {
290
- header: 'Status',
291
- width: '12%',
391
+ header: 'State',
392
+ width: scopeCluster ? '25%' : '27%',
292
393
  cell: (s) => {
293
394
  const st = getCNPGScheduledBackupStatus(s)
294
- return <Badge severity={SEVERITY[st.level]} size="sm">{st.text}</Badge>
395
+ const last = backupsForScheduledBackup(s, data.objects.backups ?? [])[0]
396
+ const blocker = cnpgScheduleDestinationBlocker(s, data.objects.clusters ?? [])
397
+ const guarded = s.spec?.suspend || getCNPGScheduledBackupOverdueMs(s) !== null
398
+ const text = guarded ? st.text : (blocker || last || s.status?.lastScheduleTime ? 'Enabled' : 'Enabled · not run yet')
399
+ const severity = guarded ? SEVERITY[st.level] : 'neutral'
400
+ return <div className="flex flex-wrap gap-1"><Badge severity={severity} size="sm" className="whitespace-nowrap">{text}</Badge>{blocker && <Badge severity="warning" size="sm" className="whitespace-nowrap">{blocker}</Badge>}</div>
295
401
  },
296
402
  },
297
- { header: 'Last run', width: '12%', cell: (s) => ageText(s.status?.lastScheduleTime) },
403
+ { header: 'Last run', width: scopeCluster ? '13%' : '10%', cell: (s) => <LastRun schedule={s} backups={data.objects.backups ?? []} readable={backupsReadable} /> },
298
404
  {
299
- header: 'Next run',
300
- width: '12%',
301
- cell: (s) => {
302
- const next = s.status?.nextScheduleTime
303
- if (!next) return '—'
304
- const ms = Date.parse(next) - Date.now()
305
- return ms >= 0 ? `in ${formatDuration(ms)}` : `${formatDuration(-ms)} overdue`
306
- },
405
+ header: <span className="block whitespace-normal">Next run reported by the operator</span>,
406
+ width: scopeCluster ? '16%' : '11%',
407
+ cell: (s) => { const next = getCNPGScheduledBackupNextSchedule(s); return next === '-' ? 'Not reported' : next },
307
408
  },
308
409
  ]}
309
410
  rows={schedules}
@@ -314,7 +415,12 @@ export function CNPGProtection({
314
415
  empty={coverageEmpty(data.coverage.scheduledBackups, 'ScheduledBackups')}
315
416
  footer={data.backupsOmitted > 0 ? `${data.backupsOmitted} settled backups older than 7 days are not listed.` : undefined}
316
417
  />
317
- </ScreenBody>
418
+ </>
419
+ )
420
+ return (
421
+ <div className="flex min-h-0 flex-1 flex-col">
422
+ {!scopeCluster && <CNPGWorkspaceHeader title="Backups" subtitle="Recovery evidence, backup runs, schedules and destinations for every cluster. Configured, backed up and restore-tested are separate facts." />}
423
+ {scopeCluster ? <div className="space-y-4 p-4">{body}</div> : <ScreenBody>{body}</ScreenBody>}
318
424
  </div>
319
425
  )
320
426
  }
@@ -0,0 +1,87 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { expect, it, vi } from 'vitest'
3
+ import { CNPGReplicationTab, OtherSlots } from './CNPGReplicationTab'
4
+ const state = vi.hoisted(() => ({ haProps: {} as any, navigate: vi.fn(), runtime: undefined as any, ha: undefined as any }))
5
+ vi.mock('../../api/cnpg', () => ({ useCNPGRuntime: () => ({ data: state.runtime }), useCNPGClusterCapabilities: () => ({}) }))
6
+ vi.mock('../../api/cnpg-ha', () => ({ useCNPGClusterHA: () => ({ data: state.ha }), cnpgInstanceLive: () => undefined, cnpgInstanceLiveUnavailable: () => 'not read' }))
7
+ vi.mock('./useCNPGSidebarWorkspace', () => ({ useCNPGFleet: () => ({ fleet: { rows: [{ name: 'pg', namespace: 'db', cluster: { status: { currentPrimary: 'pg-1' } } }] } }) }))
8
+ vi.mock('./actions/CNPGInstanceActions', () => ({ CNPGInstanceActions: () => null }))
9
+ vi.mock('./useCNPGNavigate', () => ({ useCNPGNavigate: () => state.navigate }))
10
+ vi.mock('./CNPGClusterTabs', () => ({ CNPGTabVerdict: ({ id, alwaysShow }: { id: string; alwaysShow?: boolean }) => <span>verdict: {id}{alwaysShow ? " always" : ""}</span> }))
11
+ vi.mock('@skyhook-io/k8s-ui', async (original) => ({ ...(await original<typeof import('@skyhook-io/k8s-ui')>()), CNPGClusterHASection: (props: any) => { state.haProps = props; return null } }))
12
+ it('explains Serving on Replication and connects endpoint evidence to Reachability', () => {
13
+ const html = renderToStaticMarkup(<CNPGReplicationTab namespace="db" name="pg" />)
14
+ expect(html).toContain('verdict: serving always')
15
+ expect(state.haProps.showInstances).toBe(false)
16
+ expect(html).toContain('verdict: replication')
17
+ expect(state.haProps.currentPrimary).toBe('pg-1')
18
+ state.haProps.onOpenReachability({ namespace: 'db', name: 'pg-rw' })
19
+ expect(state.navigate).toHaveBeenCalledWith('/workload/services/db/pg-rw?tab=reachability')
20
+ })
21
+
22
+ it('associates a slot with a recorded expected standby while keeping unknown associations unverified', () => {
23
+ const primary = { pod: 'orders-1', status: { state: 'ok', slots: [{ name: '_cnpg_orders_2', type: 'physical', active: false }] } } as any
24
+ const html = renderToStaticMarkup(<OtherSlots primary={primary} instances={['orders-1']} expectedInstances={['orders-1', 'orders-2']} joiningInstances={['orders-2']} clusterObject={{}} />)
25
+ expect(html).toContain('orders-2</span> — waiting to join')
26
+ expect(html).not.toContain('unmanaged')
27
+ expect(html).toContain('retained WAL not reported')
28
+ const unverified = renderToStaticMarkup(<OtherSlots primary={primary} instances={['orders-1']} clusterObject={{}} />)
29
+ expect(unverified).toContain('association unverified')
30
+ expect(unverified).not.toContain('waiting to join')
31
+ const expected = renderToStaticMarkup(<OtherSlots primary={primary} instances={['orders-1']} expectedInstances={['orders-2']} />)
32
+ expect(expected).toContain('orders-2</span> — no instance Pod observed')
33
+ expect(expected).not.toContain('waiting to join')
34
+ })
35
+ it('distinguishes an empty slot inventory from slots associated with standbys', () => {
36
+ const primary = { pod: 'payments-1', status: { state: 'ok', slots: [] } } as any
37
+ const html = renderToStaticMarkup(<OtherSlots primary={primary} instances={['payments-1']} />)
38
+ expect(html).toContain('No other slots reported')
39
+ expect(html).not.toContain('every slot')
40
+ primary.status.slots = [{ name: '_cnpg_payments_2', type: 'physical' }]
41
+ expect(renderToStaticMarkup(<OtherSlots primary={primary} instances={['payments-1', 'payments-2']} />)).toContain('No other slots reported')
42
+ primary.status.slotsTruncated = true
43
+ expect(renderToStaticMarkup(<OtherSlots primary={primary} instances={['payments-1', 'payments-2']} />)).toContain('No other slots in the reported inventory; inventory incomplete')
44
+ })
45
+ it('says checked instead of sampled when no instance answered', () => {
46
+ state.runtime = { permission: { proxy: 'allowed' }, sampledAt: '2026-10-05T12:00:00Z', instances: [] }
47
+ // The composed view has its own capabilities hook; isolate its source label here.
48
+ const html = renderToStaticMarkup(<CNPGReplicationTab namespace="db" name="pg" />)
49
+ expect(html).toContain('No instance data yet; checked')
50
+ expect(html).not.toContain('sampled')
51
+ expect(html).not.toContain('catch-up measure')
52
+ expect(html).toContain('Source: instance manager status')
53
+ state.runtime = undefined
54
+ })
55
+
56
+ it('keeps the replication measurement explanation absent for a lone primary, and present for measured standby rows', () => {
57
+ state.runtime = { permission: { proxy: 'allowed' }, sampledAt: '2026-10-05T12:00:00Z', instances: [{ pod: 'pg-1', role: 'primary', status: { state: 'ok', currentLsn: '0/4000' }, metrics: { state: 'ok' } }] }
58
+ expect(renderToStaticMarkup(<CNPGReplicationTab namespace="db" name="pg" />)).not.toContain('catch-up measure')
59
+ state.runtime.instances.push({ pod: 'pg-2', role: 'replica', status: { state: 'ok', replayLsn: '0/3000' }, metrics: { state: 'ok' } })
60
+ expect(renderToStaticMarkup(<CNPGReplicationTab namespace="db" name="pg" />)).toContain('catch-up measure')
61
+ state.runtime = undefined
62
+ })
63
+
64
+ it('keeps expected and other slots in one section', () => {
65
+ const primary = { pod: 'orders-1', status: { state: 'ok', slots: [{ name: '_cnpg_orders_2', type: 'physical', active: false }] } } as any
66
+ const html = renderToStaticMarkup(<OtherSlots primary={primary} instances={['orders-1']} expectedInstances={['orders-2']} />)
67
+ expect(html.match(/>Replication slots</g)).toHaveLength(1)
68
+ expect(html).toContain('No other slots reported')
69
+ expect(html.match(/_cnpg_orders_2/g)).toHaveLength(1)
70
+ })
71
+ it('uses streaming language only for standbys when PostgreSQL reads are denied', () => {
72
+ state.runtime = { permission: { proxy: 'denied' }, instances: [] }
73
+ state.ha = { pods: { state: 'ok' }, instances: [{ pod: 'pg-1', role: 'primary', ready: true, restartCount: 0 }, { pod: 'pg-2', role: 'replica', ready: true, restartCount: 0 }] }
74
+ const html = renderToStaticMarkup(<CNPGReplicationTab namespace="db" name="pg" />)
75
+ expect(html.match(/streaming not read/g)).toHaveLength(1)
76
+ expect(html.match(/live PostgreSQL status not read/g)).toHaveLength(1)
77
+ state.runtime = undefined; state.ha = undefined
78
+ })
79
+ it('names instance position, timeline and manager version without abbreviations', () => {
80
+ state.runtime = { permission: { proxy: 'allowed' }, instances: [{ pod: 'pg-1', role: 'primary', status: { state: 'ok', currentLsn: '0/4000', timeline: 1, instanceManagerVersion: '1.30.1' }, metrics: { state: 'ok' } }] }
81
+ const html = renderToStaticMarkup(<CNPGReplicationTab namespace="db" name="pg" />)
82
+ expect(html).toContain('WAL position')
83
+ expect(html).toContain('0/4000')
84
+ expect(html).toContain('timeline 1')
85
+ expect(html).toContain('CNPG instance manager 1.30.1')
86
+ state.runtime = undefined
87
+ })