@skyhook-io/radar-app 1.16.3 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +138 -93
  4. package/src/RadarApp.tsx +27 -17
  5. package/src/api/actions.errorCode.test.ts +40 -0
  6. package/src/api/actions.test.ts +48 -0
  7. package/src/api/actions.ts +79 -0
  8. package/src/api/apiResources.navigation.test.tsx +232 -0
  9. package/src/api/apiResources.ts +46 -3
  10. package/src/api/client.goneResource.test.ts +49 -0
  11. package/src/api/client.helmSource.test.ts +39 -0
  12. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  13. package/src/api/client.namespaceSwitch.test.ts +107 -0
  14. package/src/api/client.ts +381 -47
  15. package/src/api/cnpg-ha.live.test.ts +140 -0
  16. package/src/api/cnpg-ha.test.ts +78 -0
  17. package/src/api/cnpg-ha.ts +19 -0
  18. package/src/api/cnpg-history.ts +123 -0
  19. package/src/api/cnpg-inspect.ts +102 -0
  20. package/src/api/cnpg-protection.ts +112 -0
  21. package/src/api/cnpg-recovery.test.tsx +60 -0
  22. package/src/api/cnpg-recovery.ts +262 -0
  23. package/src/api/cnpg-sessions.ts +175 -0
  24. package/src/api/cnpg-storage.ts +138 -0
  25. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  26. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  27. package/src/api/cnpg.ts +428 -10
  28. package/src/api/diagnose.test.ts +40 -0
  29. package/src/api/diagnose.ts +12 -1
  30. package/src/api/radarFeatures.render.test.tsx +65 -3
  31. package/src/api/radarFeatures.ts +6 -0
  32. package/src/api/reviewedActionMutation.test.tsx +164 -0
  33. package/src/api/reviewedActionMutation.ts +66 -0
  34. package/src/api/timelineSource.test.ts +9 -0
  35. package/src/api/timelineSource.ts +8 -0
  36. package/src/api/traffic.sources.test.ts +55 -0
  37. package/src/api/traffic.ts +173 -14
  38. package/src/components/applications/ApplicationsView.tsx +3 -0
  39. package/src/components/audit/AuditView.tsx +9 -0
  40. package/src/components/capacity/CapacityActivity.tsx +3 -31
  41. package/src/components/capacity/CapacityDemand.tsx +4 -36
  42. package/src/components/capacity/CapacityOverview.tsx +217 -56
  43. package/src/components/capacity/CapacityPoolDetail.tsx +24 -62
  44. package/src/components/capacity/CapacityView.test.tsx +354 -1
  45. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  46. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  47. package/src/components/capacity/routes.ts +42 -0
  48. package/src/components/capacity/shared.tsx +23 -150
  49. package/src/components/capacity/workspaceRoute.ts +18 -0
  50. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  51. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  52. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  53. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  54. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  55. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  56. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  57. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  58. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  59. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  60. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  61. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  62. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  63. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  64. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  65. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  66. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  67. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  68. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  69. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  70. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  71. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  72. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  73. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  74. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  75. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  76. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  77. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  78. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  79. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  80. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  81. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  82. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  83. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  84. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  85. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  86. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  87. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  88. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  89. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  90. package/src/components/cnpg/CNPGProtection.tsx +230 -124
  91. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  92. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  93. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  94. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  95. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  96. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  97. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  98. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  99. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  100. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  101. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  102. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  103. package/src/components/cnpg/CNPGView.tsx +34 -21
  104. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  105. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  106. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  107. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  108. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  109. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  110. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  111. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  112. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  113. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  114. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  115. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  116. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  117. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  118. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  119. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  120. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  121. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  122. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  123. package/src/components/cnpg/actions/actionModel.ts +127 -0
  124. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  125. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  126. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  127. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  128. package/src/components/cnpg/archivingRepair.ts +145 -0
  129. package/src/components/cnpg/baseBackup.test.ts +36 -0
  130. package/src/components/cnpg/baseBackup.ts +36 -0
  131. package/src/components/cnpg/blocking.test.ts +59 -0
  132. package/src/components/cnpg/blocking.ts +131 -0
  133. package/src/components/cnpg/coverageText.test.ts +27 -0
  134. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  135. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  136. package/src/components/cnpg/creation/targetModel.ts +149 -0
  137. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  138. package/src/components/cnpg/fleetStatus.ts +43 -0
  139. package/src/components/cnpg/formFields.ts +2 -0
  140. package/src/components/cnpg/grantText.test.tsx +11 -0
  141. package/src/components/cnpg/host.test.tsx +24 -0
  142. package/src/components/cnpg/host.tsx +101 -0
  143. package/src/components/cnpg/inspectModel.test.ts +120 -0
  144. package/src/components/cnpg/inspectModel.ts +140 -0
  145. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  146. package/src/components/cnpg/logicalSlots.ts +29 -0
  147. package/src/components/cnpg/lsn.test.ts +40 -0
  148. package/src/components/cnpg/lsn.ts +43 -0
  149. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  150. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  151. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  152. package/src/components/cnpg/operations/model.test.ts +510 -0
  153. package/src/components/cnpg/operations/model.ts +499 -0
  154. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  155. package/src/components/cnpg/operations/presentation.ts +84 -0
  156. package/src/components/cnpg/operations/store.ts +98 -0
  157. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  158. package/src/components/cnpg/operatorStatus.ts +194 -0
  159. package/src/components/cnpg/paths.test.ts +54 -0
  160. package/src/components/cnpg/paths.ts +47 -4
  161. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  162. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  163. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  164. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  165. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  166. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  167. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  168. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  169. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  172. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  173. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  174. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  175. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  176. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  177. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  178. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  179. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  180. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  181. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  182. package/src/components/cnpg/refresh.test.ts +38 -0
  183. package/src/components/cnpg/refresh.ts +17 -0
  184. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  185. package/src/components/cnpg/routes.test.ts +34 -3
  186. package/src/components/cnpg/routes.ts +60 -48
  187. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  188. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  189. package/src/components/cnpg/runtimeModel.ts +196 -0
  190. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  191. package/src/components/cnpg/runtimeParts.tsx +79 -0
  192. package/src/components/cnpg/shared.test.tsx +59 -0
  193. package/src/components/cnpg/shared.tsx +100 -182
  194. package/src/components/cnpg/storageModel.test.ts +107 -0
  195. package/src/components/cnpg/storageModel.ts +101 -0
  196. package/src/components/cnpg/trendSamples.test.ts +148 -0
  197. package/src/components/cnpg/trendSamples.ts +222 -0
  198. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  199. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  200. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  201. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  202. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  203. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  204. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  205. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  206. package/src/components/cnpg/workspaceRoute.ts +23 -0
  207. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  208. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  209. package/src/components/cost/CostView.test.ts +2 -1
  210. package/src/components/cost/CostView.tsx +0 -2
  211. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  212. package/src/components/cost/errors.ts +3 -1
  213. package/src/components/cost/source.test.ts +10 -0
  214. package/src/components/cost/source.ts +5 -1
  215. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  216. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  217. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  218. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  219. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  220. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  221. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  222. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  223. package/src/components/diagnose/InvestigationView.tsx +39 -2
  224. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  225. package/src/components/diagnose/agentCatalog.ts +2 -2
  226. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  227. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  228. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  229. package/src/components/dock/BottomDock.tsx +2 -0
  230. package/src/components/dock/TerminalTab.test.tsx +17 -0
  231. package/src/components/dock/TerminalTab.tsx +7 -3
  232. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  233. package/src/components/execution/BatchExecutionView.tsx +11 -11
  234. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  235. package/src/components/gitops/GitOpsView.tsx +16 -23
  236. package/src/components/helm/ChartBrowser.tsx +3 -3
  237. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  238. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  239. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  240. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  241. package/src/components/helm/RevisionHistory.tsx +3 -2
  242. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  243. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  244. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  245. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  246. package/src/components/helm/ValuesViewer.tsx +169 -78
  247. package/src/components/home/ActivitySummary.tsx +2 -1
  248. package/src/components/home/ClusterHealthCard.tsx +7 -0
  249. package/src/components/home/HomeView.tsx +20 -9
  250. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  251. package/src/components/home/TopologyPreview.tsx +2 -2
  252. package/src/components/home/mcpToolCatalog.ts +1 -1
  253. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  254. package/src/components/issues/IssuesPane.test.ts +65 -1
  255. package/src/components/issues/IssuesPane.tsx +188 -25
  256. package/src/components/issues/uncheckedSources.ts +21 -0
  257. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  258. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  259. package/src/components/resource/PVCUsageBar.tsx +15 -6
  260. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  261. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  262. package/src/components/resources/CompositeRenderer.tsx +4 -4
  263. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  264. package/src/components/resources/ResourcesView.tsx +26 -12
  265. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  266. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  267. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  268. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  269. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  270. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  271. package/src/components/settings/SettingsDialog.tsx +150 -34
  272. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  273. package/src/components/timeline/TimelineList.tsx +25 -24
  274. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  275. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  276. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  277. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  278. package/src/components/timeline/TimelineView.tsx +25 -45
  279. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  280. package/src/components/timeline/useStickyError.test.tsx +47 -0
  281. package/src/components/timeline/useStickyError.ts +15 -0
  282. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  283. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  284. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  285. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  286. package/src/components/traffic/TrafficGraph.tsx +109 -26
  287. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  288. package/src/components/traffic/TrafficView.tsx +582 -149
  289. package/src/components/traffic/trafficFilters.test.ts +398 -1
  290. package/src/components/traffic/trafficFilters.ts +408 -0
  291. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  292. package/src/components/ui/Markdown.test.tsx +65 -0
  293. package/src/components/ui/Markdown.tsx +144 -127
  294. package/src/components/whats-new/releaseNotes.ts +63 -1
  295. package/src/components/workload/WorkloadView.test.ts +11 -1
  296. package/src/components/workload/WorkloadView.tsx +162 -529
  297. package/src/components/workspace/layout.test.tsx +29 -0
  298. package/src/components/workspace/layout.tsx +259 -0
  299. package/src/components/workspace/table.ts +7 -0
  300. package/src/context/TimelineCustomization.tsx +15 -0
  301. package/src/contexts/CapabilitiesContext.tsx +1 -1
  302. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  303. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  304. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  305. package/src/integrations/resourceHost.test.tsx +55 -0
  306. package/src/integrations/resourceHost.ts +99 -0
  307. package/src/integrations/resourceHosts.test.tsx +141 -0
  308. package/src/integrations/resourceHosts.tsx +119 -0
  309. package/src/integrations/workspaceRoute.ts +20 -0
  310. package/src/integrations/workspaceRoutes.test.ts +40 -0
  311. package/src/integrations/workspaceRoutes.ts +22 -0
  312. package/src/integrations/workspaceScreens.tsx +14 -0
  313. package/src/utils/drawer-trail.ts +35 -0
  314. package/src/utils/kindFilterStore.test.ts +147 -0
  315. package/src/utils/kindFilterStore.ts +91 -0
  316. package/src/utils/navigation.test.ts +13 -1
  317. package/src/utils/navigation.ts +39 -2
  318. package/src/utils/page-links.ts +21 -0
  319. package/src/utils/tabStorage.ts +40 -0
  320. package/src/utils/viewMemory.test.ts +78 -0
  321. package/src/utils/viewMemory.ts +54 -0
@@ -0,0 +1,89 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { renderToStaticMarkup } from 'react-dom/server'
5
+ import { expect, it, vi } from 'vitest'
6
+ import { buildCNPGFleet, CNPG_WORKSPACE_KEYS, type CNPGWorkspaceResponse, type CNPGPoolerPressureLive } from '@skyhook-io/k8s-ui'
7
+ import { CNPGPooling } from './CNPGPooling'
8
+
9
+ const live = vi.hoisted(() => ({ pods: [] as CNPGPoolerPressureLive['pods'], failed: false }))
10
+ vi.mock('../../api/cnpg', () => ({ useCNPGPoolerRuntime: () => ({ data: { permission: { proxy: 'allowed' }, pods: live.pods }, isRefetchError: live.failed, error: new Error('Pressure timeout'), dataUpdatedAt: Date.now() - 120_000 }) }))
11
+ vi.mock('../../api/cnpg-sessions', () => ({ useCNPGPoolerCapabilities: () => ({ data: { facts: { deployment: { name: 'p', state: 'ok', replicas: 2, readyReplicas: 0 } } }, isRefetchError: live.failed, error: new Error('Readiness timeout'), dataUpdatedAt: Date.now() - 180_000 }) }))
12
+ vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'test' } }) }))
13
+ function render(type = 'rw') {
14
+ const data: CNPGWorkspaceResponse = { installed: true, context: 'test', namespaces: null, coverage: Object.fromEntries(CNPG_WORKSPACE_KEYS.map((k) => [k, { state: 'full' }])), objects: { poolers: [{ apiVersion: 'postgresql.cnpg.io/v1', metadata: { name: 'p', namespace: 'pg' }, spec: { type, cluster: { name: 'pg' }, pgbouncer: { paused: true } } }] }, issues: [], audit: [], backupsOmitted: 0 }
15
+ return renderToStaticMarkup(<CNPGPooling data={data} fleet={buildCNPGFleet(data)} namespaces={[]} searchParams={new URLSearchParams()} onSetParams={() => {}} onInspect={() => {}} inspected={null} onClearNamespaces={() => {}} />)
16
+ }
17
+ it('does not call an empty partial read idle and names the unread Pod', () => {
18
+ live.pods = [{ pod: 'a', state: 'ok', pools: [] }, { pod: 'b', state: 'unreachable', reason: 'timeout' }]
19
+ const html = render()
20
+ expect(html).toContain('No pools seen in what was read')
21
+ expect(html).toContain('b: not read (timeout)')
22
+ expect(html).not.toContain('Idle')
23
+ })
24
+ it('qualifies partial totals and retains readiness beside the request', () => {
25
+ live.pods = [{ pod: 'a', state: 'partial', reason: 'pools capped', pools: [{ database: 'app', user: 'app', clWaiting: 0, svActive: 3 }] }]
26
+ const html = render()
27
+ expect(html).toContain('Unknown')
28
+ expect(html).toContain('≥3')
29
+ expect(html).toContain('pools capped')
30
+ expect(html).toContain('0/2 ready')
31
+ expect(html).toContain('Pause requested')
32
+ })
33
+ it('says idle for complete empty reads', () => {
34
+ live.pods = [{ pod: 'a', state: 'ok', pools: [] }]
35
+ expect(render()).toContain('Idle: no client pools open')
36
+ })
37
+ it('labels retained pressure and readiness after their refreshes fail', () => {
38
+ live.failed = true
39
+ live.pods = [{ pod: 'a', state: 'ok', pools: [] }]
40
+ const html = render()
41
+ expect(html).toContain('Idle: no client pools open')
42
+ expect(html).toContain('0/2 ready')
43
+ expect(html).toContain('Last refresh failed: Readiness timeout')
44
+ expect(html).toContain('Last refresh failed: Pressure timeout')
45
+ expect(html).toContain('3m ago')
46
+ expect(html).toContain('2m ago')
47
+ live.failed = false
48
+ })
49
+
50
+ it('shows a pending Pod scheduling cause and inspection link beside Deployment readiness', () => {
51
+ live.pods = [{ pod: 'p-pending', state: 'unreachable', reason: 'proxy failed', schedulingReason: 'Unschedulable: insufficient memory' }]
52
+ const html = render()
53
+ expect(html).toContain('0/2 ready')
54
+ expect(html).toContain('Unschedulable: insufficient memory')
55
+ expect(html).toMatch(/<button[^>]*>p-pending<\/button>/)
56
+ expect(html).toContain('Not measured: proxy failed')
57
+ expect(html).toContain('p-pending')
58
+ })
59
+
60
+ it('opens the Pod once and folds details without opening the Pooler row', () => {
61
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
62
+ const inspect = vi.fn()
63
+ live.pods = [{ pod: 'p-pending', state: 'unreachable', error: 'address not allowed', schedulingReason: 'Unschedulable: insufficient memory' }]
64
+ const data: CNPGWorkspaceResponse = { installed: true, context: 'test', namespaces: null, coverage: Object.fromEntries(CNPG_WORKSPACE_KEYS.map((k) => [k, { state: 'full' }])), objects: { poolers: [{ apiVersion: 'postgresql.cnpg.io/v1', metadata: { name: 'p', namespace: 'pg' }, spec: {} }] }, issues: [], audit: [], backupsOmitted: 0 }
65
+ const host = document.createElement('div'); const root = createRoot(host)
66
+ act(() => root.render(<CNPGPooling data={data} fleet={buildCNPGFleet(data)} namespaces={[]} searchParams={new URLSearchParams()} onSetParams={() => {}} onInspect={inspect} inspected={null} onClearNamespaces={() => {}} />))
67
+ const button = [...host.querySelectorAll('button')].find((b) => b.textContent === 'p-pending')!
68
+ act(() => button.click())
69
+ expect(inspect).toHaveBeenCalledExactlyOnceWith({ kind: 'pods', group: '', namespace: 'pg', name: 'p-pending' })
70
+ inspect.mockClear()
71
+ for (const label of ['Scheduler message', 'Measurement details']) {
72
+ const fold = [...host.querySelectorAll('button')].find((b) => b.textContent?.includes(label))!
73
+ expect(fold.getAttribute('aria-expanded')).toBe('false')
74
+ act(() => fold.click())
75
+ expect(fold.getAttribute('aria-expanded')).toBe('true')
76
+ expect(inspect).not.toHaveBeenCalled()
77
+ }
78
+ act(() => host.querySelector('tbody tr td:nth-child(5)')!.querySelector('span')!.click())
79
+ expect(inspect).toHaveBeenCalledExactlyOnceWith({ kind: 'poolers', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'p' })
80
+ act(() => root.unmount())
81
+ })
82
+
83
+ it('labels primary and standby routing in plain words', () => {
84
+ expect(render()).toContain('Primary (read-write)')
85
+ expect(render('ro')).toContain('Standbys (read-only)')
86
+ expect(render('r')).toContain('Any instance')
87
+ expect(render('future-selector')).toContain('future-selector')
88
+ expect(render('')).toContain('Not reported')
89
+ })
@@ -1,25 +1,23 @@
1
1
  import { useMemo } from 'react'
2
2
  import {
3
3
  Badge,
4
+ toneTextClass,
4
5
  getCNPGPoolerMode,
5
- getCNPGPoolerStatus,
6
+ FactValue,
7
+ poolerPressureCoverage,
8
+ poolerPressureFact,
6
9
  getCNPGPoolerType,
7
10
  isApiGroup,
11
+ isCNPGPoolerPaused,
12
+ poolerReadiness,
13
+ CNPGPoolerScheduling,
14
+ CNPGPoolerUnmeasured,
8
15
  type HealthLevel,
9
16
  } from '@skyhook-io/k8s-ui'
10
- import {
11
- CNPGWorkspaceHeader,
12
- CoverageNotice,
13
- FilterChips,
14
- Mono,
15
- ScreenBody,
16
- SectionTable,
17
- Sub,
18
- cnpgResource,
19
- coverageEmpty,
20
- namespaceChip,
21
- type CNPGScreenProps,
22
- } from './shared'
17
+ import { useCNPGPoolerRuntime } from '../../api/cnpg'
18
+ import { useCNPGPoolerCapabilities } from '../../api/cnpg-sessions'
19
+ import { CNPGWorkspaceHeader, CoverageNotice, cnpgResource, coverageEmpty, type CNPGScreenProps } from './shared'
20
+ import { FilterChips, namespaceChip, RefreshFailedNotice, ScreenBody, SectionTable, Sub } from '../workspace/layout'
23
21
 
24
22
  const SEVERITY: Record<HealthLevel, 'success' | 'warning' | 'alert' | 'error' | 'neutral'> = {
25
23
  healthy: 'success',
@@ -51,15 +49,15 @@ export function CNPGPooling({ data, fleet, namespaces, searchParams, onSetParams
51
49
  subtitle="PgBouncer Poolers, the clusters they front, and their readiness."
52
50
  />
53
51
  <ScreenBody>
54
- <CoverageNotice fleet={fleet} data={data} />
52
+ <CoverageNotice fleet={fleet} data={data} kinds={['clusters', 'poolers']} />
55
53
  <FilterChips chips={chips} />
56
54
  <SectionTable
57
55
  title="Poolers"
58
56
  columns={[
59
- { header: 'Pooler', width: '22%', cell: (p: any) => <>{p.metadata?.name}<Sub>{p.metadata?.namespace}</Sub></> },
57
+ { header: 'Pooler', width: '18%', cell: (p: any) => <>{p.metadata?.name}<Sub>{p.metadata?.namespace}</Sub></> },
60
58
  {
61
59
  header: 'Target cluster',
62
- width: '18%',
60
+ width: '16%',
63
61
  cell: (p) => {
64
62
  const name = p.spec?.cluster?.name
65
63
  const visible = fleet.rows.some((r) => r.namespace === p.metadata?.namespace && r.name === name)
@@ -71,34 +69,17 @@ export function CNPGPooling({ data, fleet, namespaces, searchParams, onSetParams
71
69
  )
72
70
  },
73
71
  },
74
- { header: 'Type', width: '8%', cell: (p) => <Mono>{getCNPGPoolerType(p)}</Mono> },
72
+ { header: 'Type', width: '14%', cell: (p) => { const type = getCNPGPoolerType(p); return type === 'ro' ? 'Standbys (read-only)' : type === 'rw' ? 'Primary (read-write)' : type === 'r' ? 'Any instance' : type === '-' ? 'Not reported' : type } },
75
73
  { header: 'Mode', width: '12%', cell: (p) => getCNPGPoolerMode(p) },
76
74
  {
77
- header: 'Instances',
78
- width: '10%',
79
- cell: (p) => (
80
- <span className="font-mono">
81
- {typeof p.status?.instances === 'number' ? p.status.instances : '–'}/{typeof p.spec?.instances === 'number' ? p.spec.instances : '–'}
82
- </span>
83
- ),
84
- },
85
- {
86
- header: 'Status',
87
- width: '14%',
88
- cell: (p) => {
89
- const st = getCNPGPoolerStatus(p)
90
- return <Badge severity={SEVERITY[st.level]} size="sm">{st.text}</Badge>
91
- },
75
+ header: 'Readiness',
76
+ width: '24%',
77
+ cell: (p) => <div onClick={(e) => { if ((e.target as Element).closest('button, a')) e.stopPropagation() }}><PoolerReadiness pooler={p} onInspect={onInspect} /></div>,
92
78
  },
93
79
  {
94
80
  header: 'Connection pressure',
95
81
  width: '16%',
96
- cell: () => (
97
- <>
98
- <span className="text-theme-text-tertiary">Not measured</span>
99
- <Sub>Needs PgBouncer metrics</Sub>
100
- </>
101
- ),
82
+ cell: (p) => <div onClick={(e) => { if ((e.target as Element).closest('button, a')) e.stopPropagation() }}><PoolerPressure namespace={p.metadata?.namespace} name={p.metadata?.name} /></div>,
102
83
  },
103
84
  ]}
104
85
  rows={poolers}
@@ -108,9 +89,85 @@ export function CNPGPooling({ data, fleet, namespaces, searchParams, onSetParams
108
89
  inspected={inspected}
109
90
  minWidth={880}
110
91
  empty={coverageEmpty(data.coverage.poolers, 'Poolers')}
111
- footer="Instances are the Pooler’s own ready count. Client waits and server-pool saturation come from PgBouncer metrics, which Radar does not read yet."
92
+ footer="Readiness is the Pooler’s Deployment (the Pooler status reports an operator count). Pressure is read live from each PgBouncer's metrics through the Kubernetes API proxy."
112
93
  />
113
94
  </ScreenBody>
114
95
  </div>
115
96
  )
116
97
  }
98
+
99
+ function PoolerReadiness({ pooler, onInspect }: { pooler: any; onInspect: CNPGScreenProps['onInspect'] }) {
100
+ const namespace = pooler.metadata?.namespace
101
+ const name = pooler.metadata?.name
102
+ const caps = useCNPGPoolerCapabilities(namespace, name)
103
+ const runtime = useCNPGPoolerRuntime(namespace, name)
104
+ const paused = isCNPGPoolerPaused(pooler)
105
+ if (!caps.data) {
106
+ const st = poolerReadiness(undefined)
107
+ return (
108
+ <>
109
+ <Badge severity={SEVERITY[st.level]} size="sm">{st.text}</Badge>
110
+ {paused && <Badge severity="warning" size="sm">Pause requested</Badge>}
111
+ <Sub>{caps.isLoading ? 'reading Deployment…' : 'Deployment was not read'}</Sub>
112
+ </>
113
+ )
114
+ }
115
+ const r = poolerReadiness(caps.data.facts.deployment)
116
+ return (
117
+ <>
118
+ <span className="inline-flex flex-wrap items-center gap-1">
119
+ <Badge severity={SEVERITY[r.level]} size="sm">{r.text}</Badge>
120
+ {paused && <Badge severity="warning" size="sm">Pause requested</Badge>}
121
+ </span>
122
+ <Sub>{r.detail}</Sub>
123
+ <CNPGPoolerScheduling namespace={namespace} pods={runtime.data?.pods ?? []} onNavigate={(ref) => onInspect({ kind: 'pods', group: '', namespace, name: ref.name })} />
124
+ <RefreshFailedNotice queries={[caps]} />
125
+ </>
126
+ )
127
+ }
128
+
129
+ function PoolerPressure({ namespace, name }: { namespace: string; name: string }) {
130
+ const q = useCNPGPoolerRuntime(namespace, name)
131
+ if (!q.data) return <span className="text-theme-text-tertiary">{q.isLoading ? 'Reading…' : 'Unavailable'}</span>
132
+ return <><RefreshFailedNotice queries={[q]} /><PoolerPressureData data={q.data} /></>
133
+ }
134
+
135
+ function PoolerPressureData({ data }: { data: NonNullable<ReturnType<typeof useCNPGPoolerRuntime>['data']> }) {
136
+ if (data.permission.proxy === 'denied') {
137
+ return (
138
+ <>
139
+ <span className="text-theme-text-tertiary">No access</span>
140
+ <Sub>needs get pods/proxy</Sub>
141
+ </>
142
+ )
143
+ }
144
+ const { reporting: ok, limitation, empty } = poolerPressureCoverage(data.pods)
145
+ if (ok.length === 0) {
146
+ return <CNPGPoolerUnmeasured pods={data.pods} />
147
+ }
148
+ const pools = ok.flatMap((p) => p.pools ?? [])
149
+ if (pools.length === 0) {
150
+ return (
151
+ <>
152
+ <span>{empty}</span>
153
+ <Sub>
154
+ {limitation ?? `${ok.length}/${data.pods.length} pods reporting`}
155
+ </Sub>
156
+ </>
157
+ )
158
+ }
159
+ const waiting = poolerPressureFact(data.pods, 'clWaiting')
160
+ const waits = pools.map((x) => x.maxwaitSeconds).filter((v): v is number => v !== undefined)
161
+ const maxwait = waits.length > 0 ? Math.max(...waits) : undefined
162
+ return (
163
+ <>
164
+ <span className={toneTextClass(waiting.tone)}>
165
+ <span className="whitespace-nowrap"><FactValue fact={waiting} /> waiting</span> · <span className="whitespace-nowrap"><FactValue fact={poolerPressureFact(data.pods, 'svActive')} /> servers busy</span>
166
+ </span>
167
+ <Sub>
168
+ {maxwait !== undefined && maxwait > 0 ? `longest wait ${poolerPressureFact(data.pods, 'maxwaitSeconds').text} · ` : ''}
169
+ {ok.length}/{data.pods.length} pods reporting{limitation ? ` · ${limitation}` : ''}
170
+ </Sub>
171
+ </>
172
+ )
173
+ }
@@ -0,0 +1,145 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { expect, it, vi, afterEach } from 'vitest'
4
+ import { buildCNPGFleet, CNPG_WORKSPACE_KEYS, type CNPGWorkspaceResponse, type CNPGKindCoverage } from '@skyhook-io/k8s-ui'
5
+ import { CNPGProtection } from './CNPGProtection'
6
+
7
+ vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'test' } }) }))
8
+ vi.mock('./useCNPGNavigate', () => ({ useCNPGNavigate: () => vi.fn() }))
9
+ afterEach(() => vi.useRealTimers())
10
+ const now = Date.parse('2026-10-05T12:00:00Z')
11
+ const backup = (name: string, phase: string, ageDays: number) => ({ apiVersion: 'postgresql.cnpg.io/v1', kind: 'Backup', metadata: { namespace: 'pg', name }, spec: { cluster: { name: 'pg' } }, status: { phase, startedAt: new Date(now - ageDays * 86400000).toISOString() } })
12
+
13
+ function render(objects: CNPGWorkspaceResponse['objects'], coverage: CNPGKindCoverage = { state: 'full' }, query = '', scopeCluster?: { namespace: string; name: string }) {
14
+ const data: CNPGWorkspaceResponse = { installed: true, context: 'test', namespaces: null, coverage: { ...Object.fromEntries(CNPG_WORKSPACE_KEYS.map((k) => [k, { state: 'full' as const }])), backups: coverage }, objects, issues: [], audit: [], backupsOmitted: 0 }
15
+ return renderToStaticMarkup(<CNPGProtection data={data} fleet={buildCNPGFleet(data)} namespaces={[]} searchParams={new URLSearchParams(query)} onSetParams={() => {}} onInspect={() => {}} inspected={null} onClearNamespaces={() => {}} scopeCluster={scopeCluster} />)
16
+ }
17
+
18
+ it('marks a blocked schedule amber and names its missing destination and unreported time', () => {
19
+ const cluster = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'payments', namespace: 'pg' }, spec: {} }
20
+ const schedule = { metadata: { name: 'nightly', namespace: 'pg' }, spec: { cluster: { name: 'payments' }, schedule: '0 0 2 * * *' } }
21
+ const html = render({ clusters: [cluster], scheduledBackups: [schedule] })
22
+ expect(html).toContain('Enabled · 0 0 2 * * * · blocked: no backup destination')
23
+ expect(html).toContain('No backup destination')
24
+ expect(html).toContain('Next run reported by the operator')
25
+ expect(html).toContain('Not reported')
26
+ const table = html.slice(html.indexOf('Schedules</h2>'))
27
+ expect(table).not.toContain('bg-emerald-100')
28
+ const unknown = render({ scheduledBackups: [schedule] })
29
+ expect(unknown).not.toContain('No backup destination')
30
+ })
31
+
32
+ it('uses green only for a completed Backup from the latest schedule firing', () => {
33
+ vi.useFakeTimers(); vi.setSystemTime(now)
34
+ const schedule = { metadata: { name: 'nightly', namespace: 'pg' }, spec: { cluster: { name: 'pg' } }, status: { lastScheduleTime: new Date(now).toISOString() } }
35
+ const completed = { ...backup('ok', 'completed', 0), metadata: { namespace: 'pg', name: 'ok', labels: { 'cnpg.io/scheduled-backup': 'nightly' } } }
36
+ const html = render({ scheduledBackups: [schedule], backups: [completed] })
37
+ const table = html.slice(html.indexOf('Schedules</h2>'))
38
+ expect(table).toMatch(/bg-emerald-100[^>]*>completed/)
39
+ expect(table).not.toMatch(/bg-emerald-100[^>]*>Enabled/)
40
+ const older = { ...completed, status: { ...completed.status, startedAt: new Date(now - 3600000).toISOString() } }
41
+ const olderTable = render({ scheduledBackups: [schedule], backups: [older] }).split('Schedules</h2>')[1]
42
+ expect(olderTable).toContain('its Backup is not loaded')
43
+ expect(olderTable).not.toContain('bg-emerald-100')
44
+ })
45
+
46
+ it('says restore tests are not recorded once inside the cluster evidence card', () => {
47
+ const cluster = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'payments', namespace: 'pg' }, spec: {} }
48
+ const html = render({ clusters: [cluster] }, { state: 'full' }, '', { namespace: 'pg', name: 'payments' })
49
+ expect(html.match(/Kubernetes (?:does not record|records no) restore tests/g)).toHaveLength(1)
50
+ })
51
+
52
+ it('uses the same suspended, due-now and overdue next-run wording as the drawer', () => {
53
+ vi.useFakeTimers(); vi.setSystemTime(now)
54
+ const schedule = (name: string, minutes: number, suspend = false) => ({ metadata: { name, namespace: 'pg' }, spec: { suspend }, status: { nextScheduleTime: new Date(now - minutes * 60000).toISOString() } })
55
+ const html = render({ scheduledBackups: [schedule('paused', 90, true), schedule('due', 5), schedule('late', 15)] })
56
+ expect(html).toContain('suspended')
57
+ const host = document.createElement('div'); host.innerHTML = html
58
+ const paused = [...host.querySelectorAll('tr')].find((row) => row.textContent?.includes('paused'))!
59
+ expect(paused.textContent).toContain('Suspended')
60
+ expect(paused.textContent).not.toContain('Enabled')
61
+ expect(html).toContain('due now')
62
+ expect(html).toContain('overdue by 15m')
63
+ expect(html).not.toContain('90m overdue')
64
+ })
65
+
66
+ it('retains old in-flight runs with their age qualification and drops old settled runs', () => {
67
+ vi.useFakeTimers(); vi.setSystemTime(now)
68
+ const html = render({ backups: [backup('old-running', 'running', 10), backup('old-completed', 'completed', 10), backup('old-failed', 'failed', 10)] })
69
+ expect(html).toContain('old-running')
70
+ expect(html).toContain('started more than a week ago')
71
+ expect(html).not.toContain('old-completed')
72
+ expect(html).not.toContain('old-failed')
73
+ expect(html).toContain('All 1')
74
+ })
75
+
76
+ it('marks backup run counts as lower bounds or unknown when coverage is incomplete', () => {
77
+ vi.useFakeTimers(); vi.setSystemTime(now)
78
+ const html = render({ backups: [backup('failed-read', 'failed', 1)] }, { state: 'partial', allowedNamespaces: ['pg'] })
79
+ expect(html).toContain('Failed ≥1')
80
+ expect(html).toContain('All ≥1')
81
+ expect(html).toContain('Backups not read')
82
+ const denied = render({}, { state: 'denied' })
83
+ expect(denied).toContain('Failed ?')
84
+ expect(denied).toContain('All ?')
85
+ expect(denied).toContain('No access to Backups')
86
+ const scoped = render({ backups: [backup('failed-read', 'failed', 1)] }, { state: 'partial', allowedNamespaces: ['pg'] }, 'cluster=pg/pg')
87
+ expect(scoped).toContain('Failed 1')
88
+ expect(scoped).not.toContain('Failed ≥1')
89
+ })
90
+
91
+ it('explains absent WAL storage and suppresses ObjectStore-only footnotes on fleet and cluster views', () => {
92
+ const cluster = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'payments', namespace: 'pg' }, spec: {}, status: { conditions: [{ type: 'ContinuousArchiving', status: 'True', message: 'Continuous archiving is working', lastTransitionTime: '2026-10-01T12:00:00Z' }] } }
93
+ for (const scope of [undefined, { namespace: 'pg', name: 'payments' }]) {
94
+ const html = render({ clusters: [cluster] }, { state: 'full' }, '', scope)
95
+ expect(html).toContain('Not archived: no destination configured')
96
+ if (scope) {
97
+ expect(html).toContain('point-in-time recovery is unavailable')
98
+ expect(html).toContain('Operator report')
99
+ expect(html).toContain('aria-expanded="false"')
100
+ } else {
101
+ expect(html).toContain('Recovery evidence for pg/payments')
102
+ expect(html).not.toContain('Operator report')
103
+ expect(html).toContain('without keeping it')
104
+ }
105
+ expect(html).toContain('None: no backup destination')
106
+ expect(html).not.toContain('Recovery windows come from ObjectStore status')
107
+ expect(html).not.toContain('ObjectStore has no health status')
108
+ }
109
+ })
110
+ it('keeps ObjectStore notes when ObjectStore evidence is present', () => {
111
+ const cluster = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'pg', namespace: 'pg' }, spec: { plugins: [{ name: 'barman-cloud.cloudnative-pg.io', parameters: { barmanObjectName: 'store' } }] } }
112
+ const store = { apiVersion: 'barmancloud.cnpg.io/v1', kind: 'ObjectStore', metadata: { name: 'store', namespace: 'pg' }, spec: {} }
113
+ for (const scope of [undefined, { namespace: 'pg', name: 'pg' }]) {
114
+ const html = render({ clusters: [cluster], objectStores: [store] }, { state: 'full' }, '', scope)
115
+ expect(html).toContain('Recovery windows come from ObjectStore status')
116
+ expect(html).toContain('ObjectStore has no health status')
117
+ }
118
+ })
119
+ it('gives schedule state space and wraps the long next-run heading without changing shared table headings', () => {
120
+ const schedule = { metadata: { name: 'nightly', namespace: 'pg' }, spec: { cluster: { name: 'payments' } } }
121
+ const html = render({ scheduledBackups: [schedule] }, { state: 'full' }, '', { namespace: 'pg', name: 'payments' })
122
+ const table = html.slice(html.indexOf('Schedules</h2>'))
123
+ expect(table).toContain('min-width:820px')
124
+ expect(table).toContain('width:25%')
125
+ expect(table).toContain('block whitespace-normal')
126
+ expect(table).toContain('whitespace-nowrap')
127
+ })
128
+
129
+ it('uses the Configuration card gutter and SectionHeading in scoped recovery evidence', () => {
130
+ const cluster = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'pg', namespace: 'pg' }, spec: {}, status: {} }
131
+ const host = document.createElement('div'); host.innerHTML = render({ clusters: [cluster] }, { state: 'full' }, '', { namespace: 'pg', name: 'pg' })
132
+ const title = [...host.querySelectorAll('h3')].find((h) => h.textContent === 'Recovery evidence')!
133
+ expect(title.classList.contains('uppercase')).toBe(true)
134
+ expect(title.closest('section')?.classList.contains('px-4')).toBe(true)
135
+ expect(title.closest('section')?.parentElement?.classList.contains('p-4')).toBe(true)
136
+ })
137
+
138
+ it('explains no-destination operator success once for the fleet table and provides full evidence access per row', () => {
139
+ const clusters = ['orders', 'payments'].map((name) => ({ apiVersion: 'postgresql.cnpg.io/v1', metadata: { name, namespace: 'pg' }, spec: {}, status: { conditions: [{ type: 'ContinuousArchiving', status: 'True', message: 'working' }] } }))
140
+ const html = render({ clusters })
141
+ expect(html.match(/CloudNativePG reports archiving as working because/g)).toHaveLength(1)
142
+ expect(html.match(/Recovery evidence for pg\//g)).toHaveLength(2)
143
+ expect(html).not.toContain('Operator report')
144
+ expect(html).not.toContain('WAL is not archived to recovery storage')
145
+ })