@skyhook-io/radar-app 1.17.0 → 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 (276) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +125 -87
  4. package/src/api/actions.errorCode.test.ts +40 -0
  5. package/src/api/actions.test.ts +48 -0
  6. package/src/api/actions.ts +79 -0
  7. package/src/api/apiResources.navigation.test.tsx +232 -0
  8. package/src/api/apiResources.ts +46 -3
  9. package/src/api/client.helmSource.test.ts +39 -0
  10. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  11. package/src/api/client.namespaceSwitch.test.ts +107 -0
  12. package/src/api/client.ts +266 -39
  13. package/src/api/cnpg-ha.live.test.ts +140 -0
  14. package/src/api/cnpg-ha.test.ts +78 -0
  15. package/src/api/cnpg-ha.ts +19 -0
  16. package/src/api/cnpg-history.ts +123 -0
  17. package/src/api/cnpg-inspect.ts +102 -0
  18. package/src/api/cnpg-protection.ts +112 -0
  19. package/src/api/cnpg-recovery.test.tsx +60 -0
  20. package/src/api/cnpg-recovery.ts +262 -0
  21. package/src/api/cnpg-sessions.ts +175 -0
  22. package/src/api/cnpg-storage.ts +138 -0
  23. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  24. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  25. package/src/api/cnpg.ts +428 -10
  26. package/src/api/radarFeatures.render.test.tsx +47 -3
  27. package/src/api/radarFeatures.ts +4 -1
  28. package/src/api/reviewedActionMutation.test.tsx +164 -0
  29. package/src/api/reviewedActionMutation.ts +66 -0
  30. package/src/api/timelineSource.test.ts +9 -0
  31. package/src/api/timelineSource.ts +8 -0
  32. package/src/components/audit/AuditView.tsx +9 -0
  33. package/src/components/capacity/CapacityActivity.tsx +3 -31
  34. package/src/components/capacity/CapacityDemand.tsx +4 -36
  35. package/src/components/capacity/CapacityOverview.tsx +217 -56
  36. package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
  37. package/src/components/capacity/CapacityView.test.tsx +323 -0
  38. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  39. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  40. package/src/components/capacity/routes.ts +42 -0
  41. package/src/components/capacity/shared.tsx +11 -129
  42. package/src/components/capacity/workspaceRoute.ts +18 -0
  43. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  44. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  45. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  46. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  47. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  48. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  49. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  50. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  51. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  52. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  53. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  54. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  55. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  56. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  57. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  58. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  59. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  60. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  61. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  62. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  63. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  64. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  65. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  66. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  67. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  68. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  69. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  70. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  71. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  72. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  73. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  74. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  75. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  76. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  77. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  78. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  79. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  80. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  81. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  82. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  83. package/src/components/cnpg/CNPGProtection.tsx +228 -122
  84. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  85. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  86. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  87. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  88. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  89. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  90. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  91. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  92. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  93. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  94. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  95. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  96. package/src/components/cnpg/CNPGView.tsx +34 -21
  97. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  98. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  99. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  100. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  101. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  102. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  103. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  104. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  105. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  106. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  107. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  108. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  109. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  110. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  111. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  112. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  113. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  114. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  115. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  116. package/src/components/cnpg/actions/actionModel.ts +127 -0
  117. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  118. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  119. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  120. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  121. package/src/components/cnpg/archivingRepair.ts +145 -0
  122. package/src/components/cnpg/baseBackup.test.ts +36 -0
  123. package/src/components/cnpg/baseBackup.ts +36 -0
  124. package/src/components/cnpg/blocking.test.ts +59 -0
  125. package/src/components/cnpg/blocking.ts +131 -0
  126. package/src/components/cnpg/coverageText.test.ts +27 -0
  127. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  128. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  129. package/src/components/cnpg/creation/targetModel.ts +149 -0
  130. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  131. package/src/components/cnpg/fleetStatus.ts +43 -0
  132. package/src/components/cnpg/formFields.ts +2 -0
  133. package/src/components/cnpg/grantText.test.tsx +11 -0
  134. package/src/components/cnpg/host.test.tsx +24 -0
  135. package/src/components/cnpg/host.tsx +101 -0
  136. package/src/components/cnpg/inspectModel.test.ts +120 -0
  137. package/src/components/cnpg/inspectModel.ts +140 -0
  138. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  139. package/src/components/cnpg/logicalSlots.ts +29 -0
  140. package/src/components/cnpg/lsn.test.ts +40 -0
  141. package/src/components/cnpg/lsn.ts +43 -0
  142. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  143. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  144. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  145. package/src/components/cnpg/operations/model.test.ts +510 -0
  146. package/src/components/cnpg/operations/model.ts +499 -0
  147. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  148. package/src/components/cnpg/operations/presentation.ts +84 -0
  149. package/src/components/cnpg/operations/store.ts +98 -0
  150. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  151. package/src/components/cnpg/operatorStatus.ts +194 -0
  152. package/src/components/cnpg/paths.test.ts +54 -0
  153. package/src/components/cnpg/paths.ts +47 -4
  154. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  155. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  156. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  157. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  158. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  159. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  160. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  161. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  162. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  163. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  164. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  165. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  166. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  167. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  168. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  169. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  172. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  173. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  174. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  175. package/src/components/cnpg/refresh.test.ts +38 -0
  176. package/src/components/cnpg/refresh.ts +17 -0
  177. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  178. package/src/components/cnpg/routes.test.ts +34 -3
  179. package/src/components/cnpg/routes.ts +60 -48
  180. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  181. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  182. package/src/components/cnpg/runtimeModel.ts +196 -0
  183. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  184. package/src/components/cnpg/runtimeParts.tsx +79 -0
  185. package/src/components/cnpg/shared.test.tsx +59 -0
  186. package/src/components/cnpg/shared.tsx +100 -182
  187. package/src/components/cnpg/storageModel.test.ts +107 -0
  188. package/src/components/cnpg/storageModel.ts +101 -0
  189. package/src/components/cnpg/trendSamples.test.ts +148 -0
  190. package/src/components/cnpg/trendSamples.ts +222 -0
  191. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  192. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  193. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  194. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  195. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  196. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  197. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  198. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  199. package/src/components/cnpg/workspaceRoute.ts +23 -0
  200. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  201. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  202. package/src/components/cost/CostView.test.ts +2 -1
  203. package/src/components/cost/CostView.tsx +0 -2
  204. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  205. package/src/components/cost/errors.ts +3 -1
  206. package/src/components/cost/source.test.ts +10 -0
  207. package/src/components/cost/source.ts +5 -1
  208. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  209. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  210. package/src/components/dock/BottomDock.tsx +2 -0
  211. package/src/components/dock/TerminalTab.test.tsx +17 -0
  212. package/src/components/dock/TerminalTab.tsx +7 -3
  213. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  214. package/src/components/gitops/GitOpsView.tsx +16 -23
  215. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  216. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  217. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  218. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  219. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  220. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  221. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  222. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  223. package/src/components/helm/ValuesViewer.tsx +169 -78
  224. package/src/components/home/ActivitySummary.tsx +2 -1
  225. package/src/components/home/HomeView.tsx +20 -9
  226. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  227. package/src/components/home/TopologyPreview.tsx +2 -2
  228. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  229. package/src/components/issues/IssuesPane.test.ts +65 -1
  230. package/src/components/issues/IssuesPane.tsx +188 -25
  231. package/src/components/issues/uncheckedSources.ts +21 -0
  232. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  233. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  234. package/src/components/resource/PVCUsageBar.tsx +15 -6
  235. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  236. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  237. package/src/components/resources/CompositeRenderer.tsx +4 -4
  238. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  239. package/src/components/resources/ResourcesView.tsx +26 -12
  240. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  241. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  242. package/src/components/settings/SettingsDialog.tsx +28 -15
  243. package/src/components/timeline/TimelineList.tsx +22 -24
  244. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  245. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  246. package/src/components/timeline/TimelineView.tsx +18 -45
  247. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  248. package/src/components/timeline/useStickyError.test.tsx +47 -0
  249. package/src/components/timeline/useStickyError.ts +15 -0
  250. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  251. package/src/components/ui/Markdown.test.tsx +65 -0
  252. package/src/components/ui/Markdown.tsx +144 -127
  253. package/src/components/whats-new/releaseNotes.ts +63 -1
  254. package/src/components/workload/WorkloadView.test.ts +10 -1
  255. package/src/components/workload/WorkloadView.tsx +27 -239
  256. package/src/components/workspace/layout.test.tsx +29 -0
  257. package/src/components/workspace/layout.tsx +259 -0
  258. package/src/components/workspace/table.ts +7 -0
  259. package/src/contexts/CapabilitiesContext.tsx +1 -1
  260. package/src/integrations/resourceHost.test.tsx +55 -0
  261. package/src/integrations/resourceHost.ts +99 -0
  262. package/src/integrations/resourceHosts.test.tsx +141 -0
  263. package/src/integrations/resourceHosts.tsx +119 -0
  264. package/src/integrations/workspaceRoute.ts +20 -0
  265. package/src/integrations/workspaceRoutes.test.ts +40 -0
  266. package/src/integrations/workspaceRoutes.ts +22 -0
  267. package/src/integrations/workspaceScreens.tsx +14 -0
  268. package/src/utils/drawer-trail.ts +35 -0
  269. package/src/utils/kindFilterStore.test.ts +147 -0
  270. package/src/utils/kindFilterStore.ts +91 -0
  271. package/src/utils/navigation.test.ts +13 -1
  272. package/src/utils/navigation.ts +39 -2
  273. package/src/utils/page-links.ts +21 -0
  274. package/src/utils/tabStorage.ts +40 -0
  275. package/src/utils/viewMemory.test.ts +78 -0
  276. package/src/utils/viewMemory.ts +54 -0
@@ -0,0 +1,212 @@
1
+ import { clsx } from 'clsx'
2
+ import {
3
+ CNPGClusterHASection,
4
+ PaneLoader,
5
+ RefLink,
6
+ StatusDot,
7
+ cnpgHASlotInstance,
8
+ formatAge,
9
+ formatGrant,
10
+ toneTextClass,
11
+ type CNPGClusterHA,
12
+ type NavigateToRef,
13
+ } from '@skyhook-io/k8s-ui'
14
+ import { useCNPGRuntime, type CNPGRuntimeInstance } from '../../api/cnpg'
15
+ import { useCNPGClusterHA } from '../../api/cnpg-ha'
16
+ import { cnpgInstanceLive, cnpgInstanceLiveUnavailable } from './runtimeAssessment'
17
+ import { CNPGReplicationView } from './CNPGReplicationView'
18
+ import { CNPGTabVerdict } from './CNPGClusterTabs'
19
+ import { formatBytes } from './lsn'
20
+ import { useCNPGFleet } from './useCNPGSidebarWorkspace'
21
+ import { Card, ProxyDenied, SourceState } from './runtimeParts'
22
+ import { cnpgRuntimeMeasured } from './runtimeModel'
23
+ import { buildWorkloadPath } from '../../utils/navigation'
24
+ import { useCNPGNavigate } from './useCNPGNavigate'
25
+ import { Notice, RefreshFailedNotice } from '../workspace/layout'
26
+
27
+ /**
28
+ * Instances, replication and HA: every instance with what Kubernetes says
29
+ * about its Pod and what PostgreSQL reports about its role, each standby's
30
+ * streaming state and the slot the primary keeps for it, then the slots no
31
+ * instance accounts for and the HA configuration a switchover or failover meets.
32
+ */
33
+ export function CNPGReplicationTab({
34
+ namespace,
35
+ name,
36
+ onOpenLogs,
37
+ onOpenHistory,
38
+ onNavigate,
39
+ }: {
40
+ namespace: string
41
+ name: string
42
+ onOpenLogs?: (pod: string) => void
43
+ onOpenHistory?: () => void
44
+ onNavigate?: NavigateToRef
45
+ }) {
46
+ const navigate = useCNPGNavigate()
47
+ const runtime = useCNPGRuntime(namespace, name)
48
+ const ha = useCNPGClusterHA(namespace, name)
49
+ const { fleet } = useCNPGFleet([namespace])
50
+ const clusterObject = fleet?.rows.find((r) => r.namespace === namespace && r.name === name)?.cluster
51
+ const data = runtime.data
52
+ const denied = data?.permission.proxy === 'denied'
53
+ const grant = formatGrant(data?.permission.grant) ?? `get pods/proxy in namespace ${namespace}`
54
+ const primary = data?.instances.find((i) => i.role === 'primary')
55
+ const replicas = data?.instances.filter((i) => i.role !== 'primary') ?? []
56
+
57
+ return (
58
+ <div className="space-y-4 p-4">
59
+ <div className="flex flex-wrap items-center gap-3">
60
+ <h2 className="text-sm font-semibold text-theme-text-primary">Instances, replication and HA</h2>
61
+ {data && (
62
+ <span className="text-xs text-theme-text-tertiary">
63
+ {denied ? 'Live instance data needs access you do not have' : cnpgRuntimeMeasured(data.instances, 'status') ? `Live instance data · sampled ${formatAge(data.sampledAt)} ago` : `No instance data yet; checked ${formatAge(data.sampledAt)} ago`}
64
+ </span>
65
+ )}
66
+ {onOpenHistory && (
67
+ <button type="button" onClick={onOpenHistory} className="ml-auto text-xs text-accent-text hover:underline">
68
+ Replication history →
69
+ </button>
70
+ )}
71
+ </div>
72
+ <CNPGTabVerdict namespace={namespace} name={name} id="serving" alwaysShow />
73
+ <CNPGTabVerdict namespace={namespace} name={name} id="replication" />
74
+ <RefreshFailedNotice queries={[runtime, ha]} />
75
+
76
+ {!data && runtime.isLoading ? (
77
+ <PaneLoader label="Reading live state…" className="h-32" />
78
+ ) : !data ? (
79
+ <Notice>Live instance data could not be loaded: {runtime.error instanceof Error ? runtime.error.message : 'unknown error'}</Notice>
80
+ ) : denied ? (
81
+ <>
82
+ <PodsOnly ha={ha.data} namespace={namespace} onNavigate={onNavigate} />
83
+ <Card title="Live replication"><ProxyDenied what="Streaming state, WAL positions, backlog and replication slots" grant={grant} /></Card>
84
+ </>
85
+ ) : (
86
+ <>
87
+ <CNPGReplicationView
88
+ namespace={namespace}
89
+ cluster={name}
90
+ primary={primary}
91
+ replicas={replicas}
92
+ onOpenLogs={onOpenLogs}
93
+ ha={ha.data}
94
+ clusterObject={clusterObject}
95
+ />
96
+ <OtherSlots
97
+ primary={primary}
98
+ instances={data.instances.map((i) => i.pod)}
99
+ expectedInstances={[...(clusterObject?.status?.instanceNames ?? []), ...(ha.data?.expectedInstances ?? [])]}
100
+ joiningInstances={ha.data?.jobs.items.filter((j) => j.role === 'join' && ['pending', 'active', 'running'].includes(j.phase)).map((j) => j.instance).filter((n): n is string => !!n)}
101
+ clusterObject={clusterObject}
102
+ />
103
+ </>
104
+ )}
105
+
106
+ <div className="rounded-xl border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
107
+ <CNPGClusterHASection
108
+ ha={ha.data}
109
+ live={cnpgInstanceLive(data)}
110
+ liveUnavailable={cnpgInstanceLiveUnavailable(data, runtime.error)}
111
+ loading={ha.isLoading}
112
+ error={ha.error instanceof Error ? ha.error.message : undefined}
113
+ onNavigate={onNavigate}
114
+ title="High availability"
115
+ currentPrimary={clusterObject?.status?.currentPrimary}
116
+ hibernated={fleet?.rows.find((r) => r.namespace === namespace && r.name === name)?.hibernated}
117
+ onOpenReachability={(svc) => navigate(buildWorkloadPath({ kind: 'services', group: '', namespace: svc.namespace, name: svc.name, tab: 'reachability' }))}
118
+ showInstances={false}
119
+ showCertificates={false}
120
+ />
121
+ </div>
122
+ </div>
123
+ )
124
+ }
125
+
126
+ /** Without pods/proxy: the instances as their Pods report them, never a streaming state. */
127
+ function PodsOnly({ ha, namespace, onNavigate }: { ha?: CNPGClusterHA; namespace: string; onNavigate?: NavigateToRef }) {
128
+ return (
129
+ <Card title="Instances" footer="Role from the Pod's cnpg.io/instanceRole label and readiness from Kubernetes. Whether each standby is streaming is read from PostgreSQL, which needs the access named below.">
130
+ {!ha ? (
131
+ <div className="text-sm text-theme-text-tertiary">Reading instance Pods…</div>
132
+ ) : ha.pods.state !== 'ok' ? (
133
+ <div className="text-sm text-theme-text-tertiary">Instance Pods are not readable{ha.pods.grant ? `: needs ${formatGrant(ha.pods.grant)}` : ''}.</div>
134
+ ) : (
135
+ <div className="space-y-1.5">
136
+ {ha.instances.map((i) => (
137
+ <div key={i.pod} className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm">
138
+ <StatusDot tone={i.ready ? 'healthy' : 'unhealthy'} />
139
+ <RefLink refTo={{ kind: 'Pod', group: '', namespace, name: i.pod }} onNavigate={onNavigate} mono />
140
+ <span className="text-theme-text-secondary">{i.role === 'replica' ? 'standby' : i.role === 'unknown' ? 'role unknown' : 'primary'}</span>
141
+ <span className={clsx('text-xs', i.ready ? 'text-theme-text-secondary' : toneTextClass('unhealthy'))}>Pod {i.ready ? 'ready' : 'not ready'}</span>
142
+ {i.node && <span className="text-xs text-theme-text-tertiary">node {i.node}</span>}
143
+ <span className="text-xs text-theme-text-tertiary">{i.role === 'replica' ? 'streaming not read' : 'live PostgreSQL status not read'}</span>
144
+ </div>
145
+ ))}
146
+ </div>
147
+ )}
148
+ </Card>
149
+ )
150
+ }
151
+
152
+ export function OtherSlots({ primary, instances, expectedInstances = [], joiningInstances = [], clusterObject }: { primary?: CNPGRuntimeInstance; instances: string[]; expectedInstances?: string[]; joiningInstances?: string[]; clusterObject?: any }) {
153
+ const readable = primary?.status.state === 'ok' || primary?.status.state === 'partial'
154
+ if (!primary) return null
155
+ if (!readable || !primary.status.slots) {
156
+ return (
157
+ <Card title="Replication slots">
158
+ <SourceState label="Slots" state={readable ? 'partial' : primary.status.state} error={primary.status.error ?? primary.status.reason ?? 'slots were not read'} />
159
+ </Card>
160
+ )
161
+ }
162
+ const standbys = instances.filter((name) => name !== primary.pod)
163
+ const expectedStandbys = expectedInstances.filter((name) => name !== primary.pod)
164
+ const allInstances = [...new Set([...standbys, ...expectedStandbys])]
165
+ const waiting = primary.status.slots.filter((sl) => sl.type === 'physical' && !cnpgHASlotInstance(clusterObject, sl.name, standbys) && cnpgHASlotInstance(clusterObject, sl.name, expectedStandbys))
166
+ const others = primary.status.slots.filter((sl) => sl.type !== 'physical' || !cnpgHASlotInstance(clusterObject, sl.name, allInstances))
167
+ return (
168
+ <Card title="Replication slots" footer="Slot inventory from the primary’s instance manager. Physical slot associations use CloudNativePG’s naming convention.">
169
+ {waiting.length > 0 && (
170
+ <div className="mb-3 space-y-2">
171
+ {waiting.map((sl) => {
172
+ const instance = cnpgHASlotInstance(clusterObject, sl.name, expectedStandbys)!
173
+ return (
174
+ <div key={sl.name} className={clsx('text-sm', sl.active === false ? toneTextClass('degraded') : 'text-theme-text-secondary')}>
175
+ <span className="font-mono">{instance}</span> — {joiningInstances.includes(instance) ? 'waiting to join' : 'no instance Pod observed'}
176
+ <div className="text-xs text-theme-text-tertiary">Slot {sl.name} · {sl.active === undefined ? 'state unknown' : sl.active ? 'active' : 'inactive'} · {sl.retainedBytes === undefined ? 'retained WAL not reported' : formatBytes(sl.retainedBytes)}</div>
177
+ </div>
178
+ )
179
+ })}
180
+ </div>
181
+ )}
182
+ {others.length === 0 ? (
183
+ <div className="text-xs text-theme-text-tertiary">{primary.status.slotsTruncated ? 'No other slots in the reported inventory; inventory incomplete' : 'No other slots reported'}</div>
184
+ ) : (
185
+ <table className="w-full text-sm">
186
+ <thead className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
187
+ <tr>
188
+ <th className="py-1.5 pr-3">Slot</th>
189
+ <th className="pr-3">Type</th>
190
+ <th className="pr-3">Database</th>
191
+ <th className="pr-3">State</th>
192
+ <th className="pr-3">WAL status</th>
193
+ <th className="text-right">Holds</th>
194
+ </tr>
195
+ </thead>
196
+ <tbody className="table-divide-subtle">
197
+ {others.map((sl) => (
198
+ <tr key={sl.name}>
199
+ <td className="py-1.5 pr-3 font-mono text-xs">{sl.name}{sl.type === 'physical' && <div className="font-sans text-theme-text-tertiary">association unverified</div>}</td>
200
+ <td className="pr-3">{sl.type ?? '—'}</td>
201
+ <td className="pr-3 font-mono text-xs">{sl.database ?? '—'}</td>
202
+ <td className={clsx('pr-3', sl.active === false && toneTextClass('degraded'))}>{sl.active === undefined ? 'state unknown' : sl.active ? 'active' : 'inactive'}</td>
203
+ <td className="pr-3">{sl.walStatus ?? '—'}</td>
204
+ <td className="text-right font-mono">{formatBytes(sl.retainedBytes)}</td>
205
+ </tr>
206
+ ))}
207
+ </tbody>
208
+ </table>
209
+ )}
210
+ </Card>
211
+ )
212
+ }
@@ -0,0 +1,264 @@
1
+ import { useId } from 'react'
2
+ import { ArrowRight } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import {
5
+ Badge,
6
+ CNPG_ROLE_DETAIL_TEXT,
7
+ CNPG_SLOT_RETENTION_WARNING_BYTES,
8
+ cnpgFormatLag,
9
+ cnpgHASlotName,
10
+ StatusDot,
11
+ Tooltip,
12
+ toneFillClass,
13
+ toneTextClass,
14
+ type CNPGClusterHA,
15
+ type CNPGHAInstance,
16
+ } from '@skyhook-io/k8s-ui'
17
+ import { useCNPGClusterCapabilities, type CNPGRuntimeInstance, type CNPGRuntimeReplication } from '../../api/cnpg'
18
+ import { CNPGInstanceActions } from './actions/CNPGInstanceActions'
19
+ import { formatBytes, lsnDistance, standbyOwnBacklog } from './lsn'
20
+ import { CNPG_BACKLOG_DEGRADED, cnpgNoStandbyText, cnpgStandbyBacklogTone, cnpgStandbyHeadline } from './runtimeModel'
21
+ import { Card, SourceState } from './runtimeParts'
22
+
23
+ function seconds(s?: number): string {
24
+ return s === undefined ? '—' : cnpgFormatLag(s)
25
+ }
26
+
27
+ type RuntimeSlot = NonNullable<CNPGRuntimeInstance['status']['slots']>[number]
28
+
29
+ /** Readiness, node and QoS from the instance Pod: what Kubernetes says, kept apart from what PostgreSQL reports. */
30
+ function PodLine({ pod }: { pod?: CNPGHAInstance }) {
31
+ if (!pod) return null
32
+ return (
33
+ <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-theme-text-secondary">
34
+ <span className={pod.ready ? undefined : toneTextClass('unhealthy')}>Pod {pod.ready ? 'ready' : 'not ready'}</span>
35
+ {pod.node && <span>node <span className="font-mono">{pod.node}</span></span>}
36
+ {pod.qosClass && <span>QoS {pod.qosClass}</span>}
37
+ {pod.restartCount > 0 && <span>{pod.restartCount} container restart{pod.restartCount === 1 ? '' : 's'}</span>}
38
+ {pod.imageMatches === false && <Badge severity="warning" size="sm">image differs</Badge>}
39
+ </div>
40
+ )
41
+ }
42
+
43
+ /** The HA slot the primary keeps for this standby, beside the standby it serves, as `kubectl cnpg status` shows it. */
44
+ function SlotLine({ slot, name }: { slot?: RuntimeSlot; name: string }) {
45
+ if (!slot) {
46
+ return <div className="mt-1 text-xs text-theme-text-tertiary">Slot <span className="font-mono">{name}</span>: not present on the primary</div>
47
+ }
48
+ const held = (slot.retainedBytes ?? 0) >= CNPG_SLOT_RETENTION_WARNING_BYTES && slot.active === false
49
+ return (
50
+ <div className="mt-1 text-xs">
51
+ <span className="text-theme-text-secondary">Slot on the primary </span>
52
+ <span className="font-mono">{slot.name}</span>
53
+ <span className={clsx(slot.active === false ? toneTextClass('degraded') : 'text-theme-text-secondary')}> · {slot.active === undefined ? 'state unknown' : slot.active ? 'active' : 'inactive'}</span>
54
+ <span className={held ? toneTextClass('degraded') : 'text-theme-text-secondary'}> · holds {formatBytes(slot.retainedBytes)} of WAL</span>
55
+ {slot.walStatus && <span className="text-theme-text-tertiary"> · WAL status {slot.walStatus}</span>}
56
+ </div>
57
+ )
58
+ }
59
+
60
+ function InstanceFacts({ inst, primaryVersion }: { inst: CNPGRuntimeInstance; primaryVersion?: string }) {
61
+ const s = inst.status
62
+ if (s.state !== 'ok' && s.state !== 'partial') return null
63
+ const skew = s.instanceManagerVersion && primaryVersion && s.instanceManagerVersion !== primaryVersion
64
+ return (
65
+ <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-theme-text-secondary">
66
+ {s.roleDetail && s.roleDetail !== 'replayPaused' && <span>{CNPG_ROLE_DETAIL_TEXT[s.roleDetail]}</span>}
67
+ {s.timeline !== undefined && <span className="font-mono">timeline {s.timeline}</span>}
68
+ {s.instanceManagerVersion && (
69
+ <span className={skew ? toneTextClass('degraded') : undefined}>
70
+ CNPG instance manager {s.instanceManagerVersion}
71
+ {skew ? ` (primary runs ${primaryVersion})` : ''}
72
+ </span>
73
+ )}
74
+ {s.pendingRestart && (
75
+ <Tooltip content={s.pendingRestartForDecrease ? 'A lowered setting is waiting: the primary must restart before its standbys.' : 'A changed parameter only takes effect after a restart.'}>
76
+ <Badge severity="warning" size="sm">pending restart</Badge>
77
+ </Tooltip>
78
+ )}
79
+ </div>
80
+ )
81
+ }
82
+
83
+ function Backlog({ primary, rep }: { primary?: CNPGRuntimeInstance; rep: CNPGRuntimeReplication }) {
84
+ const current = primary?.status.currentLsn
85
+ const replay = lsnDistance(current, rep.replayLsn)
86
+ const parts: [string, number | undefined][] = [
87
+ ['not sent', lsnDistance(current, rep.sentLsn)],
88
+ ['not written', lsnDistance(rep.sentLsn, rep.writeLsn)],
89
+ ['not flushed', lsnDistance(rep.writeLsn, rep.flushLsn)],
90
+ ['not replayed', lsnDistance(rep.flushLsn, rep.replayLsn)],
91
+ ]
92
+ return (
93
+ <div className="mt-1 text-xs">
94
+ <span className="text-theme-text-secondary">Replay backlog </span>
95
+ <span className="font-mono text-theme-text-primary">{formatBytes(replay)}</span>
96
+ <span className="text-theme-text-tertiary">
97
+ {' · '}
98
+ {parts.map(([label, v]) => `${label} ${formatBytes(v)}`).join(' · ')}
99
+ </span>
100
+ </div>
101
+ )
102
+ }
103
+
104
+ function Delays({ rep }: { rep: CNPGRuntimeReplication }) {
105
+ const none = rep.writeLag === undefined && rep.flushLag === undefined && rep.replayLag === undefined
106
+ return (
107
+ <div className="mt-0.5 text-xs text-theme-text-tertiary">
108
+ {none ? (
109
+ 'Acknowledgement delay: none recorded (idle and caught up, or not yet measured)'
110
+ ) : (
111
+ <>
112
+ Acknowledgement delay · write <span className="font-mono">{seconds(rep.writeLag)}</span> · flush <span className="font-mono">{seconds(rep.flushLag)}</span> · replay{' '}
113
+ <span className="font-mono">{seconds(rep.replayLag)}</span>
114
+ </>
115
+ )}
116
+ </div>
117
+ )
118
+ }
119
+
120
+ /**
121
+ * Instances and replication: each standby's catch-up measured as bytes of WAL
122
+ * behind the primary, beside PostgreSQL's acknowledgement delays, with each
123
+ * instance's own report of what it is doing.
124
+ */
125
+ export function CNPGReplicationView({
126
+ namespace,
127
+ cluster,
128
+ primary,
129
+ replicas,
130
+ onOpenLogs,
131
+ ha,
132
+ clusterObject,
133
+ }: {
134
+ namespace: string
135
+ cluster: string
136
+ primary?: CNPGRuntimeInstance
137
+ replicas: CNPGRuntimeInstance[]
138
+ onOpenLogs?: (pod: string) => void
139
+ /** The instance Pods as Kubernetes reports them (readiness, node, QoS). */
140
+ ha?: CNPGClusterHA
141
+ /** The Cluster object, for the HA slot prefix. */
142
+ clusterObject?: any
143
+ }) {
144
+ const instanceId = useId()
145
+ const rows = new Map((primary?.status.replication ?? []).map((r) => [r.applicationName, r]))
146
+ // The instance manager keeps answering on a fenced Pod with PostgreSQL
147
+ // stopped, so fencing comes from the Cluster, not from the runtime read.
148
+ const fenced = new Set(useCNPGClusterCapabilities(namespace, cluster).data?.facts.instances.filter((i) => i.fenced).map((i) => i.pod))
149
+ const primaryVersion = primary?.status.instanceManagerVersion
150
+ const pods = new Map((ha?.pods.state === 'ok' ? ha.instances : []).map((i) => [i.pod, i]))
151
+ const slots = new Map((primary?.status.slots ?? []).map((sl) => [sl.name, sl]))
152
+ const slotsRead = !!primary?.status.slots
153
+ const standbyMeasurementShown = replicas.some((r) => {
154
+ const rep = rows.get(r.pod)
155
+ return !!(r.status.receivedLsn || r.status.replayLsn) || !!rep && [rep.sentLsn, rep.writeLsn, rep.flushLsn, rep.replayLsn, rep.writeLag, rep.flushLag, rep.replayLag].some((value) => value !== undefined)
156
+ })
157
+ const standbys = replicas.map((r) => {
158
+ const rep = rows.get(r.pod)
159
+ const replayBacklog = rep ? lsnDistance(primary?.status.currentLsn, rep.replayLsn) : standbyOwnBacklog(primary?.status, r.status)
160
+ const otherTimeline = r.status.timeline !== undefined && primary?.status.timeline !== undefined && r.status.timeline !== primary.status.timeline ? r.status.timeline : undefined
161
+ const backlogTone = cnpgStandbyBacklogTone(replayBacklog, rep?.replayLag)
162
+ const headline = cnpgStandbyHeadline(r, rep, backlogTone, { fenced: fenced.has(r.pod), primaryRead: primary?.status.state === 'ok' })
163
+ const backlogText = replayBacklog !== undefined ? `${formatBytes(replayBacklog)} behind` : otherTimeline !== undefined ? 'backlog not comparable' : 'backlog unknown'
164
+ return { r, rep, replayBacklog, otherTimeline, backlogTone, headline, backlogText }
165
+ })
166
+ return (
167
+ <Card
168
+ title="Instances and replication"
169
+ footer={
170
+ standbyMeasurementShown ? <>
171
+ Rows come from the primary’s pg_stat_replication through the instance manager. Replay backlog is the primary’s current WAL position minus what the
172
+ standby has replayed, in bytes: the catch-up measure. A standby that isn't connected has no row, so its backlog uses the position it reports itself — only on
173
+ the same timeline, since positions on different timelines aren't comparable.
174
+ Write, flush and replay delay are PostgreSQL’s acknowledgement delay for recent WAL; empty when idle and caught up.
175
+ </> : 'Source: instance manager status and the primary’s pg_stat_replication.'
176
+ }
177
+ >
178
+ <div aria-label="Replication relationships" className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-theme-border bg-theme-base p-3 text-xs">
179
+ <span className="font-mono font-medium">{primary?.pod || 'Primary unknown'}</span><span className="text-theme-text-tertiary">primary</span>
180
+ <ArrowRight className="h-4 w-4 text-theme-text-tertiary" aria-hidden />
181
+ {standbys.length === 0 ? <span className="text-theme-text-secondary">{cnpgNoStandbyText(clusterObject?.spec?.instances)}</span> : standbys.map(({ r, headline, backlogText }) => <a key={r.pod} href={`#${instanceId}-${r.pod}`} className="inline-flex flex-wrap items-center gap-x-1.5 gap-y-0.5 rounded border border-theme-border bg-theme-surface px-2 py-1.5 hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><StatusDot tone={headline.tone} size="xs" /><span className="font-mono">{r.pod}</span><span className={toneTextClass(headline.tone)}>{headline.text}</span><span className="text-theme-text-tertiary">· {backlogText}</span></a>)}
182
+ </div>
183
+ <div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-[minmax(220px,300px)_minmax(0,1fr)]">
184
+ <div className="rounded-lg border border-theme-border border-l-4 border-l-accent bg-theme-base p-3">
185
+ {primary ? (
186
+ <div className="flex items-center gap-2">
187
+ <span className="font-mono text-sm font-semibold">{primary.pod}</span>
188
+ <Badge tone="structural" size="sm">primary</Badge>
189
+ </div>
190
+ ) : (
191
+ <div className="text-sm text-theme-text-tertiary">No primary reported</div>
192
+ )}
193
+ {primary && (
194
+ <>
195
+ <div className="mt-1 font-mono text-xs text-theme-text-secondary">WAL position {primary.status.currentLsn ?? '—'}</div>
196
+ <PodLine pod={pods.get(primary.pod)} />
197
+ <InstanceFacts inst={primary} />
198
+ <SourceState label="Status" state={primary.status.state} error={primary.status.error ?? primary.status.reason} />
199
+ <div className="mt-2 flex flex-wrap gap-3 text-xs">
200
+ {onOpenLogs && (
201
+ <button type="button" className="text-accent-text hover:underline" onClick={() => onOpenLogs(primary.pod)}>
202
+ Logs
203
+ </button>
204
+ )}
205
+ <CNPGInstanceActions namespace={namespace} cluster={cluster} pod={primary.pod} />
206
+ </div>
207
+ </>
208
+ )}
209
+ </div>
210
+ <div className="space-y-2">
211
+ {replicas.length === 0 && <div className="pt-[13px] text-sm text-theme-text-tertiary">{cnpgNoStandbyText(clusterObject?.spec?.instances)}</div>}
212
+ {standbys.map(({ r, rep, replayBacklog, otherTimeline, backlogTone, headline, backlogText }) => {
213
+ const tone = headline.tone
214
+ const pct = replayBacklog !== undefined ? Math.min(100, (replayBacklog / CNPG_BACKLOG_DEGRADED) * 100) : 0
215
+ return (
216
+ <div id={`${instanceId}-${r.pod}`} key={r.pod} className="scroll-mt-4 rounded-lg border border-theme-border bg-theme-base p-3">
217
+ <div className="flex flex-wrap items-center gap-2">
218
+ <StatusDot tone={tone} />
219
+ <span className="font-mono text-sm font-semibold">{r.pod}</span>
220
+ <span className={clsx('text-xs', toneTextClass(tone))}>{headline.text}</span>
221
+ {headline.secondary && <span className="text-xs text-theme-text-secondary">{headline.secondary}</span>}
222
+ <span className="ml-auto font-mono text-xs text-theme-text-secondary">
223
+ {backlogText}
224
+ </span>
225
+ </div>
226
+ {otherTimeline !== undefined && !rep && (
227
+ <div className={clsx('mt-1 text-xs', toneTextClass('degraded'))}>
228
+ On timeline {otherTimeline} while the primary is on {primary?.status.timeline}: it has not followed the primary’s last promotion, so how far behind it is can’t be
229
+ measured from WAL positions.
230
+ </div>
231
+ )}
232
+ {replayBacklog !== undefined && (
233
+ <div className="mt-2 flex items-center gap-2">
234
+ <div className="h-1 flex-1 overflow-hidden rounded bg-theme-elevated">
235
+ <div className={clsx('h-full', backlogTone === 'unknown' ? 'bg-transparent' : toneFillClass(backlogTone))} style={{ width: `${pct}%` }} />
236
+ </div>
237
+ <span className="text-[11px] text-theme-text-tertiary">scale: one 16 MiB WAL segment</span>
238
+ </div>
239
+ )}
240
+ {rep && <Backlog primary={primary} rep={rep} />}
241
+ {rep && <Delays rep={rep} />}
242
+ <div className="mt-1 font-mono text-xs text-theme-text-tertiary">
243
+ received {r.status.receivedLsn ?? '—'} · replayed {r.status.replayLsn ?? '—'}
244
+ </div>
245
+ {slotsRead && <SlotLine slot={slots.get(cnpgHASlotName(clusterObject, r.pod))} name={cnpgHASlotName(clusterObject, r.pod)} />}
246
+ <PodLine pod={pods.get(r.pod)} />
247
+ <InstanceFacts inst={r} primaryVersion={primaryVersion} />
248
+ <SourceState label="Status" state={r.status.state} error={r.status.error ?? r.status.reason} />
249
+ <div className="mt-2 flex flex-wrap gap-3 text-xs">
250
+ {onOpenLogs && (
251
+ <button type="button" className="text-accent-text hover:underline" onClick={() => onOpenLogs(r.pod)}>
252
+ Logs
253
+ </button>
254
+ )}
255
+ <CNPGInstanceActions namespace={namespace} cluster={cluster} pod={r.pod} />
256
+ </div>
257
+ </div>
258
+ )
259
+ })}
260
+ </div>
261
+ </div>
262
+ </Card>
263
+ )
264
+ }
@@ -0,0 +1,140 @@
1
+ // @vitest-environment jsdom
2
+ import { MemoryRouter } from 'react-router-dom'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+ import { expect, it, vi } from 'vitest'
5
+ import { CNPGStorage } from './CNPGStorage'
6
+ const state = vi.hoisted(() => ({ permission: 'denied', error: null as Error | null, terminating: false, webhookRejects: false, noData: false, instances: [] as any[], usage: { state: 'notRead' } as any }))
7
+ vi.mock('../../api/cnpg', () => ({ useCNPGClusterCapabilities: () => ({ data: state.noData ? undefined : { facts: { terminating: state.terminating }, operator: { webhookRejects: state.webhookRejects, webhookReason: 'no ready webhook endpoint' }, actions: { reload: { permission: state.permission, allowed: false, reason: 'Hibernated', grant: { verb: 'patch', group: 'postgresql.cnpg.io', resource: 'clusters', namespace: 'db' } } } }, error: state.error, isRefetchError: !!state.error && !state.noData, dataUpdatedAt: Date.now() - 120_000 }) }))
8
+ vi.mock('../../api/cnpg-storage', () => ({ useCNPGClusterStorage: () => ({ data: { volumes: { state: 'ok' }, usage: state.usage, wal: { state: 'ok' }, expansion: { targets: [{ role: 'PG_DATA', field: 'spec.storage.size', declared: '1Gi' }] }, instances: state.instances, findings: [] } }) }))
9
+ const render = () => renderToStaticMarkup(<MemoryRouter><CNPGStorage restoreState="none" namespace="db" name="pg" clusterObject={{ status: { currentPrimary: 'pg-1' } }} runtime={{ data: { instances: [{ pod: 'pg-1', role: 'primary' }], permission: { proxy: 'denied', grant: { verb: 'get', resource: 'pods', subresource: 'proxy', namespace: 'db' } } } } as any} /></MemoryRouter>)
10
+ it('names the missing proxy grant and retains the primary identity', () => {
11
+ const html = render()
12
+ expect(html).toContain('Database sizes unavailable: needs')
13
+ expect(html).toContain('get pods/proxy')
14
+ expect(html).toContain('in namespace db')
15
+ expect(html).not.toContain('No primary reported')
16
+ })
17
+ it('refuses a known failed admission webhook or terminating Cluster even with patch access', () => {
18
+ state.permission = 'allowed'; state.error = null; state.webhookRejects = true
19
+ expect(render()).toContain('no ready webhook endpoint')
20
+ expect(render()).toMatch(/<button[^>]*disabled=""[^>]*>Edit size…/)
21
+ state.webhookRejects = false; state.terminating = true
22
+ expect(render()).toContain('Cluster is being deleted')
23
+ expect(render()).toMatch(/<button[^>]*disabled=""[^>]*>Edit size…/)
24
+ state.terminating = false
25
+ })
26
+ it('disables size editing for denied patch access and names the grant', () => {
27
+ state.permission = 'denied'; state.error = null
28
+ const html = render()
29
+ expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Edit size…/)
30
+ expect(html).toContain('patch clusters (postgresql.cnpg.io)')
31
+ state.permission = 'allowed'
32
+ expect(render()).not.toMatch(/<button[^>]*disabled=""[^>]*>Edit size…/)
33
+ state.error = new Error('Permission timeout')
34
+ expect(render()).toContain('Last refresh failed: Permission timeout')
35
+ expect(render()).toContain('2m ago')
36
+ expect(render()).not.toMatch(/<button[^>]*disabled=""[^>]*>Edit size…/)
37
+ state.noData = true
38
+ expect(render()).toContain('Permissions could not be checked: Permission timeout')
39
+ expect(render()).toMatch(/<button[^>]*disabled=""[^>]*>Edit size…/)
40
+ state.noData = false; state.error = null
41
+ })
42
+
43
+ it('offers declared-size editing without promising existing claims will expand', () => {
44
+ state.permission = 'allowed'
45
+ state.instances = [{ name: 'pg-1', role: 'primary', volumes: [{ claim: 'pg-1', role: 'PG_DATA', requested: '1Gi', capacity: '1Gi', storageClass: { name: 'local-path', allowVolumeExpansion: false }, resize: {}, usage: { state: 'notRead' } }] }]
46
+ const html = render()
47
+ expect(html).toContain('Declared volume sizes')
48
+ expect(html).toContain('Edit declared size — existing volumes unchanged')
49
+ expect(html).toContain('larger size will not resize the existing claims')
50
+ expect(html).not.toContain('operator applies them to every instance')
51
+ state.instances = []
52
+ })
53
+
54
+ it('shows slot existence independently of retention and unrelated partial reads', () => {
55
+ const wal: any = { status: { state: 'ok' }, metrics: { state: 'partial' }, slotInventory: [{ name: '_cnpg_pg_2', active: false }], slots: [] }
56
+ state.instances = [{ name: 'pg-1', role: 'primary', volumes: [], wal }]
57
+ expect(render()).toContain('1 inactive slot')
58
+ expect(render().match(/retained WAL not reported/g)).toHaveLength(1)
59
+ wal.status = { state: 'partial', reason: 'Replication connections were capped' }
60
+ wal.slotInventory = []
61
+ expect(render()).toContain('No slots')
62
+ wal.status = { state: 'unreachable' }
63
+ wal.metrics = { state: 'ok' }
64
+ wal.slotInventory = null
65
+ wal.slots = [{ slot: 'subscriber', bytes: 2048 }]
66
+ const html = render()
67
+ expect(html).toContain('≥2.0 KiB retained WAL reported; slot inventory not read')
68
+ expect(html).not.toContain('No slots')
69
+ state.instances = []
70
+ })
71
+
72
+ it('labels volumes for an absent instance as an expected standby and associates its slot once', () => {
73
+ const wal: any = { status: { state: 'ok' }, metrics: { state: 'ok' }, slotInventory: [{ name: '_cnpg_pg_2', active: false }], slots: [] }
74
+ state.instances = [{ name: 'pg-1', role: 'primary', volumes: [], wal }, { name: 'pg-2', role: 'replica', volumes: [{ claim: 'pg-2', role: 'PG_DATA', requested: '1Gi', capacity: '1Gi', storageClass: {}, resize: {}, usage: { state: 'notRead' } }] }]
75
+ const html = renderToStaticMarkup(<MemoryRouter><CNPGStorage namespace="db" name="pg" clusterObject={{ status: { currentPrimary: 'pg-1', instanceNames: ['pg-1', 'pg-2'] } }} runtime={{ data: { permission: { proxy: 'allowed' }, instances: [{ pod: 'pg-1', role: 'primary' }] } } as any} /></MemoryRouter>)
76
+ expect(html).toContain('expected standby · not running')
77
+ expect(html).not.toContain('>replica<')
78
+ expect(html.match(/expected instance pg-2/g)).toHaveLength(1)
79
+ expect(html).toContain('<div>retained WAL not reported</div>')
80
+ expect(html.match(/retained WAL not reported/g)).toHaveLength(1)
81
+ const unread = renderToStaticMarkup(<MemoryRouter><CNPGStorage namespace="db" name="pg" /></MemoryRouter>)
82
+ expect(unread).not.toContain('expected standby · not running')
83
+ state.instances = []
84
+ })
85
+
86
+ it('keeps raw last-archived time folded when no recovery destination exists', () => {
87
+ state.instances = [{ name: 'pg-1', role: 'primary', volumes: [], wal: { status: { state: 'ok' }, metrics: { state: 'ok' }, lastArchivedAt: '2026-10-01T12:00:00Z', readyToArchive: 0, slots: [], slotInventory: [] } }]
88
+ const host = document.createElement('div')
89
+ host.innerHTML = renderToStaticMarkup(<MemoryRouter><CNPGStorage namespace="db" name="pg" walArchiving={{ state: 'no_destination' as const, text: 'Not archived: no destination configured', tone: 'neutral' }} /></MemoryRouter>)
90
+ expect(host.textContent).toContain('Not archived: no destination configured')
91
+ expect(host.textContent).toContain('Instance manager record')
92
+ expect(host.querySelector('[inert]')?.textContent).toContain('as recorded by the instance manager')
93
+ expect(host.querySelector('[inert]')?.textContent).toContain('Last archived')
94
+ state.instances = []
95
+ })
96
+ it('attributes pending volume states and identifies the first instance without an observed role', () => {
97
+ state.instances = [{ name: 'analytics-1', role: 'unknown', volumes: [{ claim: 'analytics-1', role: 'PG_DATA', requested: '1Gi', phase: 'Pending', clusterState: 'initializing', storageClass: { name: 'local', allowVolumeExpansion: false }, resize: {}, usage: { state: 'notRead' } }] }]
98
+ const html = renderToStaticMarkup(<MemoryRouter><CNPGStorage namespace="db" name="analytics" clusterObject={{ status: { phase: 'Setting up primary' } }} runtime={{ data: { permission: { proxy: 'allowed' }, instances: [] } } as any} /></MemoryRouter>)
99
+ for (const text of ['expected first instance · not running', 'WAL cannot be measured until the instance starts', 'Operator: initializing', 'Claim: Pending', 'No capacity provisioned yet', 'Edit the declared size for future claims']) expect(html).toContain(text)
100
+ expect(html).not.toContain('role unknown')
101
+ expect(html).not.toContain('Restore into a new Cluster')
102
+ state.instances = []
103
+ })
104
+ it('prescribes restore only with a verified source and uses intrinsic sibling volume rows', () => {
105
+ const v = { claim: 'pg-1', role: 'PG_DATA', requested: '1Gi', capacity: '1Gi', storageClass: { name: 'local', allowVolumeExpansion: false }, resize: {}, usage: { state: 'notRead' } }
106
+ state.instances = [{ name: 'pg-1', role: 'primary', volumes: [v] }, { name: 'pg-2', role: 'replica', volumes: [{ ...v, claim: 'pg-2', clusterState: 'initializing' }] }]
107
+ const html = render()
108
+ expect(html).not.toContain('Restore into a new Cluster')
109
+ expect(html).toContain('Set up backups before moving to a new Cluster')
110
+ expect(html).toContain('Backups →')
111
+ expect(html).toContain('xl:grid-rows-subgrid')
112
+ expect(html.match(/--cnpg-instance-rows:5/g)).toHaveLength(2)
113
+ expect(renderToStaticMarkup(<MemoryRouter><CNPGStorage namespace="db" name="pg" restoreState="available" /></MemoryRouter>)).toContain('Restore into a new Cluster')
114
+ state.instances = []
115
+ })
116
+ it('explains Prometheus discovery once with candidate details folded', () => {
117
+ state.usage = { state: 'noPrometheus', reason: 'Prometheus was not found.\nCandidate monitoring/prometheus: connection refused.' }
118
+ const host = document.createElement('div'); host.innerHTML = render()
119
+ expect(host.textContent!.match(/Prometheus was not found/g)).toHaveLength(1)
120
+ expect(host.querySelector('[inert]')?.textContent).toContain('monitoring/prometheus')
121
+ state.usage = { state: 'notRead' }
122
+ })
123
+
124
+ it('does not prescribe backup setup or restoration when the source is unread', () => {
125
+ state.instances = [{ name: 'pg-1', role: 'primary', volumes: [{ claim: 'pg-1', role: 'PG_DATA', capacity: '1Gi', storageClass: { allowVolumeExpansion: false }, resize: {}, usage: { state: 'notRead' } }] }]
126
+ const html = renderToStaticMarkup(<MemoryRouter><CNPGStorage namespace="db" name="pg" restoreState="unknown" /></MemoryRouter>)
127
+ expect(html).toContain('A restore source has not been verified')
128
+ expect(html).not.toContain('Set up backups')
129
+ expect(html).not.toContain('Restore into a new Cluster')
130
+ state.instances = []
131
+ })
132
+ it('keeps a configured-destination absence independent of unread instance records', () => {
133
+ state.instances = [{ name: 'pg-1', role: 'primary', volumes: [], wal: { status: { state: 'unreachable', error: 'proxy timeout' }, metrics: { state: 'error' }, slots: [] } }]
134
+ const html = renderToStaticMarkup(<MemoryRouter><CNPGStorage namespace="db" name="pg" walArchiving={{ state: 'no_destination' as const, text: 'Not archived: no destination configured', tone: 'neutral' }} /></MemoryRouter>)
135
+ expect(html).toContain('Not archived: no destination configured')
136
+ expect(html).toContain('Archiving record not read: proxy timeout')
137
+ expect(html).not.toContain('Last archived time not reported')
138
+ expect(html).not.toContain('wait for the archive')
139
+ state.instances = []
140
+ })