@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,702 @@
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import { useLocation, useSearchParams } from 'react-router-dom'
3
+ import { X } from 'lucide-react'
4
+ import { Tooltip, formatGrant, toneTextClass } from '@skyhook-io/k8s-ui'
5
+ import { AreaChart, SeriesLegend, type ChartTimeRange, type ReferenceLine, type TimeSeries } from '@skyhook-io/k8s-ui/components/charts'
6
+ import type { CNPGRuntimeResponse } from '../../api/cnpg'
7
+ import {
8
+ CNPG_HISTORY_RANGES,
9
+ useCNPGClusterHistory,
10
+ type CNPGHistoryChart,
11
+ type CNPGHistoryRange,
12
+ type CNPGClusterHistoryResponse,
13
+ } from '../../api/cnpg-history'
14
+ import { cacheHitSeries, chartedDatabases, rateSeries, sampleFrom, sessionStateSeries, SAMPLE_BUFFER_LIMIT, type Sample } from './trendSamples'
15
+ import { Notice, Segments } from '../workspace/layout'
16
+
17
+ const COLOR = '#60a5fa'
18
+ const FILL = '#60a5fa22'
19
+
20
+ export type { Sample } from './trendSamples'
21
+
22
+ export function useSampleBuffer(data: CNPGRuntimeResponse | undefined): Sample[] {
23
+ const [samples, setSamples] = useState<Sample[]>([])
24
+ const last = useRef<string | null>(null)
25
+ useEffect(() => {
26
+ if (!data || data.sampledAt === last.current) return
27
+ last.current = data.sampledAt
28
+ setSamples((prev) => [...prev, sampleFrom(data)].slice(-SAMPLE_BUFFER_LIMIT))
29
+ }, [data])
30
+ return samples
31
+ }
32
+
33
+ export type CNPGIntervalTarget = 'logs' | 'activity'
34
+
35
+ function chartUnit(unit: string): string {
36
+ return unit === 'per second' || unit === 'per minute' ? '' : unit
37
+ }
38
+
39
+ function clock(unix: number): string {
40
+ return new Date(unix * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
41
+ }
42
+
43
+ function span(seconds: number): string {
44
+ if (seconds < 90) return `${Math.round(seconds)} s`
45
+ if (seconds < 5400) return `${Math.round(seconds / 60)} min`
46
+ return `${(seconds / 3600).toFixed(1)} h`
47
+ }
48
+
49
+ /**
50
+ * Steps where no series has a sample, as hatched ranges. A run that starts
51
+ * the window is before the series existed or beyond retention; any other is a
52
+ * missed scrape or an instance that was down.
53
+ */
54
+ export function historyGaps(series: TimeSeries[], start: number, end: number, step: number): (ChartTimeRange & { label: string })[] {
55
+ if (step <= 0 || end <= start) return []
56
+ const present = new Set<number>()
57
+ for (const s of series) for (const p of s.dataPoints) if (p.value != null) present.add(p.timestamp)
58
+ const out: (ChartTimeRange & { label: string })[] = []
59
+ let runStart: number | null = null
60
+ const flush = (last: number) => {
61
+ if (runStart === null) return
62
+ out.push({
63
+ start: Math.max(start, runStart - step / 2),
64
+ end: Math.min(end, last + step / 2),
65
+ label: runStart === start ? 'No sample: before this series existed or beyond Prometheus retention' : 'No sample recorded (instance down, restarting or not scraped)',
66
+ })
67
+ runStart = null
68
+ }
69
+ let prev = start
70
+ for (let t = start; t <= end; t += step) {
71
+ if (present.has(t)) flush(prev)
72
+ else if (runStart === null) runStart = t
73
+ prev = t
74
+ }
75
+ flush(prev)
76
+ return out
77
+ }
78
+
79
+ /** Hatched ranges between the samples around each run where no series has a value. */
80
+ export function sampleGaps(series: TimeSeries[]): (ChartTimeRange & { label: string })[] {
81
+ const ts = [...new Set(series.flatMap((s) => s.dataPoints.map((p) => p.timestamp)))].sort((a, b) => a - b)
82
+ const present = new Set<number>()
83
+ for (const s of series) for (const p of s.dataPoints) if (p.value != null) present.add(p.timestamp)
84
+ const out: (ChartTimeRange & { label: string })[] = []
85
+ let i = 0
86
+ while (i < ts.length) {
87
+ if (present.has(ts[i])) {
88
+ i++
89
+ continue
90
+ }
91
+ let j = i
92
+ while (j + 1 < ts.length && !present.has(ts[j + 1])) j++
93
+ out.push({ start: ts[Math.max(0, i - 1)], end: ts[Math.min(ts.length - 1, j + 1)], label: 'No sample: the source did not answer' })
94
+ i = j + 1
95
+ }
96
+ return out
97
+ }
98
+
99
+ /**
100
+ * The instances a per-instance chart should have a line for but does not. A
101
+ * chart draws only what reported, so a stopped standby would otherwise vanish
102
+ * from the very chart about it and leave the others reading calm.
103
+ */
104
+ export function unchartedInstances(expected: readonly string[] | undefined, charted: readonly string[]): string[] {
105
+ if (!expected?.length) return []
106
+ const seen = new Set(charted)
107
+ return expected.filter((p) => !seen.has(p))
108
+ }
109
+
110
+ /** Why each instance has no line; `why` reads after "No line for <pods>: ". */
111
+ function MissingLines({ pods, why }: { pods: string[]; why: (them: string) => string }) {
112
+ if (pods.length === 0) return null
113
+ return (
114
+ <p className={`mb-2 text-xs ${toneTextClass('degraded')}`}>
115
+ No line for {pods.join(', ')}: {why(pods.length === 1 ? 'it' : 'them')}.
116
+ </p>
117
+ )
118
+ }
119
+
120
+ const promMissingWhy = (them: string) => `Prometheus has no samples from ${them} in this range`
121
+
122
+ /** The instances a history chart should have a line for, when its lines are per Pod. */
123
+ function expectedLines(chart: CNPGHistoryChart, standbys?: string[], instancePods?: string[]): string[] | undefined {
124
+ if (chart.id === 'replicationLag') return standbys
125
+ return chart.seriesBy === 'pod' ? instancePods : undefined
126
+ }
127
+
128
+ function referenceLines(chart: CNPGHistoryChart): ReferenceLine[] | undefined {
129
+ const t = chart.thresholds ?? []
130
+ if (t.length === 0) return undefined
131
+ return t.map((x, i) => ({ value: x.value, label: x.label, kind: i === t.length - 1 ? 'limit' : 'request' }))
132
+ }
133
+
134
+ function ChartCard({ title, meta, children, footer }: { title: ReactNode; meta?: ReactNode; children: ReactNode; footer?: ReactNode }) {
135
+ return (
136
+ <section className="min-w-0 overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
137
+ <div className="flex items-center gap-2 border-b border-theme-border px-4 py-2.5">
138
+ <span className="text-sm font-semibold text-theme-text-primary">{title}</span>
139
+ {meta && <span className="ml-auto text-xs text-theme-text-tertiary">{meta}</span>}
140
+ </div>
141
+ <div className="p-3">{children}</div>
142
+ {footer && <div className="border-t border-theme-border px-4 py-2 text-xs text-theme-text-tertiary">{footer}</div>}
143
+ </section>
144
+ )
145
+ }
146
+
147
+ function stateText(chart: CNPGHistoryChart): string {
148
+ switch (chart.state) {
149
+ case 'empty':
150
+ return `Nothing to plot: ${chart.reason ?? 'no values in this range'}.`
151
+ case 'noSeries':
152
+ return chart.reason ? chart.reason.charAt(0).toUpperCase() + chart.reason.slice(1) + '.' : 'Not scraped.'
153
+ case 'denied':
154
+ return `No access: needs ${formatGrant(chart.grant) ?? 'more permissions'}.`
155
+ case 'notRead':
156
+ return `Not read: ${chart.reason ?? 'no source'}.`
157
+ default:
158
+ return chart.reason ?? 'Could not be read.'
159
+ }
160
+ }
161
+
162
+ function HistoryChartCard({
163
+ chart,
164
+ data,
165
+ selection,
166
+ onSelect,
167
+ expected,
168
+ }: {
169
+ chart: CNPGHistoryChart
170
+ data: CNPGClusterHistoryResponse
171
+ selection: ChartTimeRange | null
172
+ onSelect: (r: ChartTimeRange) => void
173
+ /** The Pods this chart should have a line for, when its lines are per Pod. */
174
+ expected?: string[]
175
+ }) {
176
+ const start = Date.parse(data.start ?? '') / 1000
177
+ const end = Date.parse(data.end ?? '') / 1000
178
+ const step = data.stepSeconds ?? 60
179
+ const series: TimeSeries[] = chart.series
180
+ const labels = series.map((s) => s.labels[chart.seriesBy] ?? chart.title)
181
+ const gaps = useMemo(() => historyGaps(series, start, end, step), [series, start, end, step])
182
+ // Omitted series may include the ones that look missing, so nothing is claimed then.
183
+ const missing = (chart.state === 'ok' || chart.state === 'empty') && !chart.omitted ? unchartedInstances(expected, labels) : []
184
+ const coverage = (
185
+ <Tooltip content="Evaluation steps in this range with at least one sample" position="top">
186
+ <span className="font-mono">
187
+ {chart.covered}/{chart.steps} samples
188
+ </span>
189
+ </Tooltip>
190
+ )
191
+ return (
192
+ <ChartCard
193
+ title={chart.title}
194
+ meta={chart.state === 'ok' ? coverage : undefined}
195
+ footer={
196
+ <>
197
+ Source: <span className="font-mono">{chart.source}</span>
198
+ {chart.id === 'pvcUsed' && data.pvcIsolation ? ` · ${data.pvcIsolation.note}` : ''}
199
+ {chart.omitted ? ` · ${chart.omitted} more series not shown` : ''}
200
+ {chart.state === 'ok' && gaps.length > 0 ? ' · hatched: no sample' : ''}
201
+ </>
202
+ }
203
+ >
204
+ {chart.state === 'ok' ? (
205
+ <>
206
+ <MissingLines pods={missing} why={promMissingWhy} />
207
+ <AreaChart
208
+ series={series}
209
+ seriesLabels={labels}
210
+ color={COLOR}
211
+ fillColor={FILL}
212
+ unit={chartUnit(chart.unit)}
213
+ referenceLines={referenceLines(chart)}
214
+ domain={{ start, end }}
215
+ stepSeconds={step}
216
+ layout="dashboard"
217
+ shadedRanges={gaps}
218
+ selection={selection}
219
+ onSelectRange={onSelect}
220
+ />
221
+ {series.length > 1 && (
222
+ <div className="mt-1.5">
223
+ <SeriesLegend series={series} color={COLOR} seriesLabels={labels} />
224
+ </div>
225
+ )}
226
+ </>
227
+ ) : (
228
+ <div
229
+ className={`flex h-24 items-center justify-center rounded-lg border border-dashed border-theme-border px-4 text-center text-sm ${missing.length > 0 ? toneTextClass('degraded') : 'text-theme-text-tertiary'}`}
230
+ >
231
+ {missing.length > 0 ? `No line for ${missing.join(', ')}: ${promMissingWhy(missing.length === 1 ? 'it' : 'them')}.` : stateText(chart)}
232
+ </div>
233
+ )}
234
+ </ChartCard>
235
+ )
236
+ }
237
+
238
+ function IntervalChip({ interval, onOpen, onClear }: { interval: ChartTimeRange; onOpen?: (target: CNPGIntervalTarget) => void; onClear: () => void }) {
239
+ return (
240
+ <div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent/40 bg-accent/10 px-3 py-1.5 text-sm">
241
+ <span className="font-medium text-theme-text-primary">
242
+ {clock(interval.start)} – {clock(interval.end)}
243
+ </span>
244
+ <span className="text-xs text-theme-text-tertiary">{span(interval.end - interval.start)} selected</span>
245
+ {onOpen && (
246
+ <>
247
+ <button type="button" onClick={() => onOpen('logs')} className="rounded-md px-2 py-0.5 text-xs font-medium text-accent-text hover:bg-theme-hover">
248
+ Logs in this interval
249
+ </button>
250
+ <button type="button" onClick={() => onOpen('activity')} className="rounded-md px-2 py-0.5 text-xs font-medium text-accent-text hover:bg-theme-hover">
251
+ Activity in this interval
252
+ </button>
253
+ </>
254
+ )}
255
+ <button type="button" aria-label="Clear the selected interval" onClick={onClear} className="ml-auto rounded p-0.5 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary">
256
+ <X className="h-3.5 w-3.5" />
257
+ </button>
258
+ </div>
259
+ )
260
+ }
261
+
262
+ /** The chart groups History filters by; topic tabs link to theirs. */
263
+ export type CNPGChartGroup = 'replication' | 'sessions' | 'throughput' | 'storage'
264
+
265
+ export const CNPG_CHART_GROUPS: { id: CNPGChartGroup | 'all'; label: string }[] = [
266
+ { id: 'all', label: 'All' },
267
+ { id: 'replication', label: 'Replication' },
268
+ { id: 'sessions', label: 'Sessions' },
269
+ { id: 'throughput', label: 'Throughput' },
270
+ { id: 'storage', label: 'Storage & WAL' },
271
+ ]
272
+
273
+ // Prometheus chart ids, as the server names them.
274
+ const HISTORY_CHART_GROUP: Record<string, CNPGChartGroup> = {
275
+ replicationLag: 'replication',
276
+ sessions: 'sessions',
277
+ waiting: 'sessions',
278
+ tps: 'throughput',
279
+ deadlocks: 'throughput',
280
+ tempBytes: 'throughput',
281
+ checkpoints: 'throughput',
282
+ walArchive: 'storage',
283
+ walSize: 'storage',
284
+ pvcUsed: 'storage',
285
+ databaseSize: 'storage',
286
+ }
287
+
288
+ function inGroup(group: CNPGChartGroup | undefined, chart: CNPGChartGroup | undefined): boolean {
289
+ return !group || chart === group
290
+ }
291
+
292
+ function BufferCharts({
293
+ samples,
294
+ selection,
295
+ onSelect,
296
+ instance,
297
+ picker,
298
+ group,
299
+ standbys,
300
+ instancePods,
301
+ }: {
302
+ samples: Sample[]
303
+ selection: ChartTimeRange | null
304
+ onSelect: (r: ChartTimeRange) => void
305
+ group?: CNPGChartGroup
306
+ /** The instance whose sessions-by-state chart is shown (the Performance picker's choice). */
307
+ instance?: string
308
+ picker?: ReactNode
309
+ standbys?: string[]
310
+ instancePods?: string[]
311
+ }) {
312
+ const recent = samples.slice(-SAMPLE_BUFFER_LIMIT)
313
+ const [dbPick, setDbPick] = useState<string[] | 'all' | null>(null)
314
+ const pods = useMemo(() => [...new Set(recent.flatMap((s) => Object.keys(s.replayLag)))].sort(), [recent])
315
+ const instances = useMemo(() => [...new Set(recent.flatMap((s) => Object.keys(s.instances ?? {})))].sort(), [recent])
316
+ const dbs = useMemo(() => chartedDatabases(recent, dbPick), [recent, dbPick])
317
+ if (recent.length < 2) {
318
+ return <div className="text-sm text-theme-text-tertiary">Collecting samples… These trends start when this page opens and cover up to the last hour it stays open.</div>
319
+ }
320
+ const domain = { start: recent[0].t / 1000, end: recent[recent.length - 1].t / 1000 }
321
+ const one = (label: string, pick: (s: Sample) => number | undefined): TimeSeries => ({
322
+ labels: { series: label },
323
+ dataPoints: recent.map((s) => ({ timestamp: s.t / 1000, value: pick(s) ?? null })),
324
+ })
325
+ const approximate = recent.some((s) => s.approximate)
326
+ const rateNote = approximate
327
+ ? 'approximate: the exporter publishes no cnpg_last_update_timestamp, so rates use the time Radar fetched each reading'
328
+ : 'rates between the exporter\'s query runs (cnpg_last_update_timestamp); a change of primary or a counter reset is a gap'
329
+ const sessionsOf = instance ?? instances[0]
330
+ const dbCapped = recent.some((s) => Object.keys(s.dbSizes ?? {}).length >= 200)
331
+ const charts: {
332
+ group: CNPGChartGroup
333
+ title: string
334
+ unit: string
335
+ series: TimeSeries[]
336
+ labels: string[]
337
+ source: string
338
+ thresholds?: ReferenceLine[]
339
+ rate?: boolean
340
+ control?: ReactNode
341
+ note?: string
342
+ missing?: { pods: string[]; why: (them: string) => string }
343
+ }[] = [
344
+ {
345
+ group: 'replication',
346
+ title: 'Replay lag per standby',
347
+ unit: 'seconds',
348
+ series: pods.map((p) => one(p, (s) => s.replayLag[p])),
349
+ labels: pods,
350
+ source: "the primary's pg_stat_replication",
351
+ missing: { pods: unchartedInstances(standbys, pods), why: (them) => `the primary has not listed ${them} as a connected standby since this page opened` },
352
+ thresholds: [
353
+ { value: 5, label: '5 s', kind: 'request' },
354
+ { value: 30, label: '30 s', kind: 'limit' },
355
+ ],
356
+ },
357
+ {
358
+ group: 'sessions',
359
+ title: 'Client sessions per instance',
360
+ unit: 'count',
361
+ series: instances.map((p) => one(p, (s) => s.instances?.[p]?.total)),
362
+ labels: instances,
363
+ source: 'cnpg_backends_total on each instance, platform sessions excluded',
364
+ missing: { pods: unchartedInstances(instancePods, instances), why: (them) => `no reading from ${them} since this page opened` },
365
+ },
366
+ {
367
+ group: 'sessions',
368
+ title: sessionsOf ? `Sessions by state (${sessionsOf})` : 'Sessions by state',
369
+ unit: 'count',
370
+ series: sessionsOf ? sessionStateSeries(recent, sessionsOf) : [],
371
+ labels: ['active', 'idle', 'idle in transaction', 'other'],
372
+ source: 'cnpg_backends_total by state on the instance picked above',
373
+ control: picker,
374
+ },
375
+ {
376
+ group: 'sessions',
377
+ title: 'Sessions waiting on locks',
378
+ unit: 'count',
379
+ series: instances.map((p) => one(p, (s) => s.instances?.[p]?.waiting)),
380
+ labels: instances,
381
+ source: 'cnpg_backends_waiting_total on each instance',
382
+ },
383
+ { group: 'throughput', title: 'Transactions per second (primary)', unit: '', series: [rateSeries(recent, 'commits'), rateSeries(recent, 'rollbacks')], labels: ['commits', 'rollbacks'], source: 'xact_commit / xact_rollback', rate: true },
384
+ { group: 'throughput', title: 'Cache hit ratio (primary)', unit: '%', series: [cacheHitSeries(recent)], labels: ['hit ratio'], source: 'blks_hit / (blks_hit + blks_read)', rate: true },
385
+ {
386
+ group: 'storage',
387
+ title: 'WAL archived / failed per minute',
388
+ unit: '',
389
+ series: [rateSeries(recent, 'archived', 60), rateSeries(recent, 'failed', 60)],
390
+ labels: ['archived', 'failed'],
391
+ source: 'pg_stat_archiver archived_count / failed_count on the primary',
392
+ rate: true,
393
+ },
394
+ { group: 'storage', title: 'WAL on disk (primary)', unit: 'bytes', series: [one('WAL', (s) => s.walBytes)], labels: ['WAL'], source: 'cnpg_collector_pg_wal{value="size"} on the primary' },
395
+ {
396
+ group: 'storage',
397
+ title: 'Database size (primary)',
398
+ unit: 'bytes',
399
+ series: dbs.shown.map((d) => one(d, (s) => s.dbSizes?.[d])),
400
+ labels: dbs.shown,
401
+ source: `pg_database size_bytes on the primary, ${dbPick === 'all' ? 'every database' : dbPick ? 'the databases picked' : 'the five largest'}`,
402
+ control: dbs.all.length > 5 ? <DatabasePicker all={dbs.all} shown={dbs.shown} pick={dbPick} onPick={setDbPick} /> : undefined,
403
+ note: dbCapped ? 'The exporter reports the 200 largest databases; smaller ones are not sampled.' : undefined,
404
+ },
405
+ {
406
+ group: 'throughput',
407
+ title: 'Checkpoints per minute (primary)',
408
+ unit: '',
409
+ series: [rateSeries(recent, 'checkpointsTimed', 60, 'timed'), rateSeries(recent, 'checkpointsRequested', 60, 'requested')],
410
+ labels: ['timed', 'requested'],
411
+ source: 'pg_stat_checkpointer (17+) or pg_stat_bgwriter checkpoints_timed / _req',
412
+ rate: true,
413
+ },
414
+ { group: 'throughput', title: 'Deadlocks per minute (primary)', unit: '', series: [rateSeries(recent, 'deadlocks', 60)], labels: ['deadlocks'], source: 'pg_stat_database deadlocks, all databases', rate: true },
415
+ { group: 'throughput', title: 'Temporary file writes (primary)', unit: 'bytes', series: [rateSeries(recent, 'tempBytes', 1, 'bytes/s')], labels: ['bytes/s'], source: 'pg_stat_database temp_bytes per second, all databases', rate: true },
416
+ ]
417
+ return (
418
+ <div className="grid gap-4 lg:grid-cols-2">
419
+ {charts.filter((c) => inGroup(group, c.group)).map((c) => {
420
+ const gaps = sampleGaps(c.series)
421
+ const hasValue = c.series.some((s) => s.dataPoints.some((p) => p.value != null))
422
+ return (
423
+ <ChartCard
424
+ key={c.title}
425
+ title={c.title}
426
+ meta={c.rate && approximate ? 'approximate' : undefined}
427
+ footer={
428
+ <>
429
+ Source: {c.source}, sampled by this page · {c.rate ? rateNote : 'gaps break the line, never zero'}
430
+ {c.note ? ` · ${c.note}` : ''}
431
+ </>
432
+ }
433
+ >
434
+ {c.control && <div className="mb-2">{c.control}</div>}
435
+ {c.missing && <MissingLines pods={c.missing.pods} why={c.missing.why} />}
436
+ {hasValue ? (
437
+ <>
438
+ <AreaChart
439
+ series={c.series}
440
+ seriesLabels={c.labels}
441
+ color={COLOR}
442
+ fillColor={FILL}
443
+ unit={c.unit}
444
+ referenceLines={c.thresholds}
445
+ domain={domain}
446
+ layout="dashboard"
447
+ shadedRanges={gaps}
448
+ selection={selection}
449
+ onSelectRange={onSelect}
450
+ />
451
+ {c.series.length > 1 && (
452
+ <div className="mt-1.5">
453
+ <SeriesLegend series={c.series} color={COLOR} seriesLabels={c.labels} />
454
+ </div>
455
+ )}
456
+ </>
457
+ ) : (
458
+ <div className="flex h-24 items-center justify-center rounded-lg border border-dashed border-theme-border px-4 text-center text-sm text-theme-text-tertiary">
459
+ {c.title.startsWith('Replay')
460
+ ? 'No standby reported by the primary since this page opened.'
461
+ : c.rate
462
+ ? 'Needs two exporter readings from the same primary since this page opened.'
463
+ : 'No sample since this page opened.'}
464
+ </div>
465
+ )}
466
+ </ChartCard>
467
+ )
468
+ })}
469
+ </div>
470
+ )
471
+ }
472
+
473
+ function DatabasePicker({ all, shown, pick, onPick }: { all: string[]; shown: string[]; pick: string[] | 'all' | null; onPick: (p: string[] | 'all' | null) => void }) {
474
+ const toggle = (d: string) => {
475
+ const next = shown.includes(d) ? shown.filter((x) => x !== d) : [...shown, d]
476
+ onPick(next.length === 0 ? null : next)
477
+ }
478
+ const chip = (on: boolean) =>
479
+ `rounded-md border px-1.5 py-0.5 font-mono text-[11px] ${on ? 'border-accent bg-accent-muted text-theme-text-primary' : 'border-theme-border text-theme-text-secondary hover:bg-theme-hover'}`
480
+ return (
481
+ <div className="flex flex-wrap items-center gap-1">
482
+ <button type="button" className={chip(pick === null)} onClick={() => onPick(null)}>
483
+ five largest
484
+ </button>
485
+ <button type="button" className={chip(pick === 'all')} onClick={() => onPick('all')}>
486
+ all {all.length}
487
+ </button>
488
+ {all.map((d) => (
489
+ <button key={d} type="button" aria-pressed={pick !== null && pick !== 'all' && shown.includes(d)} className={chip(pick !== null && pick !== 'all' && shown.includes(d))} onClick={() => toggle(d)}>
490
+ {d}
491
+ </button>
492
+ ))}
493
+ </div>
494
+ )
495
+ }
496
+
497
+ /**
498
+ * Performance › History: Prometheus range queries when Radar has
499
+ * Prometheus, otherwise the samples this page took since it opened. Selecting
500
+ * a time range on any chart carries both bounds to Logs and Activity.
501
+ */
502
+ export function CNPGTrends({
503
+ namespace,
504
+ name,
505
+ samples,
506
+ onOpenInterval,
507
+ instance,
508
+ picker,
509
+ samplingDenied,
510
+ group,
511
+ standbys,
512
+ instancePods,
513
+ }: {
514
+ namespace: string
515
+ name: string
516
+ samples: Sample[]
517
+ onOpenInterval?: (target: CNPGIntervalTarget, since: string, until: string) => void
518
+ /** The Performance instance picker's choice, for the per-instance sampled charts. */
519
+ instance?: string
520
+ picker?: ReactNode
521
+ /** The grant the caller lacks for in-page samples (get pods/proxy); no sample can ever arrive. */
522
+ samplingDenied?: string
523
+ /** Show one chart group (from `?charts=`); all when unset. */
524
+ group?: CNPGChartGroup
525
+ /** The instance Pods that are standbys now, and all instance Pods: a per-instance chart names any of them it has no line for. */
526
+ standbys?: string[]
527
+ instancePods?: string[]
528
+ }) {
529
+ const { range, setRange, interval, setSelected } = useTrendParams()
530
+ const q = useCNPGClusterHistory(namespace, name, range)
531
+ const data = q.data
532
+ const fromPrometheus = data?.source === 'prometheus' && data.state === 'ok'
533
+ // Interval selection needs a chart on the page: Prometheus history, or at least two in-page samples.
534
+ const charted = fromPrometheus || (!q.isLoading && !samplingDenied && samples.length >= 2)
535
+ const fallback = samplingDenied ? `In-page samples need ${samplingDenied} too.` : 'Below: samples since this page opened.'
536
+
537
+ const open = onOpenInterval && interval
538
+ ? (target: CNPGIntervalTarget) => onOpenInterval(target, new Date(interval.start * 1000).toISOString(), new Date(interval.end * 1000).toISOString())
539
+ : undefined
540
+
541
+ return (
542
+ <div className="space-y-3">
543
+ <div className="flex flex-wrap items-center gap-3">
544
+ {data?.source === 'prometheus' && (
545
+ <Segments
546
+ label="Trend range"
547
+ value={range}
548
+ onChange={(r) => setRange(r)}
549
+ options={CNPG_HISTORY_RANGES}
550
+ />
551
+ )}
552
+ <span className="text-xs text-theme-text-tertiary">
553
+ {fromPrometheus
554
+ ? `From Prometheus · one point every ${span(data.stepSeconds ?? 60)}${data.isolation ? ` · ${data.isolation.note}` : ''}`
555
+ : q.isLoading
556
+ ? 'Checking for Prometheus…'
557
+ : samplingDenied
558
+ ? 'No trend source readable'
559
+ : 'Sampled every 5 s since this page opened · gaps are hatched, never zero'}
560
+ </span>
561
+ {charted && <span className="text-xs text-theme-text-tertiary">Drag across a chart, or click a point, to select an interval.</span>}
562
+ </div>
563
+
564
+ {fromPrometheus && data.reason && <Notice>{data.reason}</Notice>}
565
+ {data?.source === 'none' && (
566
+ <Notice>
567
+ History needs Prometheus. {data.reason ?? 'Radar is not connected to one'}.{' '}
568
+ {samplingDenied ? fallback : 'These trends cover only the time since this page opened.'}
569
+ </Notice>
570
+ )}
571
+ {data?.source === 'prometheus' && data.state !== 'ok' && (
572
+ <Notice>
573
+ Prometheus history is not shown: {data.reason}. {fallback}
574
+ </Notice>
575
+ )}
576
+ {q.error && !data && (
577
+ <Notice>
578
+ History could not be loaded: {q.error instanceof Error ? q.error.message : 'unknown error'}. {fallback}
579
+ </Notice>
580
+ )}
581
+
582
+ <ChartGroupFilter group={group} />
583
+ {interval && <IntervalChip interval={interval} onOpen={open} onClear={() => setSelected(null)} />}
584
+
585
+ {fromPrometheus ? (
586
+ <div className="grid gap-4 lg:grid-cols-2">
587
+ {data.charts.filter((c) => inGroup(group, HISTORY_CHART_GROUP[c.id])).map((c) => (
588
+ <HistoryChartCard key={c.id} chart={c} data={data} selection={interval} onSelect={setSelected} expected={expectedLines(c, standbys, instancePods)} />
589
+ ))}
590
+ </div>
591
+ ) : (
592
+ !q.isLoading &&
593
+ !samplingDenied && (
594
+ <BufferCharts
595
+ samples={samples}
596
+ selection={interval}
597
+ onSelect={setSelected}
598
+ instance={instance}
599
+ picker={picker}
600
+ group={group}
601
+ standbys={standbys}
602
+ instancePods={instancePods}
603
+ />
604
+ )
605
+ )}
606
+ </div>
607
+ )
608
+ }
609
+
610
+ function ChartGroupFilter({ group }: { group?: CNPGChartGroup }) {
611
+ const [, setParams] = useSearchParams()
612
+ const location = useLocation()
613
+ return (
614
+ <Segments
615
+ label="Chart group"
616
+ value={group ?? 'all'}
617
+ onChange={(g) =>
618
+ setParams(
619
+ (prev) => {
620
+ const next = new URLSearchParams(prev)
621
+ if (g === 'all') next.delete('charts')
622
+ else next.set('charts', g)
623
+ return next
624
+ },
625
+ { replace: true, state: location.state },
626
+ )
627
+ }
628
+ options={CNPG_CHART_GROUPS}
629
+ />
630
+ )
631
+ }
632
+
633
+ /**
634
+ * The trend range and selected interval live in the URL (replaced, keeping
635
+ * the navigation state), so Back from Logs or Activity lands on the same view.
636
+ */
637
+ function useTrendParams() {
638
+ const [params, setParams] = useSearchParams()
639
+ const location = useLocation()
640
+ const range = CNPG_HISTORY_RANGES.find((r) => r.id === params.get('trendRange'))?.id ?? '1h'
641
+ const [a, b] = (params.get('trendSel') ?? '').split('-').map(Number)
642
+ const interval: ChartTimeRange | null = Number.isFinite(a) && Number.isFinite(b) && b > a ? { start: a, end: b } : null
643
+ const update = (mutate: (next: URLSearchParams) => void) =>
644
+ setParams(
645
+ (prev) => {
646
+ const next = new URLSearchParams(prev)
647
+ mutate(next)
648
+ return next
649
+ },
650
+ { replace: true, state: location.state },
651
+ )
652
+ return {
653
+ range,
654
+ interval,
655
+ setRange: (r: CNPGHistoryRange) =>
656
+ update((next) => {
657
+ if (r === '1h') next.delete('trendRange')
658
+ else next.set('trendRange', r)
659
+ next.delete('trendSel')
660
+ }),
661
+ setSelected: (sel: ChartTimeRange | null) =>
662
+ update((next) => {
663
+ if (sel) next.set('trendSel', `${Math.floor(sel.start)}-${Math.ceil(sel.end)}`)
664
+ else next.delete('trendSel')
665
+ }),
666
+ }
667
+ }
668
+
669
+ /** The interval a trend selection carried into Logs or Activity, from `?since=&until=`. */
670
+ export function useCNPGIntervalParams(): { since: string; until: string; clear: () => void } | null {
671
+ const [params, setParams] = useSearchParams()
672
+ const location = useLocation()
673
+ const since = params.get('since')
674
+ const until = params.get('until')
675
+ if (!since || !until || !(Date.parse(until) > Date.parse(since))) return null
676
+ return {
677
+ since,
678
+ until,
679
+ clear: () => {
680
+ const next = new URLSearchParams(params)
681
+ next.delete('since')
682
+ next.delete('until')
683
+ setParams(next, { replace: true, state: location.state })
684
+ },
685
+ }
686
+ }
687
+
688
+ export function CNPGIntervalBanner({ since, until, note, onClear }: { since: string; until: string; note: ReactNode; onClear: () => void }) {
689
+ const a = Date.parse(since) / 1000
690
+ const b = Date.parse(until) / 1000
691
+ return (
692
+ <div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent/40 bg-accent/10 px-3 py-1.5 text-sm">
693
+ <span className="font-medium text-theme-text-primary">
694
+ {new Date(a * 1000).toLocaleDateString()} {clock(a)} – {clock(b)}
695
+ </span>
696
+ <span className="text-xs text-theme-text-secondary">{note}</span>
697
+ <button type="button" onClick={onClear} className="ml-auto rounded-md px-2 py-0.5 text-xs font-medium text-accent-text hover:bg-theme-hover">
698
+ Show all
699
+ </button>
700
+ </div>
701
+ )
702
+ }