@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,148 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { CNPGRuntimeResponse } from '../../api/cnpg'
3
+ import type { CNPGClusterHistoryResponse } from '../../api/cnpg-history'
4
+ import { cacheHitSeries, chartedDatabases, historyLatest, latestRate, rateSeries, sampleFrom, sessionStateSeries, type Sample } from './trendSamples'
5
+
6
+ const sample = (t: number, metricsAt: number | undefined, over: Partial<Sample>, source = 'pg-1/uid-1'): Sample => ({
7
+ t: t * 1000,
8
+ metricsAt: metricsAt === undefined ? undefined : metricsAt * 1000,
9
+ source,
10
+ replayLag: {},
11
+ ...over,
12
+ })
13
+
14
+ describe('rates between exporter generations', () => {
15
+ it('uses the generation time and counts a cached reading once', () => {
16
+ // Fetched at 0, 25 and 50 s; the exporter reran its queries at 0 and 50 s.
17
+ const samples = [sample(0, 0, { archived: 100 }), sample(25, 0, { archived: 100 }), sample(50, 50, { archived: 150 })]
18
+ expect(rateSeries(samples, 'archived', 60).dataPoints).toEqual([{ timestamp: 50, value: 60 }])
19
+ })
20
+
21
+ it('never takes a delta across a change of primary', () => {
22
+ const samples = [sample(0, 0, { checkpointsTimed: 10 }, 'pg-1/a'), sample(30, 30, { checkpointsTimed: 1000 }, 'pg-2/b'), sample(60, 60, { checkpointsTimed: 1002 }, 'pg-2/b')]
23
+ expect(rateSeries(samples, 'checkpointsTimed', 60).dataPoints).toEqual([
24
+ { timestamp: 30, value: null },
25
+ { timestamp: 60, value: 4 },
26
+ ])
27
+ })
28
+
29
+ it('leaves a gap for a reset and for a sample without a source', () => {
30
+ const samples = [sample(0, 0, { deadlocks: 5 }), sample(30, 30, { deadlocks: 2 }), sample(60, 60, { deadlocks: 3, source: undefined })]
31
+ const pts = rateSeries(samples, 'deadlocks', 60).dataPoints
32
+ expect(pts[0]).toEqual({ timestamp: 30, value: null })
33
+ expect(pts[1].value).toBeNull()
34
+ })
35
+
36
+ it('computes cache hit ratio from deltas and never shows 100 % for no reads', () => {
37
+ const samples = [sample(0, 0, { blksHit: 100, blksRead: 10 }), sample(30, 30, { blksHit: 190, blksRead: 20 }), sample(60, 60, { blksHit: 190, blksRead: 20 })]
38
+ const pts = cacheHitSeries(samples).dataPoints
39
+ expect(pts).toEqual([
40
+ { timestamp: 30, value: 90 },
41
+ { timestamp: 60, value: null },
42
+ ])
43
+ })
44
+
45
+ it('gives the latest rate only from two generations of one source', () => {
46
+ expect(latestRate([sample(0, 0, { commits: 10 }), sample(10, 0, { commits: 10 })], 'commits')).toBeUndefined()
47
+ expect(latestRate([sample(0, 0, { commits: 10 }), sample(10, 10, { commits: 30 })], 'commits')).toBe(2)
48
+ })
49
+ })
50
+
51
+ describe('sampleFrom', () => {
52
+ const rt = (primaryMetrics: any, replicaMetrics: any = { state: 'ok', sessionsTotal: 1, sessionsByState: { active: 1 } }): CNPGRuntimeResponse => ({
53
+ cluster: { namespace: 'db', name: 'pg', uid: 'u' },
54
+ sampledAt: '2026-09-30T10:00:10Z',
55
+ permission: { proxy: 'allowed' },
56
+ instances: [
57
+ { pod: 'pg-1', podUID: 'uid-1', role: 'primary', status: { state: 'ok' }, metrics: primaryMetrics },
58
+ { pod: 'pg-2', podUID: 'uid-2', role: 'replica', status: { state: 'ok' }, metrics: replicaMetrics },
59
+ ],
60
+ })
61
+
62
+ it('records the source Pod and the exporter generation, falling back to the fetch time as approximate', () => {
63
+ const exact = sampleFrom(rt({ state: 'ok', capturedAt: '2026-09-30T10:00:09Z', lastUpdateTimestamp: 1790000000, xactCommitTotal: 5 }))
64
+ expect(exact).toMatchObject({ source: 'pg-1/uid-1', metricsAt: 1790000000000, approximate: false, commits: 5 })
65
+ const approx = sampleFrom(rt({ state: 'ok', capturedAt: '2026-09-30T10:00:09Z' }))
66
+ expect(approx.metricsAt).toBe(Date.parse('2026-09-30T10:00:09Z'))
67
+ expect(approx.approximate).toBe(true)
68
+ expect(sampleFrom(rt({ state: 'denied' })).source).toBeUndefined()
69
+ })
70
+
71
+ it('records sessions for every instance that answered, and none for one that did not', () => {
72
+ const s = sampleFrom(rt({ state: 'ok', sessionsTotal: 0 }, { state: 'unreachable' }))
73
+ expect(s.instances).toEqual({ 'pg-1': { byState: {}, total: 0, waiting: undefined } })
74
+ })
75
+
76
+ it('keeps lock waits when the session-count query is missing, and leaves session states a gap', () => {
77
+ const s = sampleFrom(rt({ state: 'partial', waitingBackends: 3 }, { state: 'unreachable' }))
78
+ expect(s.instances?.['pg-1']).toEqual({ byState: undefined, total: undefined, waiting: 3 })
79
+ const states = sessionStateSeries([s], 'pg-1')
80
+ expect(states.every((series) => series.dataPoints[0].value === null)).toBe(true)
81
+ })
82
+ })
83
+
84
+ describe('sessionStateSeries', () => {
85
+ it('groups states and gaps a sample where the instance did not answer', () => {
86
+ const samples = [
87
+ sample(0, 0, { instances: { 'pg-2': { byState: { active: 2, 'idle in transaction': 1, 'idle in transaction (aborted)': 1, idle: 5, 'fastpath function call': 1 }, total: 10 } } }),
88
+ sample(5, 5, { instances: {} }),
89
+ ]
90
+ const series = sessionStateSeries(samples, 'pg-2')
91
+ expect(series.map((x) => [x.labels.series, x.dataPoints[0].value])).toEqual([
92
+ ['active', 2],
93
+ ['idle', 5],
94
+ ['idle in transaction', 2],
95
+ ['other', 1],
96
+ ])
97
+ expect(series.every((x) => x.dataPoints[1].value === null)).toBe(true)
98
+ })
99
+ })
100
+
101
+ describe('chartedDatabases', () => {
102
+ const samples = [
103
+ sample(0, 0, { dbSizes: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, tiny: 0.5 } }),
104
+ sample(5, 5, { dbSizes: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 } }),
105
+ ]
106
+ it('defaults to the five largest and follows any database the viewer picks', () => {
107
+ expect(chartedDatabases(samples, null).shown).toEqual(['f', 'e', 'd', 'c', 'b'])
108
+ expect(chartedDatabases(samples, ['tiny', 'a']).shown).toEqual(['a', 'tiny'])
109
+ expect(chartedDatabases(samples, 'all').shown).toHaveLength(7)
110
+ })
111
+ })
112
+
113
+ describe('historyLatest', () => {
114
+ const history = (over: Partial<CNPGClusterHistoryResponse> = {}, chartState = 'ok') =>
115
+ ({
116
+ source: 'prometheus',
117
+ state: 'ok',
118
+ charts: [
119
+ {
120
+ id: 'tps',
121
+ seriesBy: 'series',
122
+ state: chartState,
123
+ source: 'rate of xact_commit',
124
+ series: [{ labels: { series: 'commits' }, dataPoints: [{ timestamp: 1, value: 4 }, { timestamp: 2, value: 7.5 }, { timestamp: 3, value: null }] }],
125
+ },
126
+ ],
127
+ ...over,
128
+ }) as unknown as CNPGClusterHistoryResponse
129
+
130
+ it('takes the newest recorded point of the named series', () => {
131
+ expect(historyLatest(history({ end: new Date(3 * 1000).toISOString(), stepSeconds: 1 }), 'tps', 'commits')).toEqual({
132
+ value: 7.5,
133
+ at: 2,
134
+ source: 'rate of xact_commit',
135
+ stale: false,
136
+ })
137
+ expect(historyLatest(history(), 'tps', 'rollbacks')).toBeUndefined()
138
+ })
139
+ it('marks a value stale when the series stopped more than two steps before the end', () => {
140
+ const got = historyLatest(history({ end: new Date(600 * 1000).toISOString(), stepSeconds: 60 }), 'tps', 'commits')
141
+ expect(got).toMatchObject({ value: 7.5, stale: true })
142
+ })
143
+ it('has nothing without Prometheus history', () => {
144
+ expect(historyLatest(history({ source: 'none' }), 'tps', 'commits')).toBeUndefined()
145
+ expect(historyLatest(history({}, 'noSeries'), 'tps', 'commits')).toBeUndefined()
146
+ expect(historyLatest(undefined, 'tps', 'commits')).toBeUndefined()
147
+ })
148
+ })
@@ -0,0 +1,222 @@
1
+ import type { TimeSeries } from '@skyhook-io/k8s-ui/components/charts'
2
+ import type { CNPGRuntimeResponse } from '../../api/cnpg'
3
+ import type { CNPGClusterHistoryResponse } from '../../api/cnpg-history'
4
+
5
+ /** Sessions on one instance at one sample, by pg_stat_activity state. */
6
+ // Session totals and lock waits come from separate exporter queries: each is
7
+ // kept when its own query answered, so one missing never hides the other.
8
+ export interface InstanceSessions {
9
+ byState?: Record<string, number>
10
+ total?: number
11
+ waiting?: number
12
+ }
13
+
14
+ // A ring buffer of samples taken while this view is open: the Trends section
15
+ // without Prometheus. It says so, and starts empty.
16
+ export interface Sample {
17
+ t: number
18
+ /**
19
+ * The Pod the primary's counters came from (name and UID). Counters are per
20
+ * instance, so a rate is never taken across a change of source.
21
+ */
22
+ source?: string
23
+ /**
24
+ * When the exporter generated these values (ms): cnpg_last_update_timestamp
25
+ * when the operator publishes it, else Radar's fetch time.
26
+ */
27
+ metricsAt?: number
28
+ /** metricsAt is Radar's fetch time: the exporter published no generation time. */
29
+ approximate?: boolean
30
+ replayLag: Record<string, number | undefined>
31
+ commits?: number
32
+ rollbacks?: number
33
+ archived?: number
34
+ failed?: number
35
+ blksHit?: number
36
+ blksRead?: number
37
+ walBytes?: number
38
+ deadlocks?: number
39
+ tempBytes?: number
40
+ checkpointsTimed?: number
41
+ checkpointsRequested?: number
42
+ /** Database sizes by name (gauges), every database the exporter reported. */
43
+ dbSizes?: Record<string, number>
44
+ /** Sessions per instance Pod, for every instance whose exporter answered. */
45
+ instances?: Record<string, InstanceSessions>
46
+ }
47
+
48
+ export type CounterKey = 'commits' | 'rollbacks' | 'archived' | 'failed' | 'blksHit' | 'blksRead' | 'deadlocks' | 'tempBytes' | 'checkpointsTimed' | 'checkpointsRequested'
49
+
50
+ export const SAMPLE_BUFFER_LIMIT = 720
51
+
52
+ export function sampleFrom(data: CNPGRuntimeResponse): Sample {
53
+ const primary = data.instances.find((i) => i.role === 'primary')
54
+ const replayLag: Record<string, number | undefined> = {}
55
+ for (const r of primary?.status.replication ?? []) replayLag[r.applicationName] = r.replayLag
56
+ const m = primary?.metrics
57
+ const read = m && (m.state === 'ok' || m.state === 'partial') ? m : undefined
58
+ const generation = read?.lastUpdateTimestamp !== undefined ? read.lastUpdateTimestamp * 1000 : undefined
59
+ const fetched = read?.capturedAt ? Date.parse(read.capturedAt) || undefined : undefined
60
+ const instances: Record<string, InstanceSessions> = {}
61
+ for (const inst of data.instances) {
62
+ const im = inst.metrics
63
+ if (im.state !== 'ok' && im.state !== 'partial') continue
64
+ if (im.sessionsTotal === undefined && im.waitingBackends === undefined) continue
65
+ instances[inst.pod] = {
66
+ byState: im.sessionsTotal !== undefined ? { ...(im.sessionsByState ?? {}) } : undefined,
67
+ total: im.sessionsTotal,
68
+ waiting: im.waitingBackends,
69
+ }
70
+ }
71
+ return {
72
+ t: Date.parse(data.sampledAt) || Date.now(),
73
+ source: read && primary ? `${primary.pod}/${primary.podUID ?? ''}` : undefined,
74
+ metricsAt: generation ?? fetched,
75
+ approximate: read ? generation === undefined : undefined,
76
+ replayLag,
77
+ commits: read?.xactCommitTotal,
78
+ rollbacks: read?.xactRollbackTotal,
79
+ archived: read?.archiver?.archivedCount,
80
+ failed: read?.archiver?.failedCount,
81
+ blksHit: read?.blksHit,
82
+ blksRead: read?.blksRead,
83
+ walBytes: read?.walBytes,
84
+ deadlocks: read?.deadlocksTotal,
85
+ tempBytes: read?.tempBytesTotal,
86
+ checkpointsTimed: read?.checkpoints?.timed,
87
+ checkpointsRequested: read?.checkpoints?.requested,
88
+ dbSizes: read?.databaseSizes ? Object.fromEntries(read.databaseSizes.map((d) => [d.database, d.bytes])) : undefined,
89
+ instances,
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Walks consecutive exporter generations of the same source. Samples that
95
+ * share a generation are one reading (the exporter and Radar both cache), so
96
+ * only the first counts; a change of source is a gap, never a delta.
97
+ */
98
+ function generationPairs(
99
+ samples: Sample[],
100
+ has: (s: Sample) => boolean,
101
+ value: (prev: Sample, s: Sample, seconds: number) => number | null,
102
+ ): TimeSeries['dataPoints'] {
103
+ const points: TimeSeries['dataPoints'] = []
104
+ let prev: Sample | undefined
105
+ for (const s of samples) {
106
+ if (!has(s) || s.metricsAt === undefined || s.source === undefined) {
107
+ points.push({ timestamp: s.t / 1000, value: null })
108
+ continue
109
+ }
110
+ if (prev && prev.source !== s.source) {
111
+ points.push({ timestamp: s.t / 1000, value: null })
112
+ prev = s
113
+ continue
114
+ }
115
+ if (prev && prev.metricsAt === s.metricsAt) continue
116
+ if (prev) {
117
+ const dt = ((s.metricsAt as number) - (prev.metricsAt as number)) / 1000
118
+ points.push({ timestamp: s.t / 1000, value: dt > 0 ? value(prev, s, dt) : null })
119
+ }
120
+ prev = s
121
+ }
122
+ return points
123
+ }
124
+
125
+ /** A counter as a rate between generations; a decrease is a reset and leaves a gap. `per` scales to per-minute. */
126
+ export function rateSeries(samples: Sample[], key: CounterKey, per = 1, label: string = key): TimeSeries {
127
+ return {
128
+ labels: { series: label },
129
+ dataPoints: generationPairs(
130
+ samples,
131
+ (s) => s[key] !== undefined,
132
+ (prev, s, dt) => {
133
+ const dv = (s[key] as number) - (prev[key] as number)
134
+ return dv >= 0 ? (dv / dt) * per : null
135
+ },
136
+ ),
137
+ }
138
+ }
139
+
140
+ /** Cache hit ratio (%) of the blocks read between generations; no reads leaves a gap, not 100 %. */
141
+ export function cacheHitSeries(samples: Sample[]): TimeSeries {
142
+ return {
143
+ labels: { series: 'hit ratio' },
144
+ dataPoints: generationPairs(
145
+ samples,
146
+ (s) => s.blksHit !== undefined && s.blksRead !== undefined,
147
+ (prev, s) => {
148
+ const hit = (s.blksHit as number) - (prev.blksHit as number)
149
+ const read = (s.blksRead as number) - (prev.blksRead as number)
150
+ return hit >= 0 && read >= 0 && hit + read > 0 ? (hit / (hit + read)) * 100 : null
151
+ },
152
+ ),
153
+ }
154
+ }
155
+
156
+ /** The latest per-second rate of a counter, or undefined until two generations of one source were seen. */
157
+ export function latestRate(samples: Sample[], key: CounterKey): number | undefined {
158
+ const pts = rateSeries(samples, key).dataPoints
159
+ const last = pts[pts.length - 1]
160
+ return last?.value ?? undefined
161
+ }
162
+
163
+ /** How many trailing steps without a point still count as the latest reading. */
164
+ export const HISTORY_RECENT_STEPS = 2
165
+
166
+ /**
167
+ * The newest value of one labelled series of a Prometheus history chart, or
168
+ * undefined when history is not from Prometheus or that chart has no point.
169
+ * `stale` when that value is more than HISTORY_RECENT_STEPS steps older than
170
+ * the query's end: the series stopped, so it is not a current rate.
171
+ */
172
+ export function historyLatest(
173
+ history: CNPGClusterHistoryResponse | undefined,
174
+ chartId: string,
175
+ series: string,
176
+ ): { value: number; at: number; source: string; stale: boolean } | undefined {
177
+ if (history?.source !== 'prometheus' || history.state !== 'ok') return undefined
178
+ const chart = history.charts.find((c) => c.id === chartId)
179
+ if (!chart || chart.state !== 'ok') return undefined
180
+ const pts = chart.series.find((s) => s.labels[chart.seriesBy] === series)?.dataPoints ?? []
181
+ const endMs = Date.parse(history.end ?? history.sampledAt)
182
+ const step = history.stepSeconds ?? 60
183
+ for (let i = pts.length - 1; i >= 0; i--) {
184
+ const v = pts[i].value
185
+ if (v === null) continue
186
+ const stale = Number.isFinite(endMs) && endMs / 1000 - pts[i].timestamp > HISTORY_RECENT_STEPS * step
187
+ return { value: v, at: pts[i].timestamp, source: chart.source, stale }
188
+ }
189
+ return undefined
190
+ }
191
+
192
+ export const SESSION_STATE_GROUPS: { id: string; label: string; states: (state: string) => boolean }[] = [
193
+ { id: 'active', label: 'active', states: (s) => s === 'active' },
194
+ { id: 'idle', label: 'idle', states: (s) => s === 'idle' },
195
+ { id: 'idleInTx', label: 'idle in transaction', states: (s) => s.startsWith('idle in transaction') },
196
+ { id: 'other', label: 'other', states: (s) => s !== 'active' && s !== 'idle' && !s.startsWith('idle in transaction') },
197
+ ]
198
+
199
+ /** One instance's sessions by state group; a sample where the instance did not answer is a gap. */
200
+ export function sessionStateSeries(samples: Sample[], pod: string): TimeSeries[] {
201
+ return SESSION_STATE_GROUPS.map((g) => ({
202
+ labels: { series: g.label },
203
+ dataPoints: samples.map((s) => {
204
+ const inst = s.instances?.[pod]
205
+ if (!inst?.byState) return { timestamp: s.t / 1000, value: null }
206
+ let v = 0
207
+ for (const [state, n] of Object.entries(inst.byState)) if (g.states(state)) v += n
208
+ return { timestamp: s.t / 1000, value: v }
209
+ }),
210
+ }))
211
+ }
212
+
213
+ /** The databases to chart: the five largest in the latest sample unless the viewer picked some. */
214
+ export function chartedDatabases(samples: Sample[], picked: string[] | 'all' | null): { all: string[]; shown: string[] } {
215
+ const seen = new Set<string>()
216
+ for (const s of samples) for (const d of Object.keys(s.dbSizes ?? {})) seen.add(d)
217
+ const latest = [...samples].reverse().find((s) => s.dbSizes)?.dbSizes ?? {}
218
+ const all = [...seen].sort((a, b) => (latest[b] ?? -1) - (latest[a] ?? -1) || a.localeCompare(b))
219
+ if (picked === 'all') return { all, shown: all }
220
+ if (picked && picked.length > 0) return { all, shown: all.filter((d) => picked.includes(d)) }
221
+ return { all, shown: all.slice(0, 5) }
222
+ }
@@ -0,0 +1,30 @@
1
+ import { useMemo } from 'react'
2
+ import { cnpgDimensions } from '@skyhook-io/k8s-ui'
3
+ import { useCNPGRuntime } from '../../api/cnpg'
4
+ import { useCNPGClusterHA } from '../../api/cnpg-ha'
5
+ import { cnpgReplicationGap, cnpgReplicationLive, withLiveReplication } from './runtimeAssessment'
6
+ import { useCNPGFleet } from './useCNPGSidebarWorkspace'
7
+
8
+ /**
9
+ * One Cluster's assessment, shared by the page header's chips, the Overview
10
+ * and the tabs: the fleet row (cached objects, issues, Prometheus) enriched
11
+ * with what the instance managers report, and the four dimensions derived
12
+ * from it. Every consumer reads this, so none can read calmer than another.
13
+ */
14
+ export function useCNPGClusterAssessment(namespace: string, name: string) {
15
+ // The workspace is read for the object's own namespace: an explicitly opened
16
+ // Cluster shows its facts whatever the namespace filter is.
17
+ const { query, fleet } = useCNPGFleet([namespace])
18
+ const runtime = useCNPGRuntime(namespace, name)
19
+ const ha = useCNPGClusterHA(namespace, name)
20
+ const baseRow = fleet?.rows.find((r) => r.namespace === namespace && r.name === name)
21
+ const row = useMemo(() => (baseRow ? withLiveReplication(baseRow, runtime.data) : undefined), [baseRow, runtime.data])
22
+ const dimensions = useMemo(
23
+ () =>
24
+ row
25
+ ? cnpgDimensions({ row, ha: ha.data, replication: cnpgReplicationLive(runtime.data), replicationGap: cnpgReplicationGap(runtime.data, runtime.error) })
26
+ : undefined,
27
+ [row, ha.data, runtime.data, runtime.error],
28
+ )
29
+ return { query, fleet, runtime, ha, row, dimensions }
30
+ }
@@ -0,0 +1,45 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { afterEach, expect, it, vi } from 'vitest'
3
+ import { cnpgPortForwardCommand } from '@skyhook-io/k8s-ui'
4
+ import { useCNPGKubectlContext } from './useCNPGKubectlContext'
5
+ const state = vi.hoisted(() => ({ context: 'kind-orders', mode: 'local' as string | undefined, embedded: false, contexts: [{ name: 'kind-orders', source: 'orders' }] as { name: string; originalName?: string; source?: string }[] | undefined }))
6
+ vi.mock('../../api/client', () => ({
7
+ useCapabilities: () => ({ data: state.mode ? { deployment: { mode: state.mode } } : undefined }),
8
+ useContexts: () => ({ data: state.contexts }),
9
+ }))
10
+ vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: state.context } }) }))
11
+ vi.mock('../../context/NavCustomization', () => ({ useNavCustomization: () => ({ embedded: state.embedded }) }))
12
+ function Context() {
13
+ const context = useCNPGKubectlContext()
14
+ return <span>{cnpgPortForwardCommand({ name: 'orders-rw', port: 5432 } as any, 'db', 5432, context?.name)} · {context?.source ?? 'current kubectl context'}</span>
15
+ }
16
+ const render = () => renderToStaticMarkup(<Context />)
17
+ afterEach(() => {
18
+ state.context = 'kind-orders'; state.mode = 'local'; state.embedded = false; state.contexts = [{ name: 'kind-orders', source: 'orders' }]
19
+ })
20
+ it('only supplies a known local kubeconfig context, never a Hub or in-cluster identity', () => {
21
+ expect(render()).toContain('kind-orders')
22
+ state.embedded = true; expect(render()).toContain('current kubectl context'); expect(render()).not.toContain('--context')
23
+ state.embedded = false; state.mode = 'in-cluster'; expect(render()).toContain('current kubectl context'); expect(render()).not.toContain('--context')
24
+ state.mode = undefined; expect(render()).toContain('current kubectl context')
25
+ state.mode = 'local'; state.context = ''; expect(render()).toContain('current kubectl context')
26
+ })
27
+ it('uses the in-file name and source for a collision-qualified context', () => {
28
+ state.context = 'prod (staging)'
29
+ state.contexts = [{ name: 'prod', source: 'production' }, { name: 'prod (staging)', originalName: 'prod', source: 'staging' }]
30
+ expect(render()).toContain('kubectl --context prod -n db')
31
+ expect(render()).toContain('staging')
32
+ expect(render()).not.toContain('prod (staging)')
33
+ })
34
+ it('uses a plain context name when it has no originalName', () => {
35
+ expect(render()).toContain('kubectl --context kind-orders -n db')
36
+ expect(render()).toContain('orders')
37
+ })
38
+ it('leaves kubectl context selection to the shell when the context cannot be resolved', () => {
39
+ state.context = 'prod (staging)'
40
+ expect(render()).not.toContain('--context')
41
+ expect(render()).toContain('current kubectl context')
42
+ state.contexts = undefined
43
+ expect(render()).not.toContain('--context')
44
+ expect(render()).not.toContain('prod (staging)')
45
+ })
@@ -0,0 +1,13 @@
1
+ import { useCapabilities, useContexts } from '../../api/client'
2
+ import { useConnection } from '../../context/ConnectionContext'
3
+ import { useNavCustomization } from '../../context/NavCustomization'
4
+
5
+ export function useCNPGKubectlContext(): { name: string; source?: string } | undefined {
6
+ const { connection } = useConnection()
7
+ const { data } = useCapabilities()
8
+ const { data: contexts } = useContexts()
9
+ const { embedded } = useNavCustomization()
10
+ if (embedded || data?.deployment?.mode !== 'local') return undefined
11
+ const context = contexts?.find((context) => context.name === connection.context)
12
+ return context ? { name: context.originalName || context.name, source: context.source } : undefined
13
+ }
@@ -0,0 +1,58 @@
1
+ // @vitest-environment jsdom
2
+ import { act, useEffect } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { MemoryRouter, useLocation } from 'react-router-dom'
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+ import { useCNPGNavigate } from './useCNPGNavigate'
7
+
8
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
9
+
10
+ let root: Root | null = null
11
+ let container: HTMLDivElement | null = null
12
+ afterEach(() => {
13
+ act(() => root?.unmount())
14
+ container?.remove()
15
+ root = null
16
+ })
17
+
18
+ function Probe({ to, onLocation }: { to: string; onLocation: (path: string) => void }) {
19
+ const navigate = useCNPGNavigate()
20
+ const location = useLocation()
21
+ useEffect(() => {
22
+ onLocation(location.pathname + location.search)
23
+ }, [location, onLocation])
24
+ return (
25
+ <button type="button" onClick={() => navigate(to)}>
26
+ go
27
+ </button>
28
+ )
29
+ }
30
+
31
+ function landAt(start: string, to: string): string {
32
+ let at = ''
33
+ container = document.createElement('div')
34
+ document.body.appendChild(container)
35
+ root = createRoot(container)
36
+ act(() =>
37
+ root!.render(
38
+ <MemoryRouter initialEntries={[start]}>
39
+ <Probe to={to} onLocation={(p) => (at = p)} />
40
+ </MemoryRouter>,
41
+ ),
42
+ )
43
+ act(() => container!.querySelector('button')!.click())
44
+ return at
45
+ }
46
+
47
+ describe('useCNPGNavigate', () => {
48
+ it('keeps the namespace filter, which the app would otherwise reset to All namespaces', () => {
49
+ expect(landAt('/cnpg?namespaces=pg', '/cnpg/operator')).toBe('/cnpg/operator?namespaces=pg')
50
+ expect(landAt('/cnpg?namespaces=pg', '/workload/services/pg/pg-rw?tab=reachability')).toBe('/workload/services/pg/pg-rw?tab=reachability&namespaces=pg')
51
+ })
52
+ it('leaves a destination that names its own namespaces alone', () => {
53
+ expect(landAt('/cnpg?namespaces=pg', '/cnpg?namespaces=other')).toBe('/cnpg?namespaces=other')
54
+ })
55
+ it('keeps namespace and investigation parameters when opening cluster-scoped Declarations', () => {
56
+ expect(landAt('/cnpg/clusters/db/pg?tab=spec&namespaces=db&ai-run=run-1', '/cnpg/declarations?cluster=db%2Fpg')).toBe('/cnpg/declarations?cluster=db%2Fpg&namespaces=db&ai-run=run-1')
57
+ })
58
+ })
@@ -0,0 +1,21 @@
1
+ import { useCallback } from 'react'
2
+ import { useLocation, useNavigate, type NavigateOptions, type To } from 'react-router-dom'
3
+ import { withCrossViewParams } from '../../utils/navigation'
4
+
5
+ /**
6
+ * navigate() for the CloudNativePG screens: a string path keeps the current
7
+ * cross-view params (the namespace filter, an open investigation), as the
8
+ * app's own navigation does. Without them the app reads the missing
9
+ * ?namespaces= as "All namespaces" and saves that as the user's choice.
10
+ */
11
+ export function useCNPGNavigate() {
12
+ const navigate = useNavigate()
13
+ const { search } = useLocation()
14
+ return useCallback(
15
+ (to: To | number, options?: NavigateOptions) => {
16
+ if (typeof to === 'number') return navigate(to)
17
+ return navigate(typeof to === 'string' ? withCrossViewParams(to, search) : to, options)
18
+ },
19
+ [navigate, search],
20
+ )
21
+ }
@@ -0,0 +1,46 @@
1
+ import type { UseQueryResult } from '@tanstack/react-query'
2
+ import type { CNPGPoolerLive } from '@skyhook-io/k8s-ui'
3
+ import { useCNPGPoolerRuntime } from '../../api/cnpg'
4
+ import { useCNPGPgBouncerState, useCNPGPoolerCapabilities } from '../../api/cnpg-sessions'
5
+ import { formatGrant } from '@skyhook-io/k8s-ui'
6
+
7
+ /**
8
+ * The live reads the Pooler summary shows beside the spec, and the queries
9
+ * behind them so a host can say when a refresh failed over cached values.
10
+ */
11
+ export function useCNPGPoolerLive(namespace: string, name: string): { live: CNPGPoolerLive; queries: Pick<UseQueryResult<unknown>, 'isRefetchError' | 'error' | 'dataUpdatedAt'>[] } {
12
+ const caps = useCNPGPoolerCapabilities(namespace, name)
13
+ const runtime = useCNPGPoolerRuntime(namespace, name)
14
+ const observeDenied = caps.data?.actions.observeState.permission === 'denied'
15
+ const state = useCNPGPgBouncerState(namespace, name, !!caps.data && !observeDenied)
16
+
17
+ const live: CNPGPoolerLive = {}
18
+ if (caps.data) {
19
+ live.deployment = caps.data.facts.deployment
20
+ live.service = caps.data.facts.service
21
+ }
22
+ if (runtime.data) {
23
+ const denied = runtime.data.permission.proxy === 'denied'
24
+ live.pressure = {
25
+ state: denied ? 'denied' : 'ok',
26
+ reason: denied ? `needs ${formatGrant(runtime.data.permission.grant) ?? 'get pods/proxy'}` : undefined,
27
+ pods: runtime.data.pods,
28
+ }
29
+ } else {
30
+ live.pressure = runtime.isLoading
31
+ ? { state: 'loading', pods: [] }
32
+ : { state: 'error', reason: runtime.error instanceof Error ? runtime.error.message : 'read failed', pods: [] }
33
+ }
34
+ if (caps.data) {
35
+ if (observeDenied) {
36
+ live.observed = { state: 'denied', grant: caps.data.actions.observeState.grant, pods: [] }
37
+ } else if (state.data) {
38
+ live.observed = { state: state.data.permission.exec === 'denied' ? 'denied' : 'ok', grant: state.data.permission.grant, pods: state.data.pods }
39
+ } else {
40
+ live.observed = state.isLoading
41
+ ? { state: 'loading', pods: [] }
42
+ : { state: 'error', reason: state.error instanceof Error ? state.error.message : undefined, pods: [] }
43
+ }
44
+ }
45
+ return { live, queries: [caps, runtime, state] }
46
+ }
@@ -0,0 +1,24 @@
1
+ import { useCallback } from 'react'
2
+ import { useLocation, useSearchParams } from 'react-router-dom'
3
+
4
+ /**
5
+ * A CloudNativePG screen's own URL parameters (`q`, `cat`, `filter`, …),
6
+ * written in place: an empty value removes the parameter, history is replaced
7
+ * and its state (the return to the previous page) is kept.
8
+ */
9
+ export function useCNPGScreenParams(): [URLSearchParams, (update: Record<string, string | null>) => void] {
10
+ const [searchParams, setSearchParams] = useSearchParams()
11
+ const location = useLocation()
12
+ const setParams = useCallback(
13
+ (update: Record<string, string | null>) => {
14
+ const params = new URLSearchParams(searchParams)
15
+ for (const [k, v] of Object.entries(update)) {
16
+ if (v === null || v === '') params.delete(k)
17
+ else params.set(k, v)
18
+ }
19
+ setSearchParams(params, { replace: true, state: location.state })
20
+ },
21
+ [searchParams, setSearchParams, location.state],
22
+ )
23
+ return [searchParams, setParams]
24
+ }