@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,56 @@
1
+ import { registerCNPGOperationObserver, summarizeSteps, type CNPGObservation, type CNPGObserverResult, type CNPGOpStep, type CNPGTrackedOperation } from '../operations/model'
2
+ import { isCNPGRecoveryJob } from './restoreModel'
3
+
4
+ const HEALTHY_PHASE = 'Cluster in healthy state'
5
+ const FAILING_PHASE = /unrecoverable|unable to|cannot|error|failed/i
6
+
7
+ /**
8
+ * The tracker's view of a restore, from what the operation tracker already
9
+ * reads (the Cluster and its Jobs). The Cluster page's restore panel adds the
10
+ * recovery Pods and their init containers from /recovery.
11
+ */
12
+ export function restoreOperationObserver(op: CNPGTrackedOperation, obs: CNPGObservation): CNPGObserverResult {
13
+ const c = obs.cluster
14
+ if (!c) {
15
+ return obs.now - op.startedAt < 60_000
16
+ ? { state: 'requested', detail: `Waiting for Cluster ${op.cluster} to appear`, progressKey: 'absent' }
17
+ : { state: 'unobservable', detail: `Cluster ${op.cluster} is not visible with your access` }
18
+ }
19
+ const phase: string | undefined = c.status?.phase
20
+ const desired: number | undefined = c.spec?.instances
21
+ const ready: number | undefined = c.status?.readyInstances
22
+ const jobs = obs.ha?.jobs.state === 'ok' ? obs.ha.jobs.items.filter(isCNPGRecoveryJob) : undefined
23
+ const failed = jobs?.find((j) => j.phase === 'failed')
24
+ if (failed) return { state: 'failed', detail: `Recovery Job ${failed.name} failed${failed.reason ? `: ${failed.reason}` : ''}` }
25
+ if (phase && phase !== HEALTHY_PHASE && FAILING_PHASE.test(phase)) {
26
+ return { state: 'failed', detail: phase + (c.status?.phaseReason ? `: ${c.status.phaseReason}` : '') }
27
+ }
28
+ // A ready restored primary proves recovery finished, whether or not the
29
+ // caller can read the recovery Job.
30
+ const primaryReady = typeof ready === 'number' && ready > 0
31
+ const jobDone = primaryReady ? true : jobs === undefined ? null : jobs.some((j) => j.phase === 'succeeded')
32
+ const steps: CNPGOpStep[] = [
33
+ { label: 'Base backup restored and WAL replayed (recovery Job finished)', done: jobDone },
34
+ { label: 'Restored primary ready', done: typeof ready === 'number' ? ready > 0 : false },
35
+ {
36
+ label: `All ${desired ?? ''} instances ready and the Cluster healthy`.replace(' ', ' '),
37
+ done: typeof ready === 'number' && typeof desired === 'number' ? ready >= desired && phase === HEALTHY_PHASE : false,
38
+ },
39
+ ]
40
+ const state = summarizeSteps(steps)
41
+ return {
42
+ state: state === 'unobservable' ? 'progressing' : state,
43
+ steps,
44
+ detail: [phase, typeof ready === 'number' ? `${ready}/${desired ?? '?'} ready` : null].filter(Boolean).join(' · '),
45
+ progressKey: `${phase ?? ''}|${ready ?? ''}|${jobs?.map((j) => j.phase).join(',') ?? '?'}`,
46
+ }
47
+ }
48
+
49
+ function registerRestoreObserver(): 'restore' {
50
+ registerCNPGOperationObserver('restore', restoreOperationObserver)
51
+ return 'restore'
52
+ }
53
+
54
+ // Registered through a used export, not a bare side-effect import: the package
55
+ // declares itself side-effect free, so a bare import would be dropped.
56
+ export const CNPG_RESTORE_OPERATION = registerRestoreObserver()
@@ -0,0 +1,38 @@
1
+ import { QueryClient, QueryObserver } from '@tanstack/react-query'
2
+ import { expect, it, vi } from 'vitest'
3
+ import { refetchCNPGDetail } from './refresh'
4
+
5
+ it('refreshes active reads for the object and namespace and waits for them', async () => {
6
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
7
+ let finish!: (value: number) => void
8
+ const pending = new Promise<number>((resolve) => { finish = resolve })
9
+ const reads = [
10
+ ['cnpg', 'runtime', 'db', 'pg'], ['cnpg', 'ha', 'db', 'pg'], ['cnpg', 'storage', 'db', 'pg'],
11
+ ['cnpg', 'history', 'db', 'pg', '1h'], ['cnpg', 'sessions', 'db', 'pg', 'pg-1'],
12
+ ['cnpg', 'workspace', 'db'], ['cnpg', 'workspace', 'db,other'], ['cnpg', 'workspace', ''],
13
+ ['cnpg', 'capabilities', 'clusters', 'db', 'pg'], ['cnpg', 'restore-capability', 'db'],
14
+ ['cnpg', 'runtime', 'db', 'other'], ['cnpg', 'runtime', 'other', 'pg'], ['resource', 'pods'],
15
+ ['cnpg', 'workspace', 'elsewhere'], ['cnpg', 'parameters', 'db', 'pg'],
16
+ ]
17
+ const fns = reads.map((_, i) => vi.fn(() => i === 0 ? pending : Promise.resolve(i)))
18
+ const observers = reads.map((queryKey, i) => {
19
+ client.setQueryData(queryKey, -1)
20
+ const observer = new QueryObserver(client, { queryKey, queryFn: fns[i], staleTime: Infinity })
21
+ return observer.subscribe(() => {})
22
+ })
23
+ const inactive = vi.fn(async () => 1)
24
+ client.setQueryData(['cnpg', 'recovery', 'db', 'pg'], -1)
25
+ client.setQueryDefaults(['cnpg', 'recovery', 'db', 'pg'], { queryFn: inactive })
26
+ let done = false
27
+ const refresh = refetchCNPGDetail(client, { plural: 'clusters', namespace: 'db', name: 'pg', group: 'postgresql.cnpg.io' }).then(() => { done = true })
28
+ await Promise.resolve()
29
+ expect(done).toBe(false)
30
+ for (const i of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 14]) expect(fns[i]).toHaveBeenCalledTimes(1)
31
+ for (const i of [10, 11, 12, 13]) expect(fns[i]).not.toHaveBeenCalled()
32
+ expect(inactive).not.toHaveBeenCalled()
33
+ finish(1)
34
+ await refresh
35
+ expect(done).toBe(true)
36
+ observers.forEach((off) => off())
37
+ client.clear()
38
+ })
@@ -0,0 +1,17 @@
1
+ import type { QueryClient } from '@tanstack/react-query'
2
+ import type { CNPGDetailTarget } from './routes'
3
+
4
+ export function refetchCNPGDetail(client: QueryClient, target: CNPGDetailTarget): Promise<void> {
5
+ return client.refetchQueries({
6
+ type: 'active',
7
+ predicate: ({ queryKey: key }) => {
8
+ if (key[0] !== 'cnpg') return false
9
+ if (['workspace', 'disk', 'fleet-metrics', 'operator-status'].includes(String(key[1]))) {
10
+ return key[2] === '' || String(key[2]).split(',').includes(target.namespace)
11
+ }
12
+ if (key[1] === 'capabilities') return key[2] === target.plural && key[3] === target.namespace && key[4] === target.name
13
+ if (key[1] === 'restore-capability') return key[2] === target.namespace
14
+ return key[2] === target.namespace && key[3] === target.name
15
+ },
16
+ })
17
+ }
@@ -0,0 +1,29 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { RefreshFailedNotice } from '../workspace/layout'
4
+
5
+ describe('RefreshFailedNotice', () => {
6
+ it('says a refresh failed and how old the data on screen is', () => {
7
+ const html = renderToStaticMarkup(
8
+ <RefreshFailedNotice queries={[{ isRefetchError: true, error: new Error('Gateway Timeout'), dataUpdatedAt: Date.now() - 5 * 60_000 }]} />,
9
+ )
10
+ expect(html).toContain('Last refresh failed: Gateway Timeout')
11
+ expect(html).toContain('showing data from 5m ago')
12
+ })
13
+ it('names the oldest of several failed reads', () => {
14
+ const html = renderToStaticMarkup(
15
+ <RefreshFailedNotice
16
+ queries={[
17
+ { isRefetchError: true, error: new Error('recent'), dataUpdatedAt: Date.now() - 60_000 },
18
+ { isRefetchError: true, error: new Error('older'), dataUpdatedAt: Date.now() - 3 * 3_600_000 },
19
+ { isRefetchError: false, error: null, dataUpdatedAt: Date.now() - 10 * 3_600_000 },
20
+ ]}
21
+ />,
22
+ )
23
+ expect(html).toContain('older')
24
+ expect(html).toContain('3h ago')
25
+ })
26
+ it('renders nothing while refreshes succeed', () => {
27
+ expect(renderToStaticMarkup(<RefreshFailedNotice queries={[{ isRefetchError: false, error: null, dataUpdatedAt: Date.now() }]} />)).toBe('')
28
+ })
29
+ })
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { cnpgDetailKindFor, cnpgDetailPath, decodeDrawerTrail, encodeDrawerTrail, parseCNPGRoute, sameResource } from './routes'
2
+ import { cnpgClusterKindListMode, cnpgDetailKindFor, cnpgDetailPath, cnpgViewNamedForKind, parseCNPGRoute } from './routes'
3
+ import { decodeDrawerTrail, encodeDrawerTrail, sameSelectedResource } from '../../utils/drawer-trail'
3
4
 
4
5
  describe('CNPG routes', () => {
5
6
  it('parses workspace screens and falls back to Overview for unknown or unavailable ones', () => {
@@ -30,8 +31,8 @@ describe('CNPG routes', () => {
30
31
  it('distinguishes same-named kinds from different groups', () => {
31
32
  const cnpg = { kind: 'clusters', group: 'postgresql.cnpg.io', namespace: 'a', name: 'x' }
32
33
  const capi = { kind: 'clusters', group: 'cluster.x-k8s.io', namespace: 'a', name: 'x' }
33
- expect(sameResource(cnpg, capi)).toBe(false)
34
- expect(sameResource(cnpg, { ...cnpg })).toBe(true)
34
+ expect(sameSelectedResource(cnpg, capi)).toBe(false)
35
+ expect(sameSelectedResource(cnpg, { ...cnpg })).toBe(true)
35
36
  })
36
37
 
37
38
  it('parses full-detail routes for every CNPG kind and the cluster-scoped placeholder', () => {
@@ -58,3 +59,33 @@ describe('CNPG routes', () => {
58
59
  expect(cnpgDetailKindFor('backups', 'velero.io')).toBeNull()
59
60
  })
60
61
  })
62
+
63
+ describe('cnpgViewNamedForKind', () => {
64
+ it('is true only for the kind the crumb’s view is named after', () => {
65
+ expect(cnpgViewNamedForKind('clusters')).toBe(true)
66
+ expect(cnpgViewNamedForKind('backups')).toBe(true)
67
+ expect(cnpgViewNamedForKind('poolers')).toBe(true)
68
+ // Also under Backups, but not what the view is named after.
69
+ expect(cnpgViewNamedForKind('scheduledbackups')).toBe(false)
70
+ expect(cnpgViewNamedForKind('objectstores')).toBe(false)
71
+ expect(cnpgViewNamedForKind('databases')).toBe(false)
72
+ expect(cnpgViewNamedForKind('pods')).toBe(false)
73
+ })
74
+ })
75
+
76
+ describe('cnpgClusterKindListMode', () => {
77
+ const cnpg = { name: 'clusters', group: 'postgresql.cnpg.io' }
78
+ it('shows the Clusters view for the CloudNativePG Cluster kind when this Radar serves it', () => {
79
+ expect(cnpgClusterKindListMode(cnpg, 'supported', false)).toBe('view')
80
+ })
81
+ it('waits, rather than flashing the table, only while capabilities first load', () => {
82
+ expect(cnpgClusterKindListMode(cnpg, 'unknown', true)).toBe('wait')
83
+ expect(cnpgClusterKindListMode(cnpg, 'unknown', false)).toBe('table')
84
+ })
85
+ it('keeps the table on a Radar without the views, and for every other kind', () => {
86
+ expect(cnpgClusterKindListMode(cnpg, 'unsupported', false)).toBe('table')
87
+ expect(cnpgClusterKindListMode({ name: 'clusters', group: 'cluster.x-k8s.io' }, 'supported', false)).toBe('table')
88
+ expect(cnpgClusterKindListMode({ name: 'backups', group: 'postgresql.cnpg.io' }, 'supported', false)).toBe('table')
89
+ expect(cnpgClusterKindListMode(null, 'supported', false)).toBe('table')
90
+ })
91
+ })
@@ -1,10 +1,11 @@
1
- import type { SelectedResource } from '../../types'
1
+ import { CNPG_KIND_BY_KEY, type CNPGWorkspaceKey } from '@skyhook-io/k8s-ui'
2
+ import type { RadarFeatureSupport } from '../../api/radarFeatures'
2
3
 
3
4
  export type CNPGScreen = 'overview' | 'protection' | 'declarations' | 'pooling' | 'operator'
4
5
 
5
6
  export const CNPG_SCREENS: { id: CNPGScreen; label: string; path: string }[] = [
6
- { id: 'overview', label: 'Overview', path: '/cnpg' },
7
- { id: 'protection', label: 'Protection', path: '/cnpg/protection' },
7
+ { id: 'overview', label: 'Clusters', path: '/cnpg' },
8
+ { id: 'protection', label: 'Backups', path: '/cnpg/protection' },
8
9
  { id: 'declarations', label: 'Declarations', path: '/cnpg/declarations' },
9
10
  { id: 'pooling', label: 'Pooling', path: '/cnpg/pooling' },
10
11
  { id: 'operator', label: 'Operator', path: '/cnpg/operator' },
@@ -23,18 +24,62 @@ export interface CNPGRoute {
23
24
  }
24
25
 
25
26
  // The CNPG kinds that have a CNPG-framed full detail, with the workspace
26
- // destination each one lives under.
27
- export const CNPG_DETAIL_KINDS: Record<string, { group: string; kind: string; home: CNPGScreen; clusterScoped?: boolean }> = {
28
- clusters: { group: 'postgresql.cnpg.io', kind: 'Cluster', home: 'overview' },
29
- backups: { group: 'postgresql.cnpg.io', kind: 'Backup', home: 'protection' },
30
- scheduledbackups: { group: 'postgresql.cnpg.io', kind: 'ScheduledBackup', home: 'protection' },
31
- objectstores: { group: 'barmancloud.cnpg.io', kind: 'ObjectStore', home: 'protection' },
32
- databases: { group: 'postgresql.cnpg.io', kind: 'Database', home: 'declarations' },
33
- publications: { group: 'postgresql.cnpg.io', kind: 'Publication', home: 'declarations' },
34
- subscriptions: { group: 'postgresql.cnpg.io', kind: 'Subscription', home: 'declarations' },
35
- poolers: { group: 'postgresql.cnpg.io', kind: 'Pooler', home: 'pooling' },
36
- imagecatalogs: { group: 'postgresql.cnpg.io', kind: 'ImageCatalog', home: 'operator' },
37
- clusterimagecatalogs: { group: 'postgresql.cnpg.io', kind: 'ClusterImageCatalog', home: 'operator', clusterScoped: true },
27
+ // destination each one lives under. Kind and group come from the workspace's
28
+ // kind table, so the two never disagree.
29
+ const CNPG_DETAIL_HOMES: Partial<Record<CNPGWorkspaceKey, CNPGScreen>> = {
30
+ clusters: 'overview',
31
+ backups: 'protection',
32
+ scheduledBackups: 'protection',
33
+ objectStores: 'protection',
34
+ databases: 'declarations',
35
+ publications: 'declarations',
36
+ subscriptions: 'declarations',
37
+ databaseRoles: 'declarations',
38
+ poolers: 'pooling',
39
+ imageCatalogs: 'operator',
40
+ clusterImageCatalogs: 'operator',
41
+ }
42
+
43
+ export const CNPG_DETAIL_KINDS: Record<string, { group: string; kind: string; home: CNPGScreen; clusterScoped?: boolean }> = Object.fromEntries(
44
+ (Object.entries(CNPG_DETAIL_HOMES) as [CNPGWorkspaceKey, CNPGScreen][]).map(([key, home]) => {
45
+ const k = CNPG_KIND_BY_KEY[key]
46
+ return [k.plural, { group: k.group, kind: k.kind, home, ...(key === 'clusterImageCatalogs' ? { clusterScoped: true } : {}) }]
47
+ }),
48
+ )
49
+
50
+ // The kind each view is named after.
51
+ const CNPG_VIEW_NAMESAKES: Partial<Record<CNPGScreen, string>> = { overview: 'clusters', protection: 'backups', pooling: 'poolers' }
52
+
53
+ /**
54
+ * Whether a kind is the one its home view is named after (Cluster in
55
+ * Clusters, Backup in Backups, Pooler in Pooling): the crumb then already says
56
+ * what the object is, so the kind badge is left out. Other kinds in the same
57
+ * view, such as ScheduledBackup and ObjectStore under Backups, keep it.
58
+ */
59
+ export function cnpgViewNamedForKind(plural: string): boolean {
60
+ const home = CNPG_DETAIL_KINDS[plural]?.home
61
+ return !!home && CNPG_VIEW_NAMESAKES[home] === plural
62
+ }
63
+
64
+ /** The CloudNativePG Cluster kind, whose list is the Clusters view. */
65
+ export function isCNPGClusterKind(kind: { name: string; group: string } | null | undefined): boolean {
66
+ return !!kind && kind.name === 'clusters' && kind.group === 'postgresql.cnpg.io'
67
+ }
68
+
69
+ /**
70
+ * What the CloudNativePG Cluster kind's Resources route shows: the Clusters
71
+ * view when this Radar serves it; a loader while capabilities load for the
72
+ * first time, so the table never flashes before it; otherwise the table (a
73
+ * Radar older than the views, or capabilities that could not be read).
74
+ */
75
+ export function cnpgClusterKindListMode(
76
+ kind: { name: string; group: string } | null | undefined,
77
+ support: RadarFeatureSupport,
78
+ capabilitiesPending: boolean,
79
+ ): 'view' | 'wait' | 'table' {
80
+ if (!isCNPGClusterKind(kind)) return 'table'
81
+ if (support === 'supported') return 'view'
82
+ return support === 'unknown' && capabilitiesPending ? 'wait' : 'table'
38
83
  }
39
84
 
40
85
  export function cnpgDetailKindFor(plural: string, group: string | undefined): string | null {
@@ -77,36 +122,3 @@ export function cnpgDetailPath(target: Omit<CNPGDetailTarget, 'group'>, ctx?: st
77
122
  export function cnpgScreenPath(screen: CNPGScreen): string {
78
123
  return CNPG_SCREENS.find((s) => s.id === screen)?.path ?? '/cnpg'
79
124
  }
80
-
81
- // Drawer identity in the URL: kind:group:namespace:name, chained with "~" for
82
- // the in-drawer trail (last entry is the one shown). Kubernetes names and API
83
- // groups cannot contain ":" or "~", and the group is mandatory — CNPG's Cluster
84
- // and Backup collide with CAPI, KubeBlocks and Velero kinds.
85
- export function encodeDrawerRef(r: SelectedResource): string {
86
- return [r.kind, r.group ?? '', r.namespace ?? '', r.name].join(':')
87
- }
88
-
89
- export function decodeDrawerRef(s: string): SelectedResource | null {
90
- const parts = s.split(':')
91
- if (parts.length !== 4 || !parts[0] || !parts[3]) return null
92
- return { kind: parts[0], group: parts[1], namespace: parts[2], name: parts[3] }
93
- }
94
-
95
- export function decodeDrawerTrail(param: string | null): SelectedResource[] {
96
- if (!param) return []
97
- return param.split('~').map(decodeDrawerRef).filter((r): r is SelectedResource => r !== null)
98
- }
99
-
100
- export function encodeDrawerTrail(trail: SelectedResource[]): string {
101
- return trail.map(encodeDrawerRef).join('~')
102
- }
103
-
104
- export function sameResource(a: SelectedResource | null | undefined, b: SelectedResource | null | undefined): boolean {
105
- if (!a || !b) return false
106
- return (
107
- a.kind.toLowerCase() === b.kind.toLowerCase() &&
108
- (a.group ?? '') === (b.group ?? '') &&
109
- (a.namespace ?? '') === (b.namespace ?? '') &&
110
- a.name === b.name
111
- )
112
- }
@@ -0,0 +1,198 @@
1
+ import {
2
+ CNPG_SLOT_RETENTION_WARNING_BYTES,
3
+ cnpgFormatLag,
4
+ cnpgHASlotInstance,
5
+ cnpgReplicationTone,
6
+ cnpgSlotRetentionProblem,
7
+ cnpgStandbyNotReceivingProblem,
8
+ cnpgWithProblems,
9
+ formatGrant,
10
+ type CNPGFleetRow,
11
+ type CNPGProblem,
12
+ type CNPGInstanceLive,
13
+ type CNPGReplicationLive,
14
+ type CNPGSlotRetention,
15
+ type CNPGStandbyGap,
16
+ } from '@skyhook-io/k8s-ui'
17
+ import type { CNPGRuntimeResponse } from '../../api/cnpg'
18
+
19
+ /** The instance-manager facts the HA section shows, from a runtime read. */
20
+ export function cnpgInstanceLive(rt: CNPGRuntimeResponse | undefined): CNPGInstanceLive[] | undefined {
21
+ if (!rt || rt.permission.proxy === 'denied') return undefined
22
+ return rt.instances.map((i) => ({
23
+ pod: i.pod,
24
+ state: i.status.state,
25
+ pendingRestart: i.status.pendingRestart,
26
+ pendingRestartForDecrease: i.status.pendingRestartForDecrease,
27
+ roleDetail: i.status.roleDetail,
28
+ instanceManagerVersion: i.status.instanceManagerVersion,
29
+ timeline: i.status.timeline,
30
+ incomplete: i.status.incomplete,
31
+ reason: i.status.error ?? i.status.reason,
32
+ }))
33
+ }
34
+
35
+ /** Why the HA section has no instance-manager facts at all; undefined when it has them. */
36
+ export function cnpgInstanceLiveUnavailable(rt: CNPGRuntimeResponse | undefined, error: unknown): string | undefined {
37
+ if (rt?.permission.proxy === 'denied') return `needs ${formatGrant(rt.permission.grant) ?? 'get pods/proxy'}`
38
+ if (rt) return undefined
39
+ return error instanceof Error ? `the runtime read failed: ${error.message}` : 'instance managers not read yet'
40
+ }
41
+
42
+ /** Why cnpgReplicationLive has no answer; undefined when it has one. */
43
+ export function cnpgReplicationGap(rt: CNPGRuntimeResponse | undefined, error: unknown): string | undefined {
44
+ const unavailable = cnpgInstanceLiveUnavailable(rt, error)
45
+ if (unavailable) return unavailable
46
+ const primary = rt?.instances.find((i) => i.role === 'primary')
47
+ if (!primary) return 'no instance reports being the primary'
48
+ if (primary.status.state !== 'ok' && primary.status.state !== 'partial') {
49
+ return `${primary.pod} did not report (${primary.status.error ?? primary.status.reason ?? primary.status.state})`
50
+ }
51
+ if (!primary.status.replication) return `${primary.pod} did not report its replication rows`
52
+ return undefined
53
+ }
54
+
55
+ /** Streaming standbys and the worst replay lag, from the primary's pg_stat_replication. */
56
+ export function cnpgReplicationLive(rt: CNPGRuntimeResponse | undefined): CNPGReplicationLive | undefined {
57
+ const primary = rt?.instances.find((i) => i.role === 'primary')
58
+ if (!primary || (primary.status.state !== 'ok' && primary.status.state !== 'partial')) return undefined
59
+ const reps = primary.status.replication
60
+ if (!reps) return undefined
61
+ const lags = reps.map((r) => r.replayLag).filter((v): v is number => v !== undefined)
62
+ return {
63
+ streaming: reps.filter((r) => r.state === 'streaming').length,
64
+ standbys: rt!.instances.filter((i) => i.role !== 'primary').length,
65
+ maxReplayLagSeconds: lags.length ? Math.max(...lags) : undefined,
66
+ }
67
+ }
68
+
69
+ const LIVE_STANDBY_SOURCE = 'The primary’s pg_stat_replication and each standby’s /pg/status, read through the instance manager'
70
+
71
+ /** Instances named in the Cluster's cnpg.io/fencedInstances annotation; '*' fences all. */
72
+ function fencedInstances(cluster: any): { all: boolean; pods: Set<string> } {
73
+ const raw = cluster?.metadata?.annotations?.['cnpg.io/fencedInstances']
74
+ try {
75
+ const list = raw ? JSON.parse(raw) : []
76
+ if (!Array.isArray(list)) return { all: false, pods: new Set() }
77
+ return { all: list.includes('*'), pods: new Set(list.filter((x: unknown): x is string => typeof x === 'string')) }
78
+ } catch {
79
+ return { all: false, pods: new Set() }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Standbys the live read shows receiving nothing: no row in the primary's
85
+ * pg_stat_replication although the standby itself answered. A fenced standby
86
+ * is stopped on purpose and one running pg_rewind is rejoining, so neither is
87
+ * a gap.
88
+ */
89
+ export function cnpgLiveStandbyGaps(row: CNPGFleetRow, rt: CNPGRuntimeResponse | undefined): CNPGStandbyGap[] {
90
+ const primary = rt?.instances.find((i) => i.role === 'primary')
91
+ const reps = primary?.status.state === 'ok' ? primary.status.replication : undefined
92
+ if (!rt || !primary || !reps || row.hibernated) return []
93
+ const fenced = fencedInstances(row.cluster)
94
+ if (fenced.all) return []
95
+ const connected = new Set(reps.map((r) => r.applicationName))
96
+ const standbys = rt.instances.filter((i) => i.role !== 'primary')
97
+ const gaps = standbys.filter(
98
+ (i) => !connected.has(i.pod) && !fenced.pods.has(i.pod) && (i.status.state === 'ok' || i.status.state === 'partial') && i.status.roleDetail !== 'pgRewind',
99
+ )
100
+ const expected = row.instances.desired !== null ? Math.max(0, row.instances.desired - 1) : standbys.length
101
+ return gaps.map((i) => {
102
+ const evidence = ['it has no row in the primary’s pg_stat_replication']
103
+ if (i.status.isWalReceiverActive === false || i.status.roleDetail === 'fileBased') evidence.push('it reports no active WAL receiver')
104
+ if (i.status.replayPaused || i.status.roleDetail === 'replayPaused') evidence.push('its WAL replay is paused')
105
+ if (i.status.timeline !== undefined && primary.status.timeline !== undefined && i.status.timeline !== primary.status.timeline) {
106
+ evidence.push(`it is on timeline ${i.status.timeline} while the primary is on ${primary.status.timeline}`)
107
+ }
108
+ return {
109
+ pod: i.pod,
110
+ evidence,
111
+ measuredBy: 'the instance manager',
112
+ sourceDetail: LIVE_STANDBY_SOURCE,
113
+ noneReceiving: expected > 0 && reps.length === 0,
114
+ }
115
+ })
116
+ }
117
+
118
+ /** Inactive physical slots on the primary that hold at least the warning amount of WAL. */
119
+ export function cnpgLiveSlotRetentions(row: CNPGFleetRow, rt: CNPGRuntimeResponse | undefined): CNPGSlotRetention[] {
120
+ const primary = rt?.instances.find((i) => i.role === 'primary')
121
+ const slots = primary && (primary.status.state === 'ok' || primary.status.state === 'partial') ? primary.status.slots : undefined
122
+ if (!primary || !slots) return []
123
+ const instances = (rt?.instances ?? []).map((i) => i.pod)
124
+ return slots
125
+ .filter((sl): sl is typeof sl & { retainedBytes: number } => sl.active === false && sl.type === 'physical' && sl.retainedBytes !== undefined && sl.retainedBytes >= CNPG_SLOT_RETENTION_WARNING_BYTES)
126
+ .map((sl) => ({
127
+ slot: sl.name,
128
+ pod: primary.pod,
129
+ bytes: sl.retainedBytes,
130
+ standby: cnpgHASlotInstance(row.cluster, sl.name, instances),
131
+ measuredBy: 'the instance manager',
132
+ sourceDetail: 'The primary’s /pg/status replication slots (active) and the exporter’s retained WAL',
133
+ }))
134
+ }
135
+
136
+ /**
137
+ * The fleet row with what the instance managers report: the live replication
138
+ * fact, standbys that receive nothing, and inactive slots. A complete live read
139
+ * replaces the fleet's (Prometheus) answer for what it covers, clearing a
140
+ * problem it disproves — a standby now connected, a slot no longer inactive.
141
+ */
142
+ export function withLiveReplication(row: CNPGFleetRow, rt: CNPGRuntimeResponse | undefined): CNPGFleetRow {
143
+ return withLiveSlots(withLiveStandbys(row, rt), rt)
144
+ }
145
+
146
+ // Replaces the Kubernetes-only replication fact with the primary's
147
+ // pg_stat_replication when it has been read; otherwise keeps "lag unknown".
148
+ // Expected standbys are spec.instances − 1: a standby whose Pod is gone is
149
+ // missing, not absent from the count.
150
+ function withLiveStandbys(row: CNPGFleetRow, rt: CNPGRuntimeResponse | undefined): CNPGFleetRow {
151
+ const primary = rt?.instances.find((i) => i.role === 'primary')
152
+ if (!primary || primary.status.state !== 'ok' || row.instances.desired === 1 || row.hibernated) return row
153
+ const reps = primary.status.replication
154
+ if (!reps) return row
155
+ const streaming = reps.filter((r) => r.state === 'streaming').length
156
+ const lags = reps.map((r) => r.replayLag).filter((v): v is number => v !== undefined)
157
+ const maxLag = lags.length ? Math.max(...lags) : undefined
158
+ const source = 'From the primary’s pg_stat_replication via the instance manager'
159
+ // A standby that isn't connected has no row, so its delay isn't in the max.
160
+ const lagFor = (allConnected: boolean) =>
161
+ maxLag === undefined ? '' : ` · max replay delay ${cnpgFormatLag(maxLag)}${allConnected ? '' : ' (connected standbys only)'}`
162
+ const gaps = cnpgLiveStandbyGaps(row, rt)
163
+ const connected = new Set(reps.map((r) => r.applicationName))
164
+ // The fleet's standby problem for one the primary now streams to is disproven.
165
+ const disproven = (p: CNPGProblem) => p.reason === 'CNPGStandbyNotReceiving' && connected.has(p.subject.name)
166
+ const raised = gaps.map((g) => cnpgStandbyNotReceivingProblem(row, g))
167
+ if (row.instances.desired === null) {
168
+ const live = { ...row, replication: { text: `${streaming} streaming${lagFor(false)}`, tone: 'unknown' as const, source: `${source}; spec.instances is not reported, so the expected standbys are unknown`, at: primary.status.capturedAt } }
169
+ return cnpgWithProblems(live, raised, disproven)
170
+ }
171
+ const expected = Math.max(0, row.instances.desired - 1)
172
+ const lagText = lagFor(reps.length >= expected)
173
+ const tone = cnpgReplicationTone(streaming, expected, maxLag)
174
+ const text = streaming < expected ? `${streaming} of ${expected} expected standbys streaming` : `${streaming}/${expected} streaming`
175
+ const missing = gaps.length > 0 ? ` · ${gaps.map((g) => g.pod).join(', ')} not connected` : ''
176
+ const live = { ...row, replication: { text: `${text}${missing}${lagText}`, tone, source, at: primary.status.capturedAt } }
177
+ return cnpgWithProblems(live, raised, disproven)
178
+ }
179
+
180
+ // Slots are judged from the primary's own slot list whenever it was read,
181
+ // whatever the replication rows say. A fleet warning is cleared only by
182
+ // evidence against it: the slot active, measured below the threshold, or
183
+ // absent from a complete list. An unmeasured slot or a capped, partial list
184
+ // leaves it standing.
185
+ function withLiveSlots(row: CNPGFleetRow, rt: CNPGRuntimeResponse | undefined): CNPGFleetRow {
186
+ const primary = rt?.instances.find((i) => i.role === 'primary')
187
+ const slots = primary && (primary.status.state === 'ok' || primary.status.state === 'partial') ? primary.status.slots : undefined
188
+ if (!primary || !slots) return row
189
+ const complete = primary.status.state === 'ok'
190
+ const byName = new Map(slots.map((sl) => [sl.name, sl]))
191
+ const disproven = (p: CNPGProblem) => {
192
+ if (p.reason !== 'CNPGInactiveSlot' || !p.slot) return false
193
+ const sl = byName.get(p.slot)
194
+ if (!sl) return complete
195
+ return sl.active === true || (sl.retainedBytes !== undefined && sl.retainedBytes < CNPG_SLOT_RETENTION_WARNING_BYTES)
196
+ }
197
+ return cnpgWithProblems(row, cnpgLiveSlotRetentions(row, rt).map((r) => cnpgSlotRetentionProblem(row, r)), disproven)
198
+ }