@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,48 @@
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 { MemoryRouter } from 'react-router-dom'
6
+ import { expect, it, vi } from 'vitest'
7
+ import { CNPGPerformance } from './CNPGPerformance'
8
+ const state = vi.hoisted(() => ({ denied: false, execDenied: false, navigate: vi.fn() }))
9
+ vi.mock('./useCNPGNavigate', () => ({ useCNPGNavigate: () => state.navigate }))
10
+ vi.mock('../../api/cnpg', () => ({ useCNPGRuntime: () => ({ data: { permission: { proxy: state.denied ? 'denied' : 'allowed', grant: { verb: 'get', resource: 'pods', subresource: 'proxy', namespace: 'db' } }, instances: [] } }), useCNPGClusterCapabilities: () => ({}) }))
11
+ vi.mock('../../api/cnpg-sessions', () => ({ useCNPGSessions: () => ({ data: { pod: '', state: state.denied || state.execDenied ? 'denied' : 'unavailable', permission: { exec: state.denied || state.execDenied ? 'denied' : 'unknown', grant: { verb: 'create', resource: 'pods', subresource: 'exec', namespace: 'db' } }, instances: [] } }) }))
12
+ vi.mock('../../api/cnpg-history', () => ({ useCNPGClusterHistory: () => ({}) }))
13
+ vi.mock('./CNPGTrends', () => ({ CNPGTrends: () => null, useSampleBuffer: () => [] }))
14
+ const view = () => <MemoryRouter initialEntries={[{ pathname: '/cnpg/clusters/db/pg', search: '?ctx=test&tab=performance&drawer=x', state: { returnLabel: 'Clusters' } }]}><CNPGPerformance namespace="db" name="pg" /></MemoryRouter>
15
+ it('shares one primary prerequisite and preserves the Overview navigation context', () => {
16
+ const html = renderToStaticMarkup(view())
17
+ expect(html.match(/Available once the primary is running/g)).toHaveLength(1)
18
+ expect(html.match(/See Overview/g)).toHaveLength(1)
19
+ expect(html).toContain('Session aggregates not measured')
20
+ expect(html).toContain('Blocking detail not measured')
21
+ expect(html).not.toContain('Query text')
22
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
23
+ const host = document.createElement('div'); const root = createRoot(host)
24
+ act(() => root.render(view()))
25
+ act(() => [...host.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('See Overview'))!.click())
26
+ expect(state.navigate).toHaveBeenCalledWith('/cnpg/clusters/db/pg?ctx=test&tab=overview&drawer=x&problems=all', { replace: true, state: { returnLabel: 'Clusters' } })
27
+ act(() => root.unmount())
28
+ })
29
+ it('explains proxy access once and blocking exec access once', () => {
30
+ state.denied = true
31
+ const html = renderToStaticMarkup(view())
32
+ expect(html.match(/get pods\/proxy/g)).toHaveLength(1)
33
+ expect(html.match(/create pods\/exec/g)).toHaveLength(1)
34
+ expect(html).not.toContain('aggregate session counts are unavailable too')
35
+ expect(html).not.toContain('Available once the primary is running')
36
+ state.denied = false
37
+ })
38
+
39
+ it('keeps the startup prerequisite and only Blocking exec denial when proxy is allowed', () => {
40
+ state.execDenied = true
41
+ const html = renderToStaticMarkup(view())
42
+ expect(html.match(/Available once the primary is running/g)).toHaveLength(1)
43
+ expect(html.match(/create pods\/exec/g)).toHaveLength(1)
44
+ expect(html).toContain('Session aggregates not measured')
45
+ expect(html).not.toContain('Session detail needs exec')
46
+ expect(html).not.toContain('get pods/proxy')
47
+ state.execDenied = false
48
+ })
@@ -0,0 +1,73 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { MemoryRouter } from 'react-router-dom'
3
+ import { expect, it, vi } from 'vitest'
4
+ import { CNPGPerformance } from './CNPGPerformance'
5
+ import type { CNPGRuntimeInstance } from '../../api/cnpg'
6
+
7
+ const live = vi.hoisted(() => ({ denied: false, empty: false, count: 1, sessionRead: undefined as any, metrics: { state: 'partial', reason: '150 session groups; the largest 100 are listed and sessionsTotal counts all', sessionsTotal: 150, sessions: [{ state: 'active', database: 'app', user: 'app', application: 'client', count: 3 }], sessionsByState: { 'idle in transaction': 12 }, waitingBackends: 2, databases: [{ database: 'app', xactCommit: 90, xactRollback: 10 }], checkpoints: { source: 'pg_stat_checkpointer', timed: 7, requested: 2 }, extensionUpdates: [] } as CNPGRuntimeInstance['metrics'] }))
8
+ vi.mock('../../api/cnpg', () => ({ useCNPGRuntime: () => ({ data: { sampledAt: '2026-10-05T12:00:00Z', permission: { proxy: live.denied ? 'denied' : 'allowed' }, instances: live.empty ? [] : Array.from({ length: live.count }, (_, i) => ({ pod: `pg-${i + 1}`, role: i === 0 ? 'primary' : 'replica', metrics: live.metrics })) } }) }))
9
+ vi.mock('../../api/cnpg-sessions', () => ({ useCNPGSessions: () => ({ data: live.sessionRead }) }))
10
+ vi.mock('../../api/cnpg-history', () => ({ useCNPGClusterHistory: () => ({}) }))
11
+ vi.mock('./CNPGBlockingSessions', () => ({ CNPGBlockingSessions: () => live.sessionRead?.state === 'denied' ? <span>create pods/exec</span> : null }))
12
+ vi.mock('./CNPGTrends', () => ({ CNPGTrends: () => null, useSampleBuffer: () => [] }))
13
+ function render(section: string) {
14
+ return renderToStaticMarkup(<MemoryRouter initialEntries={[`/?section=${section}`]}><CNPGPerformance namespace="pg" name="pg" /></MemoryRouter>)
15
+ }
16
+ it('keeps partial session measurements and the producer reason visible', () => {
17
+ const html = render('sessions')
18
+ expect(html).toContain('150 in use')
19
+ expect(html).toContain('client')
20
+ expect(html).toContain('12')
21
+ expect(html).toContain(live.metrics.reason)
22
+ expect(html).toContain('Sessions: partial')
23
+ })
24
+ it('keeps partial database and checkpoint measurements and qualifies each card', () => {
25
+ const html = render('health')
26
+ expect(html).toContain('10.0 %')
27
+ expect(html).toContain('Timed')
28
+ expect(html).toContain('>7<')
29
+ expect(html).toContain('Database health: partial')
30
+ expect(html).toContain('Checkpoints: partial')
31
+ expect(html).toContain(live.metrics.reason)
32
+ expect(html).not.toContain('Every installed extension')
33
+ })
34
+
35
+ it('keeps the Sessions card title and full-width shell when proxy access is denied', () => {
36
+ live.denied = true
37
+ const html = render('sessions')
38
+ expect(html).toMatch(/<section[^>]*>.*Sessions on pg-1.*No access to live instance data.*<\/section>/)
39
+ expect(html).toContain('needs access you do not have')
40
+ live.denied = false
41
+ })
42
+ it('says checked when no instance answered and sampled when a measurement was captured', () => {
43
+ live.empty = true
44
+ const html = render('sessions')
45
+ expect(html).toContain('No instance data yet; checked')
46
+ expect(html).not.toContain('sampled')
47
+ live.empty = false
48
+ live.metrics.capturedAt = '2026-10-05T12:00:00Z'
49
+ expect(render('sessions')).toContain('Live instance data · sampled')
50
+ })
51
+
52
+ it('hides a single-instance picker, names that instance in the card, and retains multi-instance selection', () => {
53
+ live.count = 1
54
+ const single = render('sessions')
55
+ expect(single).not.toContain('<select')
56
+ expect(single).toContain('Sessions on pg-1')
57
+ live.count = 2
58
+ expect(render('sessions')).toContain('<select')
59
+ live.count = 1
60
+ })
61
+ it('uses one primary prerequisite for empty Sessions while Blocking owns exec denial', () => {
62
+ live.empty = true
63
+ const html = render('sessions')
64
+ expect(html).toContain('Available once the primary is running')
65
+ expect(html).toContain('See Overview')
66
+ expect(html).not.toContain('No instance is reported')
67
+ live.sessionRead = { state: 'denied', permission: { exec: 'denied', grant: { verb: 'create', resource: 'pods', subresource: 'exec', namespace: 'pg' } } }
68
+ const denied = render('sessions')
69
+ expect(denied).toContain('create pods/exec')
70
+ expect(denied).toContain('Available once the primary is running')
71
+ expect(denied.match(/create pods\/exec/g)).toHaveLength(1)
72
+ live.empty = false; live.sessionRead = undefined
73
+ })
@@ -0,0 +1,477 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useLocation, useSearchParams } from 'react-router-dom'
3
+ import { clsx } from 'clsx'
4
+ import { PaneLoader, Tooltip, formatAge, toneTextClass, formatGrant } from '@skyhook-io/k8s-ui'
5
+ import { useCNPGRuntime, type CNPGRuntimeInstance } from '../../api/cnpg'
6
+ import { useCNPGSessions, type CNPGSessionsResponse } from '../../api/cnpg-sessions'
7
+ import { useCNPGClusterHistory } from '../../api/cnpg-history'
8
+ import { CNPGBlockingSessions } from './CNPGBlockingSessions'
9
+ import { cnpgConnectionFigure } from './blocking'
10
+ import { cnpgRuntimeMeasured, cnpgCheckpointView, cnpgDatabaseHealthRows, cnpgIdAge, cnpgPickedInstance, cnpgSessionsCardShowsConnections, cnpgTransactionRates, type CNPGTransactionRates } from './runtimeModel'
11
+ import { formatBytes } from './lsn'
12
+ import { historyLatest, latestRate } from './trendSamples'
13
+ import { CNPGTrends, useSampleBuffer, type CNPGChartGroup, type CNPGIntervalTarget, type Sample } from './CNPGTrends'
14
+ import { Notice, RefreshFailedNotice, Segments } from '../workspace/layout'
15
+ import { Card, Metric, ProxyDenied, PrimaryPrerequisite, Unavailable, SourceState, seconds } from './runtimeParts'
16
+
17
+ type Section = 'sessions' | 'health' | 'history'
18
+
19
+ const SECTIONS: { id: Section; label: string }[] = [
20
+ { id: 'sessions', label: 'Sessions' },
21
+ { id: 'health', label: 'Database health' },
22
+ { id: 'history', label: 'History' },
23
+ ]
24
+
25
+ /**
26
+ * Performance: who is connected and what is blocked (Sessions), how the
27
+ * databases are doing (Database health), and how it changed (History). The
28
+ * instance picker drives Sessions and Database health; History covers the
29
+ * cluster.
30
+ */
31
+ export function CNPGPerformance({
32
+ namespace,
33
+ name,
34
+ onOpenInterval,
35
+ }: {
36
+ namespace: string
37
+ name: string
38
+ /** Opens Logs or Activity bounded to an interval selected on a trend chart. */
39
+ onOpenInterval?: (target: CNPGIntervalTarget, since: string, until: string) => void
40
+ }) {
41
+ const q = useCNPGRuntime(namespace, name)
42
+ // In the URL so Back from Logs or Activity returns to the same section.
43
+ const [searchParams, setSearchParams] = useSearchParams()
44
+ const location = useLocation()
45
+ const section = SECTIONS.find((x) => x.id === searchParams.get('section'))?.id ?? 'sessions'
46
+ const setSection = (next: Section) =>
47
+ setSearchParams(
48
+ (prev) => {
49
+ const params = new URLSearchParams(prev)
50
+ if (next === 'sessions') params.delete('section')
51
+ else params.set('section', next)
52
+ return params
53
+ },
54
+ { replace: true, state: location.state },
55
+ )
56
+ const samples = useSampleBuffer(q.data)
57
+
58
+ if (!q.data && q.isLoading) return <PaneLoader label="Reading live state…" className="h-40" />
59
+ if (!q.data) {
60
+ return (
61
+ <div className="p-4">
62
+ <Notice>Live instance data could not be loaded: {q.error instanceof Error ? q.error.message : 'unknown error'}</Notice>
63
+ </div>
64
+ )
65
+ }
66
+ const data = q.data
67
+ const denied = data.permission.proxy === 'denied'
68
+ const grant = formatGrant(data.permission.grant) ?? `get pods/proxy in namespace ${namespace}`
69
+ const primary = data.instances.find((i) => i.role === 'primary')
70
+ // Sessions and Database health read one instance's exporter; the primary
71
+ // unless another is picked (kept in the URL like the section).
72
+ // With no primary reported, the first instance, so standbys stay reachable.
73
+ const picked = cnpgPickedInstance(data.instances, searchParams.get('instance'))
74
+ const setPicked = (pod: string) =>
75
+ setSearchParams(
76
+ (prev) => {
77
+ const params = new URLSearchParams(prev)
78
+ if (primary && pod === primary.pod) params.delete('instance')
79
+ else params.set('instance', pod)
80
+ return params
81
+ },
82
+ { replace: true, state: location.state },
83
+ )
84
+ const picker =
85
+ data.instances.length > 1 && picked ? (
86
+ <label className="flex items-center gap-2 text-xs text-theme-text-secondary">
87
+ Instance
88
+ <select
89
+ value={picked.pod}
90
+ onChange={(e) => setPicked(e.target.value)}
91
+ className="rounded border border-theme-border bg-theme-base px-1.5 py-0.5 font-mono text-xs text-theme-text-primary"
92
+ >
93
+ {data.instances.map((i) => (
94
+ <option key={i.pod} value={i.pod}>
95
+ {i.pod}
96
+ {i.role === 'primary' ? ' (primary)' : i.role === 'replica' ? ' (standby)' : ''}
97
+ </option>
98
+ ))}
99
+ </select>
100
+ </label>
101
+ ) : null
102
+ const charts = (searchParams.get('charts') as CNPGChartGroup | null) ?? undefined
103
+
104
+ return (
105
+ <div className="space-y-4 p-4">
106
+ <div className="flex flex-wrap items-center gap-3">
107
+ <Segments label="Performance section" value={section} onChange={setSection} options={SECTIONS} />
108
+ {section !== 'history' && (
109
+ <span className="text-xs text-theme-text-tertiary">
110
+ {denied ? 'Live instance data needs access you do not have' : cnpgRuntimeMeasured(data.instances, 'metrics') ? `Live instance data · sampled ${formatAge(data.sampledAt)} ago` : `No instance data yet; checked ${formatAge(data.sampledAt)} ago`}
111
+ </span>
112
+ )}
113
+ </div>
114
+ <RefreshFailedNotice queries={[q]} />
115
+
116
+ {section === 'sessions' &&
117
+ (denied ? (
118
+ <>
119
+ {picker}
120
+ <Card title={picked ? `Sessions on ${picked.pod}` : 'Sessions'}><ProxyDenied what="Session counts by state, lock waits and connection headroom" grant={grant} /></Card>
121
+ <CNPGBlockingSessions namespace={namespace} cluster={name} pod={picked?.pod} />
122
+ </>
123
+ ) : (
124
+ <SessionsView namespace={namespace} cluster={name} instance={picked} picker={picker} />
125
+ ))}
126
+ {section === 'health' && (
127
+ <TransactionsView namespace={namespace} cluster={name} primary={primary} instance={picked} picker={picker} samples={samples} deniedGrant={denied ? grant : undefined} />
128
+ )}
129
+ {section === 'history' && (
130
+ <CNPGTrends
131
+ namespace={namespace}
132
+ name={name}
133
+ samples={samples}
134
+ onOpenInterval={onOpenInterval}
135
+ instance={picked?.pod}
136
+ picker={picker}
137
+ samplingDenied={denied ? grant : undefined}
138
+ group={charts}
139
+ standbys={data.instances.filter((i) => i.role === 'replica').map((i) => i.pod)}
140
+ instancePods={data.instances.map((i) => i.pod)}
141
+ />
142
+ )}
143
+ </div>
144
+ )
145
+ }
146
+
147
+ function SessionsView({ namespace, cluster, instance, picker }: { namespace: string; cluster: string; instance?: CNPGRuntimeInstance; picker?: ReactNode }) {
148
+ // The pod is always named: left to the server, "the primary" is its own
149
+ // reading of status.currentPrimary, which can differ from the one picked.
150
+ const blocking = useCNPGSessions(namespace, cluster, instance?.pod)
151
+ const exec = blocking.data?.state === 'ok' && blocking.data.pod === instance?.pod ? blocking.data : undefined
152
+ if (!instance) return (
153
+ <div className="space-y-4">
154
+ <PrimaryPrerequisite namespace={namespace} cluster={cluster} />
155
+ <Card title="Sessions">Session aggregates not measured.</Card>
156
+ <CNPGBlockingSessions namespace={namespace} cluster={cluster} />
157
+ </div>
158
+ )
159
+ return (
160
+ <div className="space-y-4">
161
+ {picker}
162
+ <SessionAggregates primary={instance} exec={exec} />
163
+ <CNPGBlockingSessions namespace={namespace} cluster={cluster} pod={instance?.pod} headroom={!cnpgSessionsCardShowsConnections(instance, exec)} />
164
+ </div>
165
+ )
166
+ }
167
+
168
+ // `primary` is the instance shown: the primary unless another was picked.
169
+ function SessionAggregates({ primary, exec }: { primary?: CNPGRuntimeInstance; exec?: CNPGSessionsResponse }) {
170
+ const m = primary?.metrics
171
+ if (!primary) return <Card title="Sessions">Session aggregates not measured.</Card>
172
+ if (!m || (m.state !== 'ok' && m.state !== 'partial')) return <Card title={`Sessions on ${primary.pod}`}><Unavailable inst={primary} what="Sessions" /></Card>
173
+ const rows = [...(m.sessions ?? [])].sort((a, b) => b.count - a.count)
174
+ const measured = m.sessionsTotal !== undefined
175
+ const idleTx = measured
176
+ ? Object.entries(m.sessionsByState ?? {}).filter(([state]) => state.startsWith('idle in transaction')).reduce((s, [, count]) => s + count, 0)
177
+ : undefined
178
+ const connections = cnpgConnectionFigure(exec, m)
179
+ return (
180
+ <Card
181
+ title={<>Sessions on {primary!.pod}</>}
182
+ footer="Counts by state, database, user and application from the metrics exporter (platform users excluded). Individual blocking sessions, with their query text, are below when you can exec into the instance."
183
+ >
184
+ <SourceState label="Sessions" state={m.state} reason={m.reason} error={m.error} />
185
+ <div className="mb-3 flex flex-wrap gap-6 text-sm">
186
+ <Metric
187
+ label="Connections"
188
+ value={connections ? <Tooltip content={connections.detail}>{connections.value}</Tooltip> : '—'}
189
+ caption={connections?.limit}
190
+ tone={connections?.tone}
191
+ />
192
+ <Metric label="Waiting on locks" value={m.waitingBackends ?? '—'} tone={m.waitingBackends ? 'degraded' : undefined} />
193
+ <Metric label="Idle in transaction" value={idleTx ?? '—'} tone={idleTx ? 'degraded' : undefined} />
194
+ <Metric label="Oldest transaction" value={seconds(m.oldestXactSeconds)} tone={m.oldestXactSeconds !== undefined && m.oldestXactSeconds > 300 ? 'degraded' : undefined} />
195
+ </div>
196
+ {rows.length === 0 ? (
197
+ <div className="text-sm text-theme-text-tertiary">
198
+ {measured
199
+ ? m.state === 'partial'
200
+ ? 'No client sessions seen in what was read.'
201
+ : 'No client sessions.'
202
+ : 'Sessions unknown: this sample from the exporter has no cnpg_backends_total, so sessions were not measured. PostgreSQL may not be accepting connections, or the exporter does not collect it.'}
203
+ </div>
204
+ ) : (
205
+ <table className="w-full text-sm">
206
+ <thead className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
207
+ <tr><th className="py-1.5 pr-3">State</th><th className="pr-3">Database</th><th className="pr-3">User</th><th className="pr-3">Application</th><th className="text-right">Sessions</th></tr>
208
+ </thead>
209
+ <tbody className="table-divide-subtle">
210
+ {rows.map((r, i) => (
211
+ <tr key={i}>
212
+ <td className={clsx('py-1.5 pr-3', r.state.startsWith('idle in transaction') && toneTextClass('degraded'))}>{r.state}</td>
213
+ <td className="pr-3 font-mono text-xs">{r.database}</td>
214
+ <td className="pr-3 font-mono text-xs">{r.user}</td>
215
+ <td className="pr-3 text-xs text-theme-text-secondary">{r.application || '—'}</td>
216
+ <td className="text-right font-mono">{r.count}</td>
217
+ </tr>
218
+ ))}
219
+ </tbody>
220
+ </table>
221
+ )}
222
+ </Card>
223
+ )
224
+ }
225
+
226
+ function TransactionsView({
227
+ namespace,
228
+ cluster,
229
+ primary,
230
+ instance,
231
+ picker,
232
+ samples,
233
+ deniedGrant,
234
+ }: {
235
+ namespace: string
236
+ cluster: string
237
+ primary?: CNPGRuntimeInstance
238
+ instance?: CNPGRuntimeInstance
239
+ picker?: ReactNode
240
+ samples: Sample[]
241
+ /** pods/proxy is denied: no exporter figures and no sampling, but Prometheus rates may still be readable. */
242
+ deniedGrant?: string
243
+ }) {
244
+ const history = useCNPGClusterHistory(namespace, cluster, '15m')
245
+ // The page samples the primary's counters whichever instance is picked, so
246
+ // sampled rates are the primary's and do not follow the picker.
247
+ const canSample = !deniedGrant && (primary?.metrics.state === 'ok' || primary?.metrics.state === 'partial')
248
+ const rates = cnpgTransactionRates(
249
+ { commits: historyLatest(history.data, 'tps', 'commits'), rollbacks: historyLatest(history.data, 'tps', 'rollbacks') },
250
+ canSample ? { commits: latestRate(samples, 'commits'), rollbacks: latestRate(samples, 'rollbacks') } : null,
251
+ deniedGrant ? `needs ${deniedGrant}` : '—',
252
+ )
253
+ const throughputTitle = rates.source === 'prometheus' ? 'Cluster throughput' : 'Throughput on the primary'
254
+ return (
255
+ <div className="space-y-4">
256
+ <RefreshFailedNotice queries={[history]} />
257
+ {deniedGrant ? (
258
+ <>
259
+ {rates.source !== 'none' && (
260
+ <Card title="Cluster throughput" footer={RATES_FOOTER.prometheus}>
261
+ <Rates rates={rates} />
262
+ </Card>
263
+ )}
264
+ <ProxyDenied what="Per-instance transaction figures, cache hit ratio, deadlocks, transaction and multixact ID age, and extension versions" grant={deniedGrant} />
265
+ </>
266
+ ) : (
267
+ <>
268
+ <Card title={throughputTitle} footer={RATES_FOOTER[rates.source]}>
269
+ <Rates rates={rates} />
270
+ </Card>
271
+ {picker}
272
+ <TransactionsCard inst={instance} />
273
+ <CheckpointsCard inst={instance} />
274
+ </>
275
+ )}
276
+ </div>
277
+ )
278
+ }
279
+
280
+ const RATES_FOOTER = {
281
+ prometheus: 'Prometheus’s latest commit and rollback rates across every instance and database of the cluster.',
282
+ sampled: "Rates are the change between the primary exporter's last two query runs seen while this page is open, never across a change of primary.",
283
+ none: 'No current commit or rollback rate: Prometheus has no recent point, and this page samples only the primary through its exporter.',
284
+ }
285
+
286
+ function Rates({ rates }: { rates: CNPGTransactionRates }) {
287
+ return (
288
+ <div>
289
+ <div className="flex gap-8">
290
+ <Metric label="Commits / s" value={rates.commits} />
291
+ <Metric label="Rollbacks / s" value={rates.rollbacks} />
292
+ </div>
293
+ {rates.at !== undefined && (
294
+ <div className="mt-0.5 text-[11px] text-theme-text-tertiary">
295
+ {rates.source === 'prometheus' ? 'From Prometheus, point' : 'Last Prometheus point'} {formatAge(new Date(rates.at * 1000).toISOString())} ago
296
+ </div>
297
+ )}
298
+ </div>
299
+ )
300
+ }
301
+
302
+ function TransactionsCard({ inst }: { inst?: CNPGRuntimeInstance }) {
303
+ const m = inst?.metrics
304
+ if (!m || (m.state !== 'ok' && m.state !== 'partial')) {
305
+ return (
306
+ <Card title="Database health">
307
+ <Unavailable inst={inst} what="Database health" />
308
+ </Card>
309
+ )
310
+ }
311
+ const hit = m.blksHit !== undefined && m.blksRead !== undefined && m.blksHit + m.blksRead > 0 ? (m.blksHit / (m.blksHit + m.blksRead)) * 100 : undefined
312
+ return (
313
+ <Card title={<>Database health on {inst!.pod}</>} footer="This instance’s exporter. Counters are cumulative since the last statistics reset.">
314
+ <SourceState label="Database health" state={m.state} reason={m.reason} error={m.error} />
315
+ <div className="flex flex-wrap gap-8">
316
+ <Metric label="Cache hit ratio" value={hit !== undefined ? `${hit.toFixed(1)} %` : '—'} />
317
+ <Metric label="Deadlocks (total)" value={m.deadlocksTotal ?? '—'} tone={m.deadlocksTotal ? 'degraded' : undefined} />
318
+ <Metric label="Oldest transaction" value={seconds(m.oldestXactSeconds)} />
319
+ </div>
320
+ <DatabaseHealth m={m} />
321
+ <ExtensionUpdates rows={m.extensionUpdates} missing={m.missing} partial={m.state === 'partial'} />
322
+ </Card>
323
+ )
324
+ }
325
+
326
+ function CheckpointsCard({ inst }: { inst?: CNPGRuntimeInstance }) {
327
+ const m = inst?.metrics
328
+ if (!m || (m.state !== 'ok' && m.state !== 'partial')) return <Card title="Checkpoints"><Unavailable inst={inst} what="Checkpoints" /></Card>
329
+ const c = m.checkpoints
330
+ if (!c) {
331
+ return (
332
+ <Card title={<>Checkpoints on {inst!.pod}</>}>
333
+ <SourceState label="Checkpoints" state={m.state} reason={m.reason} error={m.error} />
334
+ <div className="text-xs text-theme-text-tertiary">
335
+ Unknown: the exporter reported neither pg_stat_checkpointer (PostgreSQL 17+) nor pg_stat_bgwriter checkpoint counters.
336
+ </div>
337
+ </Card>
338
+ )
339
+ }
340
+ const view = cnpgCheckpointView(c, inst!.role)
341
+ const n = (v?: number) => (v === undefined ? '—' : v.toLocaleString())
342
+ return (
343
+ <Card
344
+ title={<>Checkpoints on {inst!.pod}</>}
345
+ footer={
346
+ <>
347
+ Cumulative since the statistics were last reset, from {c.source}.{' '}
348
+ {c.source === 'pg_stat_checkpointer'
349
+ ? 'A standby performs restartpoints instead of checkpoints; they are counted separately.'
350
+ : 'Before PostgreSQL 17 a standby counts its restartpoints as checkpoints.'}{' '}
351
+ Many requested checkpoints usually mean max_wal_size is too small for the write load.
352
+ </>
353
+ }
354
+ >
355
+ <SourceState label="Checkpoints" state={m.state} reason={m.reason} error={m.error} />
356
+ <div className="flex flex-wrap gap-8">
357
+ <Metric label="Timed" value={n(c.timed)} />
358
+ <Metric label="Requested" value={n(c.requested)} tone={view.pressure ? 'degraded' : undefined} />
359
+ {view.share !== undefined && <Metric label="Requested share" value={view.share} />}
360
+ <Metric label="Buffers written" value={n(c.buffersWritten)} />
361
+ {view.showRestartpoints ? (
362
+ <>
363
+ <Metric label="Restartpoints timed" value={n(c.restartpointsTimed)} />
364
+ <Metric label="Restartpoints requested" value={n(c.restartpointsRequested)} />
365
+ <Metric label="Restartpoints done" value={n(c.restartpointsDone)} />
366
+ </>
367
+ ) : null}
368
+ </div>
369
+ </Card>
370
+ )
371
+ }
372
+
373
+ function NotExported({ what, family }: { what: string; family: string }) {
374
+ return (
375
+ <div className="text-xs text-theme-text-tertiary">
376
+ {what} unknown: the exporter did not report <span className="font-mono">{family}</span> (a custom monitoring configuration may have replaced the default query).
377
+ </div>
378
+ )
379
+ }
380
+
381
+ const AGE_FAMILIES: { family: string; what: string }[] = [
382
+ { family: 'cnpg_pg_stat_database_xact_rollback', what: 'Rollback ratio' },
383
+ { family: 'cnpg_pg_stat_database_temp_bytes', what: 'Temporary files' },
384
+ { family: 'cnpg_pg_database_xid_age', what: 'Transaction ID age' },
385
+ { family: 'cnpg_pg_database_mxid_age', what: 'Multixact ID age' },
386
+ ]
387
+
388
+ function DatabaseHealth({ m }: { m: CNPGRuntimeInstance['metrics'] }) {
389
+ const rows = cnpgDatabaseHealthRows(m)
390
+ const absent = AGE_FAMILIES.filter((f) => m.missing?.includes(f.family))
391
+ // Whether a database accepts connections is not in the exporter's output, so
392
+ // a missing value is only said to be unreported, never explained.
393
+ const cell = (v: number | undefined, fmt: (v: number) => ReactNode) =>
394
+ v !== undefined ? (
395
+ fmt(v)
396
+ ) : (
397
+ <Tooltip content="Not reported by the exporter">
398
+ <span className="text-theme-text-tertiary">—</span>
399
+ </Tooltip>
400
+ )
401
+ const age = (v: number) => (
402
+ <Tooltip content={`${v.toLocaleString()} of ~2 billion before wraparound`}>
403
+ <span className={v > 1_000_000_000 ? toneTextClass('degraded') : undefined}>{cnpgIdAge(v)}</span>
404
+ </Tooltip>
405
+ )
406
+ return (
407
+ <div className="mt-4 text-sm">
408
+ <div className="text-xs text-theme-text-tertiary">Per database · counters since the last statistics reset · ID wraparound at ~2 billion</div>
409
+ {rows.length > 0 && (
410
+ <table className="mt-1 w-full text-sm">
411
+ <thead className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
412
+ <tr>
413
+ <th className="py-1.5 pr-3">Database</th>
414
+ <th className="pr-3 text-right">Rollback ratio</th>
415
+ <th className="pr-3 text-right">Temp files</th>
416
+ <th className="pr-3 text-right">Temp bytes</th>
417
+ <th className="pr-3 text-right">Transaction ID age</th>
418
+ <th className="text-right">Multixact ID age</th>
419
+ </tr>
420
+ </thead>
421
+ <tbody className="table-divide-subtle">
422
+ {rows.map((r) => (
423
+ <tr key={r.database}>
424
+ <td className="py-1.5 pr-3 font-mono text-xs">{r.database}</td>
425
+ <td className="pr-3 text-right font-mono text-xs">
426
+ {r.noTransactions ? (
427
+ <span className="text-theme-text-tertiary">no transactions</span>
428
+ ) : (
429
+ cell(r.rollbackRatio, (v) => <span className={v > 0.1 ? toneTextClass('degraded') : undefined}>{(v * 100).toFixed(1)} %</span>)
430
+ )}
431
+ </td>
432
+ <td className="pr-3 text-right font-mono text-xs">{cell(r.tempFiles, (v) => v.toLocaleString())}</td>
433
+ <td className="pr-3 text-right font-mono text-xs">{cell(r.tempBytes, formatBytes)}</td>
434
+ <td className="pr-3 text-right font-mono text-xs">{cell(r.xidAge, age)}</td>
435
+ <td className="text-right font-mono text-xs">{cell(r.mxidAge, age)}</td>
436
+ </tr>
437
+ ))}
438
+ </tbody>
439
+ </table>
440
+ )}
441
+ {absent.map((f) => (
442
+ <NotExported key={f.family} what={f.what} family={f.family} />
443
+ ))}
444
+ {rows.length === 0 && absent.length === 0 && <div className="text-xs text-theme-text-tertiary">The exporter reported no per-database rows.</div>}
445
+ </div>
446
+ )
447
+ }
448
+
449
+ const EXTENSIONS_FAMILY = 'cnpg_pg_extensions_update_available'
450
+
451
+ function ExtensionUpdates({ rows, missing, partial }: { partial?: boolean; rows?: { database: string; extension: string; installedVersion: string; defaultVersion: string }[] | null; missing?: string[] }) {
452
+ return (
453
+ <div className="mt-4 text-sm">
454
+ <div className="text-xs text-theme-text-tertiary">Extensions with updates available</div>
455
+ {missing?.includes(EXTENSIONS_FAMILY) || !rows ? (
456
+ <NotExported what="Extension versions" family={EXTENSIONS_FAMILY} />
457
+ ) : rows.length === 0 ? (
458
+ <div className="text-xs text-theme-text-secondary">{partial ? 'No extension updates seen in what was read.' : 'Every installed extension is at its default version.'}</div>
459
+ ) : (
460
+ <>
461
+ {rows.map((x) => (
462
+ <div key={`${x.database}/${x.extension}`} className="flex gap-3 font-mono text-xs">
463
+ <span className="w-40 truncate">{x.database}</span>
464
+ <span className="w-40 truncate">{x.extension}</span>
465
+ <span>
466
+ {x.installedVersion} → {x.defaultVersion}
467
+ </span>
468
+ </div>
469
+ ))}
470
+ <div className="mt-1 text-[11.5px] text-theme-text-tertiary">
471
+ From the primary's metrics exporter (default query pg_extensions). The installed version differs from the default version the server's packages provide; <span className="font-mono">ALTER EXTENSION … UPDATE</span> applies it.
472
+ </div>
473
+ </>
474
+ )}
475
+ </div>
476
+ )
477
+ }