@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,107 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { createElement } from 'react'
3
+ import { buildCNPGFleet, CNPG_WORKSPACE_KEYS, type CNPGWorkspaceResponse } from '@skyhook-io/k8s-ui'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { CNPGDeclarations, cnpgDeclarationBlocks, type DeclItem } from './CNPGDeclarations'
6
+
7
+ const item = (kind: DeclItem['kind'], pgName: string, database: string | undefined, state: DeclItem['state'] = 'applied'): DeclItem => ({
8
+ key: `${kind}/${pgName}`,
9
+ kind,
10
+ pgName,
11
+ database,
12
+ state,
13
+ resource: { kind, namespace: 'pg', name: pgName } as DeclItem['resource'],
14
+ isField: false,
15
+ })
16
+
17
+ const items = [
18
+ item('Database', 'demo_app', 'demo_app'),
19
+ item('Publication', 'demo_app_pub', 'demo_app'),
20
+ item('Subscription', 'demo_app_sub', 'demo_app', 'failed'),
21
+ item('Database', 'demo_reporting', 'demo_reporting'),
22
+ item('Publication', 'missing_pub', 'no_such_database', 'failed'),
23
+ item('DatabaseRole', 'reader', undefined),
24
+ ]
25
+
26
+ describe('cnpgDeclarationBlocks', () => {
27
+ it('nests what lives in each database under it, undeclared databases after the declared ones, roles last', () => {
28
+ const blocks = cnpgDeclarationBlocks(items, null)
29
+ expect(blocks.map((b) => [b.database, b.head?.pgName, b.children.map((c) => c.pgName)])).toEqual([
30
+ ['demo_app', 'demo_app', ['demo_app_pub', 'demo_app_sub']],
31
+ ['demo_reporting', 'demo_reporting', []],
32
+ ['no_such_database', undefined, ['missing_pub']],
33
+ [undefined, undefined, ['reader']],
34
+ ])
35
+ })
36
+
37
+ it('keeps a database as context for a matching item in it, and drops what does not match', () => {
38
+ const blocks = cnpgDeclarationBlocks(items, 'failed')
39
+ expect(blocks.map((b) => [b.database, b.head?.pgName, b.children.map((c) => c.pgName)])).toEqual([
40
+ ['demo_app', 'demo_app', ['demo_app_sub']],
41
+ ['no_such_database', undefined, ['missing_pub']],
42
+ ])
43
+ })
44
+ })
45
+
46
+ vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'test' } }) }))
47
+
48
+ const G = 'postgresql.cnpg.io/v1'
49
+ const declaration = (cluster: string, name: string, applied?: boolean, stale = false) => ({ apiVersion: G, kind: 'Database', metadata: { name, namespace: 'pg', generation: 2 }, spec: { name, cluster: { name: cluster } }, status: { applied, observedGeneration: stale ? 1 : 2 } })
50
+
51
+ function renderDeclarations(query: string, pgName?: string, partial: false | 'pg' | 'other' = false) {
52
+ const data: CNPGWorkspaceResponse = {
53
+ installed: true, context: 'test', namespaces: null,
54
+ coverage: Object.fromEntries(CNPG_WORKSPACE_KEYS.map((k) => [k, { state: 'full' }])),
55
+ objects: {
56
+ clusters: ['a', 'b'].map((name) => ({ apiVersion: G, kind: 'Cluster', metadata: { name, namespace: 'pg' }, spec: {} })),
57
+ databases: [declaration('a', 'failed-a', false), declaration('a', 'stale-a', true, true), declaration('b', 'failed-b1', false), declaration('b', 'failed-b2', false), declaration('b', 'pending-b')],
58
+ }, issues: [], audit: [], backupsOmitted: 0,
59
+ }
60
+ if (partial) data.coverage.publications = { state: 'partial', allowedNamespaces: [partial] }
61
+ if (pgName) data.objects.databases![0].spec.name = pgName
62
+ return renderToStaticMarkup(createElement(CNPGDeclarations, {
63
+ data, fleet: buildCNPGFleet(data), namespaces: [], searchParams: new URLSearchParams(query), onSetParams: () => {}, onInspect: () => {}, inspected: null, onClearNamespaces: () => {},
64
+ }))
65
+ }
66
+
67
+ it('counts the selected cluster’s actual items before applying the state filter', () => {
68
+ const html = renderDeclarations('cluster=pg/a&show=failed')
69
+ expect(html).toMatch(/Not applied[^<]*<[^>]*>1</)
70
+ expect(html).toMatch(/Pending[^<]*<[^>]*>1</)
71
+ expect(html).toContain('failed-a')
72
+ expect(html).not.toContain('failed-b1')
73
+ expect(html).not.toContain('stale-a')
74
+ })
75
+
76
+ it('shows stale success as pending in the declaration list', () => {
77
+ const html = renderDeclarations('cluster=pg/a&show=pending')
78
+ expect(html).toContain('stale-a')
79
+ expect(html).toContain('awaiting the operator for the current spec')
80
+ expect(html).not.toContain('failed-a')
81
+ })
82
+
83
+ it('offers inspection with the Kubernetes object identity beside the database name', () => {
84
+ const html = renderDeclarations('cluster=pg/a', 'appdb')
85
+ expect(html).toContain('appdb')
86
+ expect(html).toContain('Inspect →')
87
+ expect(html).toContain('Database failed-a')
88
+ expect(html).toContain('role="button"')
89
+ expect(html).toContain('tabindex="0"')
90
+ })
91
+
92
+ it('qualifies declaration filter counts over partial coverage including zero matches', () => {
93
+ expect(renderDeclarations('cluster=pg/a', undefined, 'other')).toMatch(/Not applied[^<]*<[^>]*>≥1</)
94
+ expect(renderDeclarations('cluster=pg/a', undefined, 'other')).toMatch(/Pending[^<]*<[^>]*>≥1</)
95
+ const empty = renderDeclarations('cluster=pg/missing', undefined, 'other')
96
+ expect(empty).toMatch(/Not applied[^<]*<[^>]*>Unknown</)
97
+ expect(empty).toMatch(/Pending[^<]*<[^>]*>Unknown</)
98
+ expect(renderDeclarations('cluster=pg/missing')).toMatch(/Not applied[^<]*<[^>]*>0</)
99
+ })
100
+
101
+ it('keeps filtered counts exact in a fully read namespace even when another namespace is unread', () => {
102
+ const html = renderDeclarations('cluster=pg/a', undefined, 'pg')
103
+ expect(html).toMatch(/Not applied[^<]*<[^>]*>1</)
104
+ expect(html).toMatch(/Pending[^<]*<[^>]*>1</)
105
+ expect(renderDeclarations('cluster=pg/missing', undefined, 'pg')).toMatch(/Not applied[^<]*<[^>]*>0</)
106
+ expect(renderDeclarations('', undefined, 'pg')).toMatch(/Not applied[^<]*<[^>]*>≥3</)
107
+ })
@@ -1,31 +1,39 @@
1
1
  import { useMemo, type ReactNode } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
  import { AlertTriangle } from 'lucide-react'
4
- import { Badge, cnpgGitOpsSource, isApiGroup, toneTextClass, type CNPGFleetRow } from '@skyhook-io/k8s-ui'
5
- import type { SelectedResource } from '../../types'
6
4
  import {
7
- CNPGWorkspaceHeader,
8
- CoverageNotice,
9
- FilterChips,
10
- ScreenBody,
11
- Segments,
12
- Sub,
13
- clusterResource,
14
- cnpgResource,
15
- coverageEmpty,
16
- worstCoverage,
17
- namespaceChip,
18
- type CNPGScreenProps,
19
- } from './shared'
20
- import { sameResource } from './routes'
5
+ Badge,
6
+ CNPGLogicalPathView,
7
+ cnpgDatabaseRoleFacts,
8
+ coverageReadable,
9
+ cnpgRoleState,
10
+ cnpgDatabaseRoleMeta,
11
+ cnpgManagedBy,
12
+ managedByLabel,
13
+ type CNPGWorkspaceResponse,
14
+ cnpgLogicalPaths,
15
+ cnpgLogicalSlotFact,
16
+ isApiGroup,
17
+ refToSelectedResource,
18
+ relationUnavailable,
19
+ toneTextClass,
20
+ type CNPGFleetRow,
21
+ type CNPGLogicalPath,
22
+ } from '@skyhook-io/k8s-ui'
23
+ import { useCNPGPublisherSlots } from './logicalSlots'
24
+ import type { SelectedResource } from '../../types'
25
+ import { CNPGWorkspaceHeader, CoverageNotice, clusterResource, cnpgResource, coverageEmpty, worstCoverage, type CNPGScreenProps } from './shared'
26
+ import { FilterChips, namespaceChip, RefreshFailedNotice, ScreenBody, Segments, Sub } from '../workspace/layout'
27
+ import { sameSelectedResource } from '../../utils/drawer-trail'
21
28
 
22
29
  type State = 'applied' | 'failed' | 'pending'
23
30
 
24
- interface DeclItem {
31
+ export interface DeclItem {
25
32
  key: string
26
- kind: 'Database' | 'Publication' | 'Subscription' | 'Managed role'
33
+ kind: 'Database' | 'Publication' | 'Subscription' | 'Managed role' | 'DatabaseRole'
27
34
  pgName: string
28
- indent: boolean
35
+ /** The PostgreSQL database the item lives in (a Database's own name); unset for roles, which are cluster-wide. */
36
+ database?: string
29
37
  state: State
30
38
  meta?: string
31
39
  error?: string
@@ -41,17 +49,48 @@ interface DeclGroup {
41
49
  items: DeclItem[]
42
50
  }
43
51
 
44
- function stateOf(obj: any): State {
45
- const applied = obj?.status?.applied
46
- if (applied === true) return 'applied'
47
- if (applied === false) return 'failed'
48
- return 'pending'
52
+ /** A database and the Publications and Subscriptions in it; without `database`, the cluster's roles. */
53
+ export interface DeclBlock {
54
+ database?: string
55
+ /** The Database declaration; absent when the database is not declared (the bootstrap database, or one that does not exist). */
56
+ head?: DeclItem
57
+ children: DeclItem[]
58
+ }
59
+
60
+ /**
61
+ * Groups a cluster's declarations for display: each database with what lives
62
+ * in it, declared databases first, then roles. A filter keeps a database's
63
+ * row as context for a matching Publication or Subscription in it.
64
+ */
65
+ export function cnpgDeclarationBlocks(items: DeclItem[], show: State | null): DeclBlock[] {
66
+ const match = (i: DeclItem) => !show || i.state === show
67
+ const byDatabase = new Map<string, DeclBlock>()
68
+ const roles: DeclItem[] = []
69
+ for (const i of items) {
70
+ if (i.database === undefined) {
71
+ if (match(i)) roles.push(i)
72
+ continue
73
+ }
74
+ let b = byDatabase.get(i.database)
75
+ if (!b) {
76
+ b = { database: i.database, children: [] }
77
+ byDatabase.set(i.database, b)
78
+ }
79
+ if (i.kind === 'Database' && !b.head) b.head = i
80
+ else b.children.push(i)
81
+ }
82
+ const out: DeclBlock[] = []
83
+ const blocks = [...byDatabase.values()].sort((a, b) => (a.head ? 0 : 1) - (b.head ? 0 : 1))
84
+ for (const b of blocks) {
85
+ const children = b.children.filter(match)
86
+ if ((b.head && match(b.head)) || children.length > 0) out.push({ ...b, children })
87
+ }
88
+ if (roles.length > 0) out.push({ children: roles })
89
+ return out
49
90
  }
50
91
 
51
- function gitopsSource(obj: any): string | undefined {
52
- const src = cnpgGitOpsSource(obj)
53
- if (!src) return undefined
54
- return `${src.tool === 'argocd' ? 'Argo CD' : 'Flux'} ${src.name}`
92
+ function gitopsSource(managedBy: CNPGWorkspaceResponse['managedBy'], obj: any): string | undefined {
93
+ return managedByLabel(cnpgManagedBy({ managedBy }, obj))
55
94
  }
56
95
 
57
96
  const STATE_BADGE: Record<State, { severity: 'success' | 'warning' | 'neutral'; text: string }> = {
@@ -73,7 +112,7 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP
73
112
  const clusterFilter = searchParams.get('cluster')
74
113
  const show = (searchParams.get('show') as 'failed' | 'pending' | null) ?? null
75
114
 
76
- const groups = useMemo<DeclGroup[]>(() => {
115
+ const scopedGroups = useMemo(() => {
77
116
  const byCluster = new Map<string, DeclGroup>()
78
117
  const group = (ns: string, cluster: string) => {
79
118
  const k = `${ns}/${cluster}`
@@ -93,16 +132,16 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP
93
132
  const ns = d.metadata?.namespace ?? ''
94
133
  const cluster = d.spec?.cluster?.name ?? '(no cluster)'
95
134
  const g = group(ns, cluster)
96
- const st = stateOf(d)
135
+ const st = cnpgRoleState(d)
97
136
  g.items.push({
98
137
  key: `db/${ns}/${d.metadata?.name}`,
99
138
  kind: 'Database',
100
139
  pgName: d.spec?.name ?? d.metadata?.name,
101
- indent: false,
140
+ database: d.spec?.name ?? d.metadata?.name,
102
141
  state: st,
103
142
  meta: d.spec?.owner ? `owner ${d.spec.owner}` : undefined,
104
143
  error: st === 'failed' ? d.status?.message : undefined,
105
- source: gitopsSource(d),
144
+ source: gitopsSource(data.managedBy, d),
106
145
  resource: cnpgResource('databases', ns, d.metadata?.name),
107
146
  isField: false,
108
147
  })
@@ -110,19 +149,16 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP
110
149
  for (const p of list) {
111
150
  if (p.metadata?.namespace !== ns || p.spec?.cluster?.name !== d.spec?.cluster?.name || p.spec?.dbname !== d.spec?.name) continue
112
151
  placed.add(p)
113
- const pst = stateOf(p)
152
+ const pst = cnpgRoleState(p)
114
153
  g.items.push({
115
154
  key: `${kind}/${ns}/${p.metadata?.name}`,
116
155
  kind,
117
156
  pgName: p.spec?.name ?? p.metadata?.name,
118
- indent: true,
157
+ database: d.spec?.name ?? d.metadata?.name,
119
158
  state: pst,
120
- meta:
121
- kind === 'Publication'
122
- ? p.spec?.target?.allTables ? 'all tables' : 'selected objects'
123
- : `from ${p.spec?.publicationName ?? '?'} on ${p.spec?.externalClusterName ?? '?'}`,
159
+ meta: logicalMeta(kind, p),
124
160
  error: pst === 'failed' ? p.status?.message : undefined,
125
- source: gitopsSource(p),
161
+ source: gitopsSource(data.managedBy, p),
126
162
  resource: cnpgResource(kind === 'Publication' ? 'publications' : 'subscriptions', ns, p.metadata?.name),
127
163
  isField: false,
128
164
  })
@@ -134,21 +170,38 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP
134
170
  if (placed.has(p)) continue
135
171
  const ns = p.metadata?.namespace ?? ''
136
172
  const g = group(ns, p.spec?.cluster?.name ?? '(no cluster)')
137
- const pst = stateOf(p)
173
+ const pst = cnpgRoleState(p)
138
174
  g.items.push({
139
175
  key: `${kind}/${ns}/${p.metadata?.name}`,
140
176
  kind,
141
177
  pgName: p.spec?.name ?? p.metadata?.name,
142
- indent: false,
178
+ database: p.spec?.dbname ?? '(no database named)',
143
179
  state: pst,
144
- meta: p.spec?.dbname ? `database ${p.spec.dbname}` : undefined,
180
+ meta: logicalMeta(kind, p),
145
181
  error: pst === 'failed' ? p.status?.message : undefined,
146
- source: gitopsSource(p),
182
+ source: gitopsSource(data.managedBy, p),
147
183
  resource: cnpgResource(kind === 'Publication' ? 'publications' : 'subscriptions', ns, p.metadata?.name),
148
184
  isField: false,
149
185
  })
150
186
  }
151
187
  }
188
+ for (const r of (data.objects.databaseRoles ?? []).filter(valid)) {
189
+ const ns = r.metadata?.namespace ?? ''
190
+ const clusterName = r.spec?.cluster?.name ?? '(no cluster)'
191
+ const g = group(ns, clusterName)
192
+ const f = cnpgDatabaseRoleFacts(r, g.row?.cluster ?? null)
193
+ g.items.push({
194
+ key: `databaserole/${ns}/${r.metadata?.name}`,
195
+ kind: 'DatabaseRole',
196
+ pgName: f.pgName,
197
+ state: f.state,
198
+ meta: cnpgDatabaseRoleMeta(f),
199
+ error: f.state === 'failed' ? f.message : undefined,
200
+ source: gitopsSource(data.managedBy, r),
201
+ resource: cnpgResource('databaseroles', ns, r.metadata?.name),
202
+ isField: false,
203
+ })
204
+ }
152
205
  for (const row of fleet.rows) {
153
206
  const roles: any[] = Array.isArray(row.cluster?.spec?.managed?.roles) ? row.cluster.spec.managed.roles : []
154
207
  if (roles.length === 0) continue
@@ -160,7 +213,6 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP
160
213
  key: `role/${row.namespace}/${row.name}/${r.name}`,
161
214
  kind: 'Managed role',
162
215
  pgName: r.name,
163
- indent: false,
164
216
  state: rs.state,
165
217
  meta: r.ensure === 'absent' ? 'ensure absent' : 'spec.managed.roles',
166
218
  error: rs.error,
@@ -172,25 +224,49 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP
172
224
  }
173
225
  return [...byCluster.values()]
174
226
  .filter((g) => !clusterFilter || `${g.namespace}/${g.cluster}` === clusterFilter)
175
- .map((g) => ({ ...g, items: show ? g.items.filter((i) => i.state === show) : g.items }))
176
- .filter((g) => g.items.length > 0)
227
+ }, [data.objects.databases, data.objects.publications, data.objects.subscriptions, data.objects.databaseRoles, data.managedBy, fleet.rows, clusterFilter])
228
+
229
+ const groups = useMemo(() =>
230
+ scopedGroups
231
+ .map((g) => ({ ...g, matched: show ? g.items.filter((i) => i.state === show) : g.items, blocks: cnpgDeclarationBlocks(g.items, show) }))
232
+ .filter((g) => g.matched.length > 0)
177
233
  .sort((a, b) => {
178
- const fa = a.items.some((i) => i.state === 'failed') ? 0 : 1
179
- const fb = b.items.some((i) => i.state === 'failed') ? 0 : 1
234
+ const fa = a.matched.some((i) => i.state === 'failed') ? 0 : 1
235
+ const fb = b.matched.some((i) => i.state === 'failed') ? 0 : 1
180
236
  return fa - fb || a.namespace.localeCompare(b.namespace) || a.cluster.localeCompare(b.cluster)
181
- })
182
- }, [data.objects.databases, data.objects.publications, data.objects.subscriptions, fleet.rows, clusterFilter, show])
237
+ }),
238
+ [scopedGroups, show],
239
+ )
183
240
 
184
241
  const totals = useMemo(() => {
185
242
  let failed = 0
186
243
  let pending = 0
187
- for (const r of fleet.rows) {
188
- failed += r.declarations.failed
189
- pending += r.declarations.pending
244
+ for (const g of scopedGroups) {
245
+ failed += g.items.filter((i) => i.state === 'failed').length
246
+ pending += g.items.filter((i) => i.state === 'pending').length
190
247
  }
191
248
  return { failed, pending }
192
- }, [fleet.rows])
193
- const declCoverage = worstCoverage(data.coverage.databases, data.coverage.publications, data.coverage.subscriptions)
249
+ }, [scopedGroups])
250
+ const logicalPaths = useMemo(() => {
251
+ const valid = (o: any) => isApiGroup(o?.apiVersion, 'postgresql.cnpg.io')
252
+ const paths = cnpgLogicalPaths(
253
+ (data.objects.subscriptions ?? []).filter(valid),
254
+ data.objects.clusters ?? [],
255
+ (data.objects.publications ?? []).filter(valid),
256
+ data.objects.poolers ?? [],
257
+ (ns) => relationUnavailable(data, 'publications', ns, 'Publications'),
258
+ )
259
+ if (!clusterFilter) return paths
260
+ return paths.filter(
261
+ (p) =>
262
+ `${p.subscription.namespace}/${p.subscription.cluster}` === clusterFilter ||
263
+ (p.publisher.kind === 'cluster' && `${p.publisher.namespace}/${p.publisher.name}` === clusterFilter),
264
+ )
265
+ }, [data, clusterFilter])
266
+
267
+ const declarationCoverage = [data.coverage.clusters, data.coverage.databases, data.coverage.publications, data.coverage.subscriptions, data.coverage.databaseRoles]
268
+ const declCoverage = worstCoverage(...declarationCoverage)
269
+ const countsComplete = declarationCoverage.every((cov) => cov && (cov.state === 'notInstalled' || coverageReadable(cov, clusterFilter?.split('/')[0])))
194
270
 
195
271
  const chips = [
196
272
  ...(clusterFilter ? [{ label: `Cluster: ${clusterFilter}`, onClear: () => onSetParams({ cluster: null }) }] : []),
@@ -201,10 +277,10 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP
201
277
  <div className="flex min-h-0 flex-1 flex-col">
202
278
  <CNPGWorkspaceHeader
203
279
  title="Declarations"
204
- subtitle="Databases, managed roles, publications and subscriptions, by PostgreSQL cluster. Declared is not the same as reconciled."
280
+ subtitle="Databases, roles, publications and subscriptions, by PostgreSQL cluster. Declared is not the same as reconciled."
205
281
  />
206
282
  <ScreenBody>
207
- <CoverageNotice fleet={fleet} data={data} />
283
+ <CoverageNotice fleet={fleet} data={data} kinds={['clusters', 'databases', 'databaseRoles', 'publications', 'subscriptions', 'poolers']} />
208
284
  <div className="flex flex-wrap items-center gap-2">
209
285
  <Segments
210
286
  label="Declarations"
@@ -212,8 +288,8 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP
212
288
  onChange={(id) => onSetParams({ show: id === 'all' ? null : id })}
213
289
  options={[
214
290
  { id: 'all', label: 'All declarations' },
215
- { id: 'failed', label: 'Not applied', count: totals.failed },
216
- { id: 'pending', label: 'Pending', count: totals.pending },
291
+ { id: 'failed', label: 'Not applied', count: countsComplete ? totals.failed : totals.failed > 0 ? `≥${totals.failed}` : 'Unknown' },
292
+ { id: 'pending', label: 'Pending', count: countsComplete ? totals.pending : totals.pending > 0 ? `≥${totals.pending}` : 'Unknown' },
217
293
  ]}
218
294
  />
219
295
  </div>
@@ -226,11 +302,13 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP
226
302
  : show === 'pending'
227
303
  ? 'No declaration in this scope is waiting for the operator.'
228
304
  : coverageEmpty(declCoverage, 'declarations')}
229
- {show && declCoverage?.state !== 'full' ? ' Some declarations are not readable with your access.' : ''}
305
+ {show && !countsComplete ? ' Some declarations are not readable with your access.' : ''}
230
306
  </div>
231
307
  ) : (
232
308
  groups.map((g) => {
233
- const failed = g.items.filter((i) => i.state === 'failed').length
309
+ const failed = g.matched.filter((i) => i.state === 'failed').length
310
+ const noSources = g.items.every((i) => !i.source)
311
+ const hasDatabases = g.blocks.some((b) => b.database !== undefined)
234
312
  return (
235
313
  <section key={`${g.namespace}/${g.cluster}`} className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
236
314
  <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-theme-border px-4 py-2.5">
@@ -245,31 +323,105 @@ export function CNPGDeclarations({ data, fleet, namespaces, searchParams, onSetP
245
323
  )}
246
324
  <span className="text-xs text-theme-text-tertiary">{g.namespace}</span>
247
325
  <span className={clsx('text-xs', failed > 0 ? toneTextClass('degraded') : 'text-theme-text-tertiary')}>
248
- {g.items.length} {g.items.length === 1 ? 'declaration' : 'declarations'}
326
+ {g.matched.length} {g.matched.length === 1 ? 'declaration' : 'declarations'}
249
327
  {failed > 0 ? ` · ${failed} not reconciled` : ''}
250
328
  {!g.row ? ' · target cluster not visible' : ''}
251
329
  </span>
330
+ {noSources && <span className="text-xs text-theme-text-tertiary">no GitOps source recorded on any of them</span>}
252
331
  </div>
253
332
  <div className="table-divide-subtle">
254
- {g.items.map((i) => (
255
- <DeclarationRow
256
- key={i.key}
257
- item={i}
258
- active={!i.isField && sameResource(inspected, i.resource)}
259
- onInspect={() => onInspect(i.resource)}
260
- />
333
+ {g.blocks.map((b) => (
334
+ <div key={b.database === undefined ? 'roles' : `db/${b.database}`}>
335
+ {b.database === undefined ? (
336
+ hasDatabases && <div className="px-4 pb-1 pt-2.5 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">Roles</div>
337
+ ) : b.head ? (
338
+ <DeclarationRow
339
+ item={b.head}
340
+ sourceStated={noSources}
341
+ active={sameSelectedResource(inspected, b.head.resource)}
342
+ onInspect={() => onInspect(b.head!.resource)}
343
+ />
344
+ ) : (
345
+ <div className="flex flex-wrap items-center gap-2 px-4 pb-1 pt-2.5 text-sm">
346
+ <span className="text-xs text-theme-text-tertiary">Database</span>
347
+ <span className="font-mono text-theme-text-secondary">{b.database}</span>
348
+ <span className="text-xs text-theme-text-tertiary">no Database declaration</span>
349
+ </div>
350
+ )}
351
+ {b.children.map((i) => (
352
+ <DeclarationRow
353
+ key={i.key}
354
+ item={i}
355
+ nested={b.database !== undefined}
356
+ sourceStated={noSources}
357
+ active={!i.isField && sameSelectedResource(inspected, i.resource)}
358
+ onInspect={() => onInspect(i.resource)}
359
+ />
360
+ ))}
361
+ </div>
261
362
  ))}
262
363
  </div>
263
364
  </section>
264
365
  )
265
366
  })
266
367
  )}
368
+
369
+ {!show && logicalPaths.length > 0 && (
370
+ <section className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
371
+ <div className="flex flex-wrap items-baseline gap-x-3 border-b border-theme-border px-4 py-2.5">
372
+ <span className="text-sm font-semibold text-theme-text-primary">Logical replication</span>
373
+ <span className="text-xs text-theme-text-tertiary">
374
+ Each Subscription to its publication and the slot the publisher keeps for it · Subscriptions created in SQL are not listed
375
+ </span>
376
+ </div>
377
+ <div className="table-divide-subtle">
378
+ {logicalPaths.map((p) => (
379
+ <LogicalPathRow key={`${p.subscription.namespace}/${p.subscription.name}`} path={p} onInspect={onInspect} />
380
+ ))}
381
+ </div>
382
+ </section>
383
+ )}
267
384
  </ScreenBody>
268
385
  </div>
269
386
  )
270
387
  }
271
388
 
272
- function DeclarationRow({ item, active, onInspect }: { item: DeclItem; active: boolean; onInspect: () => void }) {
389
+ function LogicalPathRow({ path, onInspect }: { path: CNPGLogicalPath; onInspect: CNPGScreenProps['onInspect'] }) {
390
+ const { observed, query } = useCNPGPublisherSlots(path.publisher)
391
+ return (
392
+ <div className="px-4 py-3">
393
+ <CNPGLogicalPathView
394
+ path={path}
395
+ slot={cnpgLogicalSlotFact(path, observed)}
396
+ notice={<RefreshFailedNotice queries={[query]} />}
397
+ onNavigate={(ref) => onInspect(refToSelectedResource(ref))}
398
+ compact
399
+ />
400
+ </div>
401
+ )
402
+ }
403
+
404
+ function logicalMeta(kind: 'Publication' | 'Subscription', p: { spec?: { target?: { allTables?: boolean }; publicationName?: string; externalClusterName?: string } }): string {
405
+ if (kind === 'Publication') return p.spec?.target?.allTables ? 'all tables' : 'selected objects'
406
+ return `from ${p.spec?.publicationName ?? 'an unnamed publication'} on ${p.spec?.externalClusterName ?? 'an unnamed external cluster'}`
407
+ }
408
+
409
+ // `sourceStated`: the cluster header already says none of its rows records a
410
+ // GitOps source, so the row has no source column. `nested`: it lives in the
411
+ // database above it; a guide line joins it to that row.
412
+ function DeclarationRow({
413
+ item,
414
+ active,
415
+ onInspect,
416
+ sourceStated,
417
+ nested,
418
+ }: {
419
+ item: DeclItem
420
+ active: boolean
421
+ onInspect: () => void
422
+ sourceStated?: boolean
423
+ nested?: boolean
424
+ }) {
273
425
  const badge = STATE_BADGE[item.state]
274
426
  let detail: ReactNode = null
275
427
  if (item.error) {
@@ -285,27 +437,33 @@ function DeclarationRow({ item, active, onInspect }: { item: DeclItem; active: b
285
437
  role="button"
286
438
  tabIndex={0}
287
439
  onClick={onInspect}
288
- onKeyDown={(e) => { if (e.key === 'Enter') onInspect() }}
440
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onInspect() } }}
289
441
  className={clsx(
290
- 'grid cursor-pointer grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1fr)] gap-x-4 px-4 py-2.5 text-sm transition-colors hover:bg-theme-hover/50',
442
+ 'relative grid cursor-pointer gap-x-4 px-4 py-2.5 text-sm transition-colors hover:bg-theme-hover/50',
443
+ sourceStated ? 'grid-cols-[minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1fr)]',
291
444
  active && 'selection',
292
445
  )}
293
446
  >
294
- <div className={clsx('min-w-0', item.indent && 'pl-6')}>
447
+ {nested && <span aria-hidden className="absolute bottom-0 left-[1.6rem] top-0 w-px bg-theme-border" />}
448
+ <div className={clsx('min-w-0', nested && 'pl-6')}>
295
449
  <div className="flex flex-wrap items-center gap-2">
296
- <span className="text-xs text-theme-text-tertiary">{item.kind}</span>
297
450
  <span className="font-mono font-medium text-theme-text-primary">{item.pgName}</span>
451
+ <span className="text-xs text-theme-text-tertiary">· {item.kind}{!item.isField && ` ${item.resource.name}`}</span>
298
452
  {item.meta && <span className="text-xs text-theme-text-tertiary">{item.meta}</span>}
299
453
  </div>
300
454
  {detail}
301
455
  </div>
302
- <div>
456
+ <div className={clsx(sourceStated && 'text-right')}>
303
457
  <Badge severity={badge.severity} size="sm">{badge.text}</Badge>
458
+ <div className="mt-1 text-xs text-accent-text">Inspect →</div>
459
+ {item.state === 'pending' && <Sub>awaiting the operator for the current spec</Sub>}
304
460
  {item.isField && <Sub>field of the Cluster</Sub>}
305
461
  </div>
306
- <div className="min-w-0 text-xs text-theme-text-secondary break-words">
307
- {item.source ?? <span className="text-theme-text-tertiary">GitOps source not recorded</span>}
308
- </div>
462
+ {!sourceStated && (
463
+ <div className="min-w-0 text-xs text-theme-text-secondary break-words">
464
+ {item.source ?? <span className="text-theme-text-tertiary">GitOps source not recorded</span>}
465
+ </div>
466
+ )}
309
467
  </div>
310
468
  )
311
469
  }
@@ -0,0 +1,62 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { MemoryRouter } from 'react-router-dom'
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import { expect, it, vi } from 'vitest'
5
+ import { CNPGDetailPage } from './CNPGDetailPage'
6
+ const state = vi.hoisted(() => ({ extraTabs: undefined as any, support: 'supported' as 'supported' | 'unsupported' | 'unknown', refresh: undefined as undefined | (() => Promise<unknown>), refetch: vi.fn(async () => {}), specTab: undefined as any, renderStatusBadge: undefined as undefined | ((resource: any) => any) }))
7
+ vi.mock('./refresh', () => ({ refetchCNPGDetail: state.refetch }))
8
+ vi.mock('../workload/WorkloadView', () => ({ WorkloadView: ({ onRefresh, specTab, renderStatusBadge, extraTabs }: { onRefresh?: () => Promise<unknown>; extraTabs?: any; specTab?: any; renderStatusBadge?: (resource: any) => any }) => { state.extraTabs = extraTabs; state.refresh = onRefresh; state.specTab = specTab; state.renderStatusBadge = renderStatusBadge; return <div>detail</div> } }))
9
+ vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'test' } }) }))
10
+ vi.mock('../../api/client', () => ({ useRadarFeature: () => ({ support: state.support }) }))
11
+ vi.mock('./CNPGOperatorBanner', () => ({ CNPGOperatorBanner: () => null }))
12
+ it('passes the detail target to the awaited CNPG refresh hook', async () => {
13
+ const client = new QueryClient()
14
+ const target = { plural: 'clusters' as const, namespace: 'db', name: 'pg', group: 'postgresql.cnpg.io' }
15
+ renderToStaticMarkup(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/cnpg/clusters/db/pg?ctx=test']}><CNPGDetailPage target={target} namespaces={['db']} onOpenResource={() => {}} /></MemoryRouter></QueryClientProvider>)
16
+ expect(state.refresh).toBeTypeOf('function')
17
+ await state.refresh!()
18
+ expect(state.refetch).toHaveBeenCalledWith(client, target)
19
+ client.clear()
20
+ })
21
+ it('replaces only the CNPG Cluster Configuration body, retaining the spec tab identity', () => {
22
+ const client = new QueryClient()
23
+ for (const target of [
24
+ { plural: 'clusters' as const, group: 'postgresql.cnpg.io' },
25
+ { plural: 'backups' as const, group: 'postgresql.cnpg.io' },
26
+ { plural: 'clusters' as const, group: 'cluster.x-k8s.io' },
27
+ ]) {
28
+ renderToStaticMarkup(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/cnpg/clusters/db/pg?ctx=test&tab=spec']}><CNPGDetailPage target={{ ...target, namespace: 'db', name: 'pg' }} namespaces={['db']} onOpenResource={() => {}} /></MemoryRouter></QueryClientProvider>)
29
+ if (target.plural === 'clusters' && target.group === 'postgresql.cnpg.io') {
30
+ expect(state.specTab.label).toBe('Configuration')
31
+ expect(state.renderStatusBadge).toBeTypeOf('function')
32
+ const badge = state.renderStatusBadge!({ spec: { instances: 2 }, status: { readyInstances: 1, phase: 'Cluster in healthy state' } })
33
+ const html = renderToStaticMarkup(badge)
34
+ expect(html).toContain('>Phase</span>Cluster in healthy state')
35
+ expect(html).not.toContain('Degraded')
36
+ expect(badge.props.content).toContain('status.phase, reported by CloudNativePG: Cluster in healthy state')
37
+ const creating = renderToStaticMarkup(state.renderStatusBadge!({ status: { phase: 'Creating a new replica' } }))
38
+ expect(creating).toContain('Creating a new replica')
39
+ expect(creating).not.toMatch(/amber|emerald/)
40
+ expect(renderToStaticMarkup(state.renderStatusBadge!({ status: {} }))).toContain('Not reported')
41
+ expect(state.specTab.render).toBeTypeOf('function')
42
+ expect(state.specTab.lead).toBeUndefined()
43
+ } else { expect(state.specTab).toBeUndefined(); expect(state.renderStatusBadge).toBeUndefined() }
44
+ }
45
+ client.clear()
46
+ })
47
+
48
+ it('keeps the ordinary detail and omits advanced tabs/configuration until advertised', () => {
49
+ const client = new QueryClient()
50
+ for (const support of ['unsupported', 'unknown'] as const) {
51
+ state.support = support
52
+ const html = renderToStaticMarkup(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/cnpg/clusters/db/pg?ctx=test&tab=activity']}><CNPGDetailPage target={{ plural: 'clusters', group: 'postgresql.cnpg.io', namespace: 'db', name: 'pg' }} namespaces={['db']} onOpenResource={() => {}} /></MemoryRouter></QueryClientProvider>)
53
+ expect(html).toContain('detail')
54
+ expect(state.specTab).toBeUndefined()
55
+ expect(state.renderStatusBadge).toBeUndefined()
56
+ expect(state.refresh).toBeUndefined()
57
+ expect(state.extraTabs.map((tab: any) => tab.id)).toEqual(['activity'])
58
+ expect(state.extraTabs[0].render().props).toMatchObject({ namespace: 'db', name: 'pg' })
59
+ }
60
+ state.support = 'supported'
61
+ client.clear()
62
+ })