@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,175 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { CNPGRuntimeInstance, CNPGRuntimeReplication } from '../../api/cnpg'
3
+ import type { CNPGSessionsResponse } from '../../api/cnpg-sessions'
4
+ import { cnpgCheckpointView, cnpgDatabaseHealthRows, cnpgNoStandbyText, cnpgIdAge, cnpgStandbyBacklogTone, cnpgPickedInstance, cnpgSessionAggregatesGap, cnpgSessionsCardShowsConnections, cnpgStandbyHeadline, cnpgTransactionRates } from './runtimeModel'
5
+
6
+ describe('cnpgCheckpointView', () => {
7
+ it('flags requested-checkpoint pressure only with enough checkpoints', () => {
8
+ expect(cnpgCheckpointView({ source: 'pg_stat_checkpointer', timed: 2, requested: 8 }, 'primary')).toMatchObject({ total: 10, requestedShare: 0.8, pressure: true })
9
+ expect(cnpgCheckpointView({ source: 'pg_stat_checkpointer', timed: 1, requested: 3 }, 'primary').pressure).toBe(false)
10
+ expect(cnpgCheckpointView({ source: 'pg_stat_checkpointer', timed: 1, requested: 2 }, 'primary').share).toBeUndefined()
11
+ expect(cnpgCheckpointView({ source: 'pg_stat_checkpointer', timed: 2, requested: 8 }, 'primary').share).toBe('80 %')
12
+ expect(cnpgCheckpointView({ source: 'pg_stat_bgwriter', timed: 5 }, 'primary').requestedShare).toBeUndefined()
13
+ })
14
+ it('shows restartpoints for standbys on PostgreSQL 17+, never from pg_stat_bgwriter', () => {
15
+ expect(cnpgCheckpointView({ source: 'pg_stat_checkpointer', restartpointsDone: 0 }, 'replica').showRestartpoints).toBe(true)
16
+ expect(cnpgCheckpointView({ source: 'pg_stat_checkpointer', restartpointsDone: 0 }, 'primary').showRestartpoints).toBe(false)
17
+ expect(cnpgCheckpointView({ source: 'pg_stat_bgwriter' }, 'replica').showRestartpoints).toBe(false)
18
+ })
19
+ })
20
+
21
+ describe('cnpgDatabaseHealthRows', () => {
22
+ it('joins per-database counters and ages by name and keeps unreported values unknown', () => {
23
+ const rows = cnpgDatabaseHealthRows({
24
+ state: 'ok',
25
+ databases: [
26
+ { database: 'app', xactCommit: 90, xactRollback: 10, tempFiles: 2, tempBytes: 4096 },
27
+ { database: 'idle', xactCommit: 0, xactRollback: 0 },
28
+ { database: 'noroll', xactCommit: 5 },
29
+ ],
30
+ xidAge: [{ database: 'app', age: 100 }],
31
+ mxidAge: [{ database: 'postgres', age: 7 }],
32
+ })
33
+ expect(rows.map((r) => r.database)).toEqual(['app', 'idle', 'noroll', 'postgres'])
34
+ expect(rows[0]).toEqual({ database: 'app', rollbackRatio: 0.1, tempFiles: 2, tempBytes: 4096, xidAge: 100 })
35
+ expect(rows[1].rollbackRatio).toBeUndefined()
36
+ expect(rows[1].noTransactions).toBe(true)
37
+ expect(rows[2].rollbackRatio).toBeUndefined()
38
+ expect(rows[2].noTransactions).toBeUndefined()
39
+ expect(rows[3]).toEqual({ database: 'postgres', mxidAge: 7 })
40
+ })
41
+ })
42
+
43
+ describe('standby cards', () => {
44
+ const standby = (status: Partial<CNPGRuntimeInstance['status']>) => ({ pod: 'pg-2', role: 'replica', status: { state: 'ok', ...status } }) as unknown as CNPGRuntimeInstance
45
+ const rep = { applicationName: 'pg-2', state: 'streaming', syncState: 'async' } as CNPGRuntimeReplication
46
+
47
+ it('rates replay delay on the Replication fact scale', () => {
48
+ expect(cnpgStandbyBacklogTone(0, 2)).toBe('healthy')
49
+ expect(cnpgStandbyBacklogTone(0, 8)).toBe('degraded')
50
+ expect(cnpgStandbyBacklogTone(0, 72)).toBe('unhealthy')
51
+ expect(cnpgStandbyBacklogTone(2 * 1024 ** 3, 0)).toBe('unhealthy')
52
+ expect(cnpgStandbyBacklogTone(undefined, 72)).toBe('unhealthy')
53
+ expect(cnpgStandbyBacklogTone(undefined, 8)).toBe('degraded')
54
+ expect(cnpgStandbyBacklogTone(undefined, 0)).toBe('unknown')
55
+ expect(cnpgStandbyBacklogTone(undefined, undefined)).toBe('unknown')
56
+ })
57
+
58
+ it('leads with a paused replay, keeping the streaming state secondary', () => {
59
+ expect(cnpgStandbyHeadline(standby({ roleDetail: 'replayPaused' }), rep, 'healthy', { fenced: false, primaryRead: true })).toEqual({
60
+ text: 'replay paused',
61
+ tone: 'degraded',
62
+ secondary: 'streaming · async',
63
+ })
64
+ expect(cnpgStandbyHeadline(standby({ replayPaused: true }), rep, 'unhealthy', { fenced: false, primaryRead: true }).tone).toBe('unhealthy')
65
+ expect(cnpgStandbyHeadline(standby({ roleDetail: 'streaming' }), rep, 'healthy', { fenced: false, primaryRead: true })).toEqual({ text: 'streaming · async', tone: 'healthy' })
66
+ })
67
+
68
+ it('says a fenced instance stopped only when the instance manager reports it', () => {
69
+ const fenced = { fenced: true, primaryRead: true }
70
+ expect(cnpgStandbyHeadline(standby({ state: 'unreachable', error: 'instance is fenced: (PostgreSQL is not running on this instance)' }), undefined, 'unknown', fenced).text).toBe('fenced · PostgreSQL not running')
71
+ expect(cnpgStandbyHeadline(standby({ state: 'denied' }), undefined, 'unknown', fenced).text).toBe('fenced · shutdown unverified')
72
+ expect(cnpgStandbyHeadline(standby({ state: 'ok' }), undefined, 'unknown', fenced).text).toBe('fenced · PostgreSQL still answering')
73
+ })
74
+
75
+ it('says a paused standby is not connected when the primary has no row for it', () => {
76
+ expect(cnpgStandbyHeadline(standby({ replayPaused: true }), undefined, 'unhealthy', { fenced: false, primaryRead: true })).toEqual({
77
+ text: 'replay paused',
78
+ tone: 'unhealthy',
79
+ secondary: 'not connected to the primary',
80
+ })
81
+ expect(cnpgStandbyHeadline(standby({ replayPaused: true }), undefined, 'unknown', { fenced: false, primaryRead: false }).secondary).toBeUndefined()
82
+ })
83
+ })
84
+
85
+ describe('cnpgTransactionRates', () => {
86
+ const p = (value: number, at: number, stale = false) => ({ value, at, stale })
87
+ it('prefers a current Prometheus rate, with its time', () => {
88
+ expect(cnpgTransactionRates({ commits: p(12.34, 100), rollbacks: p(0.5, 100) }, { commits: 3 }, 'primary only')).toEqual({
89
+ source: 'prometheus',
90
+ commits: '12.3',
91
+ rollbacks: '0.5',
92
+ at: 100,
93
+ })
94
+ })
95
+ it('never shows a stopped Prometheus series as current', () => {
96
+ expect(cnpgTransactionRates({ commits: p(12, 100, true), rollbacks: undefined }, { commits: 3 }, 'primary only')).toMatchObject({ source: 'sampled', commits: '3.0' })
97
+ expect(cnpgTransactionRates({ commits: p(12, 100, true), rollbacks: undefined }, null, 'primary only')).toEqual({
98
+ source: 'none',
99
+ commits: 'no recent sample',
100
+ rollbacks: 'no recent sample',
101
+ at: 100,
102
+ })
103
+ })
104
+ it('shows Prometheus rates without exporter access, and says why when there is neither', () => {
105
+ expect(cnpgTransactionRates({ commits: p(4, 1), rollbacks: p(0, 1) }, null, 'needs get pods/proxy')).toMatchObject({ source: 'prometheus', commits: '4.0' })
106
+ expect(cnpgTransactionRates({ commits: undefined, rollbacks: undefined }, null, 'needs get pods/proxy')).toMatchObject({ source: 'none', commits: 'needs get pods/proxy' })
107
+ })
108
+ })
109
+
110
+ describe('Sessions instance and aggregates', () => {
111
+ const inst = (pod: string, role: string, metrics: Record<string, unknown> = { state: 'ok', sessionsTotal: 3 }) => ({ pod, role, status: { state: 'ok' }, metrics }) as unknown as CNPGRuntimeInstance
112
+ it('keeps standbys pickable when no primary is reported', () => {
113
+ const standbys = [inst('pg-2', 'replica'), inst('pg-3', 'replica')]
114
+ expect(cnpgPickedInstance(standbys, null)?.pod).toBe('pg-2')
115
+ expect(cnpgPickedInstance([inst('pg-1', 'primary'), ...standbys], null)?.pod).toBe('pg-1')
116
+ expect(cnpgPickedInstance([inst('pg-1', 'primary'), ...standbys], 'pg-3')?.pod).toBe('pg-3')
117
+ })
118
+ it('counts the aggregates as present only when sessions were measured', () => {
119
+ expect(cnpgSessionAggregatesGap(inst('pg-1', 'primary'))).toBeUndefined()
120
+ expect(cnpgSessionAggregatesGap(inst('pg-1', 'primary', { state: 'ok' }))).toBe('the metrics exporter on pg-1 reported no session counts')
121
+ expect(cnpgSessionAggregatesGap(inst('pg-1', 'primary', { state: 'unreachable', error: 'x' }))).toBe('the metrics exporter on pg-1 did not answer (x)')
122
+ })
123
+ })
124
+
125
+ describe('cnpgSessionsCardShowsConnections', () => {
126
+ const inst = (metrics: Record<string, unknown>) => ({ pod: 'pg-1', role: 'primary', status: { state: 'ok' }, metrics }) as unknown as CNPGRuntimeInstance
127
+ const exec = { pod: 'pg-1', state: 'ok', maxConnections: 100, superuserReservedConnections: 3, clientBackends: 91 } as unknown as CNPGSessionsResponse
128
+ it('shows headroom once: in the card when it has a figure, otherwise in Blocking', () => {
129
+ expect(cnpgSessionsCardShowsConnections(inst({ state: 'ok' }), exec)).toBe(true)
130
+ expect(cnpgSessionsCardShowsConnections(inst({ state: 'ok', sessionsTotal: 5 }), undefined)).toBe(true)
131
+ expect(cnpgSessionsCardShowsConnections(inst({ state: 'ok' }), undefined)).toBe(false)
132
+ expect(cnpgSessionsCardShowsConnections(inst({ state: 'unreachable' }), exec)).toBe(false)
133
+ })
134
+ })
135
+
136
+ describe('cnpgIdAge', () => {
137
+ it('reads as a count with k / M / B and never shows a non-zero age as zero', () => {
138
+ expect(cnpgIdAge(0)).toBe('0')
139
+ expect(cnpgIdAge(812)).toBe('812')
140
+ expect(cnpgIdAge(1_234)).toBe('1.2k')
141
+ expect(cnpgIdAge(45_000)).toBe('45k')
142
+ expect(cnpgIdAge(3_400_000)).toBe('3.4 M')
143
+ expect(cnpgIdAge(1_100_000_000)).toBe('1.1 B')
144
+ expect(cnpgIdAge(12)).not.toBe('0 M')
145
+ })
146
+ })
147
+
148
+ describe('cnpgNoStandbyText', () => {
149
+ it('calls a one-instance cluster single, a larger one short of standbys, and claims neither unread', () => {
150
+ expect(cnpgNoStandbyText(1)).toBe('Single instance: no replica to fail over to.')
151
+ expect(cnpgNoStandbyText(2)).toBe('No standby is running: spec.instances is 2, so one standby is expected. Nothing to fail over to until one joins.')
152
+ expect(cnpgNoStandbyText(3)).toContain('2 standbys are expected')
153
+ expect(cnpgNoStandbyText(undefined)).toBe('No standby is running.')
154
+ })
155
+ })
156
+
157
+ it('keeps the measured connections in Sessions when only its row lists were capped', () => {
158
+ const inst = { pod: 'pg-1', metrics: { state: 'partial', reason: 'sessions capped', sessionsTotal: 150 } } as CNPGRuntimeInstance
159
+ expect(cnpgSessionAggregatesGap(inst)).toBeUndefined()
160
+ expect(cnpgSessionsCardShowsConnections(inst, undefined)).toBe(true)
161
+ })
162
+
163
+ it('does not treat a captured empty or failed read as a measurement', async () => {
164
+ const { cnpgRuntimeMeasured } = await import('./runtimeModel')
165
+ const instance = { pod: 'pg-1', role: 'primary', status: { state: 'ok', capturedAt: 'now' }, metrics: { state: 'ok', capturedAt: 'now', databases: [] } } as CNPGRuntimeInstance
166
+ expect(cnpgRuntimeMeasured([], 'metrics')).toBe(false)
167
+ expect(cnpgRuntimeMeasured([instance], 'status')).toBe(false)
168
+ expect(cnpgRuntimeMeasured([instance], 'metrics')).toBe(false)
169
+ instance.metrics.sessionsTotal = 0
170
+ expect(cnpgRuntimeMeasured([instance], 'metrics')).toBe(true)
171
+ instance.status.slots = []
172
+ expect(cnpgRuntimeMeasured([instance], 'status')).toBe(true)
173
+ instance.metrics.state = 'error'
174
+ expect(cnpgRuntimeMeasured([instance], 'metrics')).toBe(false)
175
+ })
@@ -0,0 +1,196 @@
1
+ import { cnpgLagTone, worseTone, type HealthLevel } from '@skyhook-io/k8s-ui'
2
+ import type { CNPGRuntimeInstance, CNPGRuntimeReplication } from '../../api/cnpg'
3
+ import type { CNPGSessionsResponse } from '../../api/cnpg-sessions'
4
+ import { cnpgConnectionFigure } from './blocking'
5
+
6
+ type Checkpoints = NonNullable<CNPGRuntimeInstance['metrics']['checkpoints']>
7
+
8
+ /**
9
+ * How to read one instance's checkpoint counters. Restartpoints exist as
10
+ * their own counters only in pg_stat_checkpointer (PostgreSQL 17+), and matter
11
+ * on a standby or where any were done. Pressure needs enough checkpoints for
12
+ * the requested share to mean something.
13
+ */
14
+ const CNPG_CHECKPOINT_SHARE_MIN = 10
15
+
16
+ export function cnpgCheckpointView(c: Checkpoints, role: CNPGRuntimeInstance['role']) {
17
+ const total = c.timed !== undefined && c.requested !== undefined ? c.timed + c.requested : undefined
18
+ const requestedShare = total ? (c.requested as number) / total : undefined
19
+ const few = total !== undefined && total < CNPG_CHECKPOINT_SHARE_MIN
20
+ return {
21
+ total,
22
+ requestedShare,
23
+ // Too few checkpoints for a share to mean anything; the counts beside it say it all.
24
+ share: requestedShare === undefined || few ? undefined : `${(requestedShare * 100).toFixed(0)} %`,
25
+ pressure: requestedShare !== undefined && !few && requestedShare > 0.5,
26
+ showRestartpoints: c.source === 'pg_stat_checkpointer' && (role !== 'primary' || !!c.restartpointsDone),
27
+ }
28
+ }
29
+
30
+ export interface CNPGDatabaseHealthRow {
31
+ database: string
32
+ /** rollbacks / (commits + rollbacks); undefined when either is unreported or there were none. */
33
+ rollbackRatio?: number
34
+ /** Commits and rollbacks were both reported and both zero, so there is no ratio to show. */
35
+ noTransactions?: boolean
36
+ tempFiles?: number
37
+ tempBytes?: number
38
+ xidAge?: number
39
+ mxidAge?: number
40
+ }
41
+
42
+ /** One row per database any family names; a value a family did not report stays undefined. */
43
+ export function cnpgDatabaseHealthRows(m: CNPGRuntimeInstance['metrics']): CNPGDatabaseHealthRow[] {
44
+ const rows = new Map<string, CNPGDatabaseHealthRow>()
45
+ const row = (database: string) => {
46
+ let r = rows.get(database)
47
+ if (!r) rows.set(database, (r = { database }))
48
+ return r
49
+ }
50
+ for (const d of m.databases ?? []) {
51
+ const r = row(d.database)
52
+ if (d.xactCommit !== undefined && d.xactRollback !== undefined) {
53
+ if (d.xactCommit + d.xactRollback > 0) r.rollbackRatio = d.xactRollback / (d.xactCommit + d.xactRollback)
54
+ else r.noTransactions = true
55
+ }
56
+ r.tempFiles = d.tempFiles
57
+ r.tempBytes = d.tempBytes
58
+ }
59
+ for (const x of m.xidAge ?? []) row(x.database).xidAge = x.age
60
+ for (const x of m.mxidAge ?? []) row(x.database).mxidAge = x.age
61
+ return [...rows.values()].sort((a, b) => a.database.localeCompare(b.database))
62
+ }
63
+
64
+ // 16 MiB is one WAL segment: a standby a segment or more behind is visibly
65
+ // catching up, not just between acknowledgements.
66
+ export const CNPG_BACKLOG_DEGRADED = 16 * 1024 * 1024
67
+ const CNPG_BACKLOG_UNHEALTHY = 1024 * 1024 * 1024
68
+
69
+ /** A standby's catch-up tone: the worse of its byte backlog and replay delay, the delay on the same scale as the cluster's Replication fact. */
70
+ export function cnpgStandbyBacklogTone(bytes: number | undefined, replayLag: number | undefined): HealthLevel {
71
+ if (bytes === undefined) return replayLag === undefined ? 'unknown' : worseTone('unknown', cnpgLagTone(replayLag))
72
+ const byBytes: HealthLevel = bytes >= CNPG_BACKLOG_UNHEALTHY ? 'unhealthy' : bytes >= CNPG_BACKLOG_DEGRADED ? 'degraded' : 'healthy'
73
+ return replayLag === undefined ? byBytes : worseTone(byBytes, cnpgLagTone(replayLag))
74
+ }
75
+
76
+ export interface CNPGStandbyHeadline {
77
+ text: string
78
+ tone: HealthLevel
79
+ /** The streaming state when the headline says something more pressing. */
80
+ secondary?: string
81
+ }
82
+
83
+ /**
84
+ * What a standby card leads with. A paused replay leads: the standby keeps
85
+ * streaming and receiving WAL, so "streaming" alone reads as healthy while
86
+ * nothing is being applied.
87
+ */
88
+ export function cnpgStandbyHeadline(
89
+ inst: CNPGRuntimeInstance,
90
+ rep: CNPGRuntimeReplication | undefined,
91
+ backlogTone: HealthLevel,
92
+ ctx: { fenced: boolean; primaryRead: boolean },
93
+ ): CNPGStandbyHeadline {
94
+ const streaming = rep ? [rep.state, rep.syncState].filter(Boolean).join(' · ') : undefined
95
+ if (inst.status.roleDetail === 'replayPaused' || inst.status.replayPaused) {
96
+ const secondary = streaming ?? (ctx.primaryRead && !ctx.fenced ? 'not connected to the primary' : undefined)
97
+ return { text: 'replay paused', tone: worseTone('degraded', backlogTone), secondary }
98
+ }
99
+ if (streaming !== undefined) return { text: streaming, tone: backlogTone }
100
+ // The fence asks the operator to stop PostgreSQL; only the instance manager
101
+ // reporting it down confirms the shutdown.
102
+ if (ctx.fenced) {
103
+ if (inst.status.state === 'ok') return { text: 'fenced · PostgreSQL still answering', tone: 'unknown' }
104
+ if (/PostgreSQL is not running/.test(inst.status.error ?? '')) return { text: 'fenced · PostgreSQL not running', tone: 'unknown' }
105
+ return { text: 'fenced · shutdown unverified', tone: 'unknown' }
106
+ }
107
+ if (inst.role === 'unknown') return { text: 'role unknown', tone: 'unknown' }
108
+ // A standby the primary reports no row for receives nothing: a concern, not an unknown.
109
+ return ctx.primaryRead ? { text: 'not connected to the primary', tone: 'degraded' } : { text: 'unknown', tone: 'unknown' }
110
+ }
111
+
112
+ type PromLatest = { value: number; at: number; stale: boolean } | undefined
113
+
114
+ export interface CNPGTransactionRates {
115
+ /** prometheus: cluster-wide, from history; sampled: the primary's, sampled by this page; none: neither is current. */
116
+ source: 'prometheus' | 'sampled' | 'none'
117
+ commits: string
118
+ rollbacks: string
119
+ /** Unix seconds of the Prometheus point shown, or of the last one when it is no longer recent. */
120
+ at?: number
121
+ }
122
+
123
+ /**
124
+ * Commit and rollback rates for the Transactions card. A current Prometheus
125
+ * rate wins; otherwise the page's own sampling when it can sample
126
+ * (`sampled` is null when it cannot: not the primary, or no exporter access);
127
+ * a Prometheus series that stopped is reported as such, never as current.
128
+ */
129
+ export function cnpgTransactionRates(
130
+ prom: { commits: PromLatest; rollbacks: PromLatest },
131
+ sampled: { commits?: number; rollbacks?: number } | null,
132
+ cannotSample: string,
133
+ ): CNPGTransactionRates {
134
+ const fresh = [prom.commits, prom.rollbacks].filter((p) => p && !p.stale) as NonNullable<PromLatest>[]
135
+ if (fresh.length) {
136
+ const text = (p: PromLatest) => (p && !p.stale ? p.value.toFixed(1) : '—')
137
+ return { source: 'prometheus', commits: text(prom.commits), rollbacks: text(prom.rollbacks), at: Math.min(...fresh.map((p) => p.at)) }
138
+ }
139
+ if (sampled) {
140
+ const text = (v?: number) => (v !== undefined ? v.toFixed(1) : 'collecting…')
141
+ return { source: 'sampled', commits: text(sampled.commits), rollbacks: text(sampled.rollbacks) }
142
+ }
143
+ const stale = [prom.commits, prom.rollbacks].filter(Boolean) as NonNullable<PromLatest>[]
144
+ if (stale.length) return { source: 'none', commits: 'no recent sample', rollbacks: 'no recent sample', at: Math.max(...stale.map((p) => p.at)) }
145
+ return { source: 'none', commits: cannotSample, rollbacks: cannotSample }
146
+ }
147
+
148
+ /** The instance Sessions and Transactions read: the one in the URL, else the primary, else the first, so standbys stay reachable without a primary. */
149
+ export function cnpgPickedInstance(instances: CNPGRuntimeInstance[], param: string | null): CNPGRuntimeInstance | undefined {
150
+ return instances.find((i) => i.pod === param) ?? instances.find((i) => i.role === 'primary') ?? instances[0]
151
+ }
152
+
153
+ /** Why the exporter's session counts are not shown, or undefined when they were measured. */
154
+ export function cnpgSessionAggregatesGap(inst: CNPGRuntimeInstance | undefined): string | undefined {
155
+ if (!inst) return 'no instance is reported'
156
+ const m = inst.metrics
157
+ if (m.state !== 'ok' && m.state !== 'partial') return `the metrics exporter on ${inst.pod} did not answer${m.error ? ` (${m.error})` : ''}`
158
+ if (m.sessionsTotal === undefined) return `the metrics exporter on ${inst.pod} reported no session counts`
159
+ return undefined
160
+ }
161
+
162
+ /** Whether the Sessions card shows the connections figure, so the Blocking panel does not repeat it. */
163
+ export function cnpgSessionsCardShowsConnections(inst: CNPGRuntimeInstance | undefined, exec: CNPGSessionsResponse | undefined): boolean {
164
+ return (inst?.metrics.state === 'ok' || inst?.metrics.state === 'partial') && cnpgConnectionFigure(exec, inst.metrics) !== undefined
165
+ }
166
+
167
+ /** A transaction or multixact ID age as a readable count: 812, 1.2k, 45k, 3.4 M, 1.1 B. A non-zero age never reads as zero. */
168
+ export function cnpgIdAge(v: number): string {
169
+ const unit = (n: number, d: number, s: string) => {
170
+ const x = n / d
171
+ return `${x < 10 ? x.toFixed(1).replace(/\.0$/, '') : Math.round(x)}${s}`
172
+ }
173
+ if (v < 1_000) return String(v)
174
+ if (v < 1_000_000) return unit(v, 1_000, 'k')
175
+ if (v < 1_000_000_000) return unit(v, 1_000_000, ' M')
176
+ return unit(v, 1_000_000_000, ' B')
177
+ }
178
+
179
+ /** Why no standby is listed: a one-instance cluster has none, a larger one is missing them. */
180
+ export function cnpgNoStandbyText(desired: unknown): string {
181
+ if (typeof desired !== 'number') return 'No standby is running.'
182
+ if (desired <= 1) return 'Single instance: no replica to fail over to.'
183
+ const expected = desired - 1
184
+ return `No standby is running: spec.instances is ${desired}, so ${expected === 1 ? 'one standby is' : `${expected} standbys are`} expected. Nothing to fail over to until one joins.`
185
+ }
186
+
187
+ export function cnpgRuntimeMeasured(instances: CNPGRuntimeInstance[], source: 'status' | 'metrics'): boolean {
188
+ return instances.some((instance) => {
189
+ const report = instance[source]
190
+ if (report.state !== 'ok' && report.state !== 'partial') return false
191
+ if (source === 'status') return instance.status.currentLsn !== undefined || instance.status.replayLsn !== undefined || instance.status.timeline !== undefined || instance.status.slots != null
192
+ return Object.values(instance.metrics).some((value) => typeof value === 'number')
193
+ || !!instance.metrics.databases?.length || !!instance.metrics.sessions?.length
194
+ || !!instance.metrics.databaseSizes?.length || instance.metrics.extensionUpdates != null || instance.metrics.checkpoints !== undefined
195
+ })
196
+ }
@@ -0,0 +1,17 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { expect, it } from 'vitest'
3
+ import { Denied, ProxyDenied } from './runtimeParts'
4
+
5
+ it('uses the same full-width padding and grant layout for proxy and exec denials', () => {
6
+ const proxy = renderToStaticMarkup(<ProxyDenied what="Sessions" grant="get pods/proxy in namespace db" />)
7
+ const exec = renderToStaticMarkup(<Denied title="No access to blocking detail" grant="create pods/exec in namespace db">Blocking is unavailable.</Denied>)
8
+ const shell = (html: string) => html.match(/^<div class="([^"]+)"/)?.[1]
9
+ expect(shell(proxy)).toBe(shell(exec))
10
+ expect(shell(proxy)).toContain('w-full')
11
+ expect(shell(proxy)).toContain('p-4')
12
+ for (const html of [proxy, exec]) {
13
+ expect(html).not.toContain('max-w-2xl')
14
+ expect(html).toContain('requires:')
15
+ expect(html).toContain('break-words')
16
+ }
17
+ })
@@ -0,0 +1,79 @@
1
+ import type { ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { Lock } from 'lucide-react'
4
+ import { toneTextClass } from '@skyhook-io/k8s-ui'
5
+ import { useLocation } from 'react-router-dom'
6
+ import { useCNPGNavigate } from './useCNPGNavigate'
7
+ import { cnpgClusterProblemsPath, cnpgWithinDetail } from './paths'
8
+ import type { CNPGRuntimeInstance } from '../../api/cnpg'
9
+
10
+ export function seconds(s?: number): string {
11
+ if (s === undefined) return '—'
12
+ if (s < 1) return `${(s * 1000).toFixed(0)} ms`
13
+ if (s < 90) return `${s.toFixed(1)} s`
14
+ if (s < 5400) return `${Math.round(s / 60)} min`
15
+ return `${(s / 3600).toFixed(1)} h`
16
+ }
17
+
18
+ export function SourceState({ label, state, error, reason }: { label: string; state: string; error?: string; reason?: string }) {
19
+ if (state === 'ok') return null
20
+ const detail = [reason, error].filter(Boolean).join(' · ')
21
+ const text =
22
+ state === 'denied'
23
+ ? `${label}: no access (needs get pods/proxy)`
24
+ : state === 'partial'
25
+ ? `${label}: partial${detail ? ` · ${detail}` : ''}`
26
+ : `${label}: ${state}${detail ? ` · ${detail}` : ''}`
27
+ return <div className="text-xs text-theme-text-tertiary">{text}</div>
28
+ }
29
+
30
+ // Denied is not zero: the section says what it would show and the grant it needs.
31
+ export function ProxyDenied({ what, grant }: { what: string; grant: string }) {
32
+ return <Denied title="No access to live instance data" grant={grant}>
33
+ {what} are read from each instance through the Kubernetes API proxy, which your identity may not use. These readings are unavailable.
34
+ </Denied>
35
+ }
36
+
37
+ export function Denied({ title, grant, children }: { title: string; grant: string; children: ReactNode }) {
38
+ return <div className="w-full rounded-xl border border-dashed border-theme-border p-4">
39
+ <div className="flex items-center gap-2 text-sm font-medium text-theme-text-primary"><Lock className="h-4 w-4 shrink-0" />{title}</div>
40
+ <p className="mt-2 text-sm text-theme-text-secondary">{children}</p>
41
+ <div className="mt-2 break-words rounded-md bg-theme-elevated px-3 py-2 font-mono text-xs text-theme-text-primary">requires: {grant}</div>
42
+ </div>
43
+ }
44
+
45
+ export function PrimaryPrerequisite({ namespace, cluster }: { namespace: string; cluster: string }) {
46
+ const navigate = useCNPGNavigate()
47
+ const location = useLocation()
48
+ const overviewPath = `${cnpgClusterProblemsPath(namespace, cluster, new URLSearchParams(location.search).get('ctx') ?? undefined)}&tab=overview`
49
+ const inPlace = cnpgWithinDetail(location.pathname, location.search, overviewPath)
50
+ return <div className="text-sm text-theme-text-tertiary">Available once the primary is running. <button type="button" className="text-accent-text hover:underline" onClick={() => navigate(inPlace ?? overviewPath, { replace: !!inPlace, state: location.state })}>See Overview →</button></div>
51
+ }
52
+
53
+ export function Card({ title, children, footer, aside }: { title: ReactNode; children: ReactNode; footer?: ReactNode; aside?: ReactNode }) {
54
+ return (
55
+ <section className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
56
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-theme-border px-4 py-2.5">
57
+ <div className="text-sm font-semibold text-theme-text-primary">{title}</div>
58
+ {aside && <div className="ml-auto text-xs">{aside}</div>}
59
+ </div>
60
+ <div className="p-4">{children}</div>
61
+ {footer && <div className="border-t border-theme-border px-4 py-2 text-xs text-theme-text-tertiary">{footer}</div>}
62
+ </section>
63
+ )
64
+ }
65
+
66
+ export function Unavailable({ inst, what }: { inst?: CNPGRuntimeInstance; what: string }) {
67
+ if (!inst) return <div className="text-sm text-theme-text-tertiary">No instance is reported, so {what} is unknown.</div>
68
+ return <SourceState label={what} state={inst.metrics.state} error={inst.metrics.error} reason={inst.metrics.reason} />
69
+ }
70
+
71
+ export function Metric({ label, value, tone, caption }: { label: string; value: ReactNode; tone?: 'degraded' | 'unhealthy'; caption?: ReactNode }) {
72
+ return (
73
+ <div>
74
+ <div className="text-xs text-theme-text-tertiary">{label}</div>
75
+ <div className={clsx('font-mono text-base', tone ? toneTextClass(tone) : 'text-theme-text-primary')}>{value}</div>
76
+ {caption && <div className="text-[11px] text-theme-text-tertiary">{caption}</div>}
77
+ </div>
78
+ )
79
+ }
@@ -0,0 +1,59 @@
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 { CNPGScreenGate, CoverageNotice } from './shared'
7
+ import { buildCNPGFleet, CNPG_WORKSPACE_KEYS, type CNPGWorkspaceResponse } from '@skyhook-io/k8s-ui'
8
+ vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'test' } }) }))
9
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
10
+ it('retries an initial failure, disables duplicate retries and shows recovered data', async () => {
11
+ const refetch = vi.fn(async () => ({}))
12
+ const query = { isLoading: false, isFetching: false, error: new Error('Connection timed out'), refetch } as any
13
+ const host = document.createElement('div')
14
+ document.body.appendChild(host)
15
+ const root = createRoot(host)
16
+ const render = () => root.render(<CNPGScreenGate query={query} fleet={{} as any}>{() => <span>recovered workspace</span>}</CNPGScreenGate>)
17
+ await act(async () => render())
18
+ expect(host.textContent).toContain('Connection timed out')
19
+ await act(async () => host.querySelector('button')!.click())
20
+ expect(refetch).toHaveBeenCalledOnce()
21
+ query.isFetching = true
22
+ await act(async () => render())
23
+ expect(host.querySelector('button')!.disabled).toBe(true)
24
+ expect(host.textContent).toContain('Retrying…')
25
+ query.data = { installed: true }
26
+ query.isFetching = false
27
+ await act(async () => render())
28
+ expect(host.textContent).toContain('recovered workspace')
29
+ expect(host.textContent).not.toContain('CloudNativePG data unavailable')
30
+ act(() => root.unmount())
31
+ host.remove()
32
+ })
33
+ it('keeps workspace data with its failed-refresh reason and age', () => {
34
+ const query = { data: { installed: true }, isRefetchError: true, error: new Error('Workspace timeout'), dataUpdatedAt: Date.now() - 120_000 } as any
35
+ const html = renderToStaticMarkup(<CNPGScreenGate query={query} fleet={{} as any}>{() => <span>retained Cluster</span>}</CNPGScreenGate>)
36
+ expect(html).toContain('retained Cluster')
37
+ expect(html).toContain('Last refresh failed: Workspace timeout')
38
+ expect(html).toContain('2m ago')
39
+ })
40
+
41
+ it('scopes unread kinds to the screen and its namespace, with details folded', () => {
42
+ const data: CNPGWorkspaceResponse = { installed: true, context: 'test', namespaces: null, objects: {}, coverage: { ...Object.fromEntries(CNPG_WORKSPACE_KEYS.map((k) => [k, { state: 'full' as const }])), backups: { state: 'partial', allowedNamespaces: ['prod'], deniedNamespaces: ['staging'] }, clusterImageCatalogs: { state: 'uncached' } }, issues: [], audit: [], backupsOmitted: 0, jobCoverage: { state: 'denied' } }
43
+ const fleet = buildCNPGFleet(data)
44
+ const html = renderToStaticMarkup(<CoverageNotice data={data} fleet={fleet} kinds={['backups', 'scheduledBackups', 'objectStores']} />)
45
+ expect(html).toContain('Some data used on this screen was not read.')
46
+ expect(html).toContain('Backup (no access in staging)')
47
+ expect(html).toContain('aria-expanded="false"')
48
+ expect(html).not.toContain('ClusterImageCatalog')
49
+ expect(html).not.toContain('Job (')
50
+ expect(renderToStaticMarkup(<CoverageNotice data={data} fleet={fleet} kinds={['backups']} namespace="prod" />)).toBe('')
51
+ })
52
+
53
+ it('identifies coverage the server did not report without claiming the kind is absent', () => {
54
+ const data: CNPGWorkspaceResponse = { installed: true, context: 'test', namespaces: null, objects: {}, coverage: Object.fromEntries(CNPG_WORKSPACE_KEYS.filter(k => k !== 'databaseRoles').map(k => [k, { state: 'full' as const }])), issues: [], audit: [], backupsOmitted: 0 }
55
+ const fleet = buildCNPGFleet(data)
56
+ const html = renderToStaticMarkup(<CoverageNotice data={data} fleet={fleet} kinds={['databaseRoles']} />)
57
+ expect(html).toContain('DatabaseRole (not read by this Radar)')
58
+ expect(html).toContain('Counts over incomplete data are lower bounds')
59
+ })