@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,187 @@
1
+ import { useRef, type ReactNode } from 'react'
2
+ import type { RenderDiagnoseAction } from '../../context/DiagnoseCustomization'
3
+ import { useSearchParams } from 'react-router-dom'
4
+ import { CNPGDimensionMark, CNPGDimensionVerdict, CNPGServingStatus, coverageReadable, toneTextClass, type CNPGDimension, type CNPGFleetRow } from '@skyhook-io/k8s-ui'
5
+ import type { SelectedResource } from '../../types'
6
+ import { useCNPGRuntime } from '../../api/cnpg'
7
+ import { CNPGStorage } from './CNPGStorage'
8
+ import { CNPGArchivingRepair } from './CNPGArchivingRepair'
9
+ import { CNPGProtection } from './CNPGProtection'
10
+ import { CNPGRestoreValidation } from './recovery/CNPGRestoreValidation'
11
+ import { CNPGRestoreButton } from './recovery/CNPGRestoreButton'
12
+ import { assessRestoreSources } from './recovery/restoreModel'
13
+ import { useCNPGRestoreCapability } from '../../api/cnpg-recovery'
14
+ import { CNPGScreenGate } from './shared'
15
+ import { useCNPGClusterAssessment } from './useCNPGClusterAssessment'
16
+ import { useCNPGFleet } from './useCNPGSidebarWorkspace'
17
+ import { CNPGProtectionSetup } from './protection/CNPGProtectionSetup'
18
+
19
+ /** The health mark beside a tab: the same assessment the Overview explains, for the dimension that tab holds. */
20
+ export function CNPGTabMark({ namespace, name, id }: { namespace: string; name: string; id: CNPGDimension['id'] }) {
21
+ const { dimensions } = useCNPGClusterAssessment(namespace, name)
22
+ const dimension = dimensions?.find((d) => d.id === id)
23
+ return dimension ? <CNPGDimensionMark dimension={dimension} /> : null
24
+ }
25
+
26
+ /** Why a tab carries its mark, as the tab's first line; nothing when the dimension is fine. */
27
+ export function CNPGTabVerdict({ namespace, name, id, className, alwaysShow }: { namespace: string; name: string; id: CNPGDimension['id']; className?: string; alwaysShow?: boolean }) {
28
+ const { dimensions } = useCNPGClusterAssessment(namespace, name)
29
+ const dimension = dimensions?.find((d) => d.id === id)
30
+ return dimension ? <CNPGDimensionVerdict dimension={dimension} className={className} alwaysShow={alwaysShow} /> : null
31
+ }
32
+
33
+ /** Whether the cluster serves writes, on its title line, opening the tab that explains it. */
34
+ export function CNPGClusterServing({ namespace, name, onSelect }: { namespace: string; name: string; onSelect: () => void }) {
35
+ const { dimensions } = useCNPGClusterAssessment(namespace, name)
36
+ const dimension = dimensions?.find((d) => d.id === 'serving')
37
+ return dimension ? <CNPGServingStatus dimension={dimension} onSelect={onSelect} /> : null
38
+ }
39
+
40
+ /** Storage: volumes, what holds WAL, and resize, with the history of both one click away. */
41
+ export function CNPGStorageTab({
42
+ namespace,
43
+ name,
44
+ onOpenHistory,
45
+ onOpenReplication,
46
+ }: {
47
+ namespace: string
48
+ name: string
49
+ onOpenHistory?: () => void
50
+ onOpenReplication?: () => void
51
+ }) {
52
+ const runtime = useCNPGRuntime(namespace, name)
53
+ const { row, query } = useCNPGClusterAssessment(namespace, name)
54
+ const clusterObject = row?.cluster
55
+ const primary = runtime.data?.instances.find((i) => i.role === 'primary')
56
+ return (
57
+ <div className="space-y-4 p-4">
58
+ <div className="flex flex-wrap items-center gap-3">
59
+ <h2 className="text-sm font-semibold text-theme-text-primary">Volumes and WAL</h2>
60
+ {onOpenHistory && (
61
+ <button type="button" onClick={onOpenHistory} className="ml-auto text-xs text-accent-text hover:underline">
62
+ Storage history →
63
+ </button>
64
+ )}
65
+ </div>
66
+ <CNPGTabVerdict namespace={namespace} name={name} id="storage" />
67
+ {row && <SlotRelief row={row} onOpenReplication={onOpenReplication} />}
68
+ <CNPGStorage namespace={namespace} name={name} primary={primary} runtime={runtime} clusterObject={clusterObject} restoreState={assessRestoreSources(query.data, namespace, clusterObject).recoveryState} walArchiving={row?.protection.walArchiving} />
69
+ </div>
70
+ )
71
+ }
72
+
73
+ /**
74
+ * What to do about WAL an inactive slot holds: the standby it is kept for,
75
+ * whether that standby receives anything, and the two ways the WAL is freed.
76
+ */
77
+ function SlotRelief({ row, onOpenReplication }: { row: CNPGFleetRow; onOpenReplication?: () => void }) {
78
+ const slots = row.problems.filter((p) => p.reason === 'CNPGInactiveSlot')
79
+ if (slots.length === 0) return null
80
+ const gaps = new Set(row.problems.filter((p) => p.reason === 'CNPGStandbyNotReceiving').map((p) => p.subject.name))
81
+ return (
82
+ <section className="rounded-xl border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
83
+ {slots.map((p) => {
84
+ const standby = p.instance
85
+ return (
86
+ <div key={p.id} className="space-y-1 text-sm">
87
+ <div className={toneTextClass('degraded')}>{p.title}</div>
88
+ <p className="text-theme-text-secondary">
89
+ The primary keeps that WAL until {standby ?? 'the slot’s consumer'} catches up or the slot is dropped.
90
+ {standby && gaps.has(standby) ? ` ${standby} is not streaming from the primary, and the slot advances only while it does — even if ${standby} replays WAL from the archive meanwhile.` : ''}
91
+ </p>
92
+ {standby && (
93
+ <p className="text-theme-text-secondary">
94
+ To free it, get {standby} streaming again — restart it, after checking its logs for why it stopped — or destroy it so CloudNativePG recreates it from
95
+ a fresh copy of the primary; the operator drops the HA slot of an instance that no longer exists.
96
+ </p>
97
+ )}
98
+ {onOpenReplication && (
99
+ <button type="button" onClick={onOpenReplication} className="text-xs text-accent-text hover:underline">
100
+ {standby ? `${standby} on Replication →` : 'Replication →'}
101
+ </button>
102
+ )}
103
+ </div>
104
+ )
105
+ })}
106
+ </section>
107
+ )
108
+ }
109
+
110
+ /** Backups: this cluster's recovery evidence, runs, schedules and destination, plus restore validation once it was restored. */
111
+ export function CNPGBackupsTab({
112
+ namespace,
113
+ name,
114
+ onInspect,
115
+ onOpenLogs,
116
+ onOpenOperator,
117
+ onOpenYaml,
118
+ }: {
119
+ namespace: string
120
+ name: string
121
+ onInspect: (r: SelectedResource) => void
122
+ onOpenLogs?: (pod: string, container: string) => void
123
+ onOpenOperator?: () => void
124
+ onOpenYaml?: () => void
125
+ }) {
126
+ const { query, fleet } = useCNPGFleet([namespace])
127
+ const { row, runtime } = useCNPGClusterAssessment(namespace, name)
128
+ const primary = runtime.data?.instances.find((i) => i.role === 'primary')
129
+ const [searchParams] = useSearchParams()
130
+ const archivingRepairRef = useRef<HTMLDivElement>(null)
131
+ const restore = useCNPGRestoreCapability(namespace)
132
+ const restoreBlocked = restore.data ? (restore.data.allowed ? undefined : restore.data.reason ?? 'Not allowed') : restore.isLoading ? 'Checking whether you can create a Cluster here…' : undefined
133
+ return (
134
+ <CNPGScreenGate query={query} fleet={fleet}>
135
+ {(data, readyFleet) => {
136
+ const cluster = readyFleet.rows.find((r) => r.namespace === namespace && r.name === name)?.cluster
137
+ const nothing = assessRestoreSources(data, namespace, cluster).disabledReason
138
+ return (
139
+ <div className="flex min-h-0 flex-1 flex-col">
140
+ <CNPGTabVerdict namespace={namespace} name={name} id="protection" className="px-4 pt-4" />
141
+ <div className="flex flex-wrap items-center gap-2 px-4 pt-3">
142
+ <CNPGProtectionSetup key={`${namespace}/${name}`} namespace={namespace} name={name} onInspect={onInspect} onOpenYaml={onOpenYaml} onOpenOperator={onOpenOperator} onOpenArchivingRepair={() => archivingRepairRef.current?.scrollIntoView({ block: 'start' })} />
143
+ <CNPGRestoreButton namespace={namespace} entry={{ kind: 'cluster', name }} disabledReason={restoreBlocked ?? nothing} />
144
+ <span className="text-xs text-theme-text-tertiary">{restoreBlocked ?? nothing ?? 'Restores into a new Cluster beside this one; this cluster is not changed.'}</span>
145
+ </div>
146
+ {row && (
147
+ <div ref={archivingRepairRef} className="px-4 pt-4">
148
+ <CNPGArchivingRepair
149
+ row={row}
150
+ primary={primary}
151
+ objectStores={data.objects.objectStores ?? []}
152
+ backups={coverageReadable(data.coverage?.backups ?? { state: 'notInstalled' }, namespace) ? (data.objects.backups ?? []) : null}
153
+ onOpenLogs={onOpenLogs}
154
+ onOpenOperator={onOpenOperator}
155
+ onInspect={onInspect}
156
+ />
157
+ </div>
158
+ )}
159
+ {readyFleet.rows.find((r) => r.namespace === namespace && r.name === name)?.cluster?.spec?.bootstrap?.recovery && (
160
+ <CNPGRestoreValidation namespace={namespace} name={name} />
161
+ )}
162
+ <CNPGProtection
163
+ data={data}
164
+ fleet={readyFleet}
165
+ namespaces={[namespace]}
166
+ searchParams={searchParams}
167
+ onSetParams={() => {}}
168
+ onInspect={onInspect}
169
+ inspected={null}
170
+ onClearNamespaces={() => {}}
171
+ scopeCluster={{ namespace, name }}
172
+ />
173
+ </div>
174
+ )
175
+ }}
176
+ </CNPGScreenGate>
177
+ )
178
+ }
179
+
180
+ export function CNPGInvestigationAction({ namespace, name, render, context }: {
181
+ namespace: string; name: string; render: RenderDiagnoseAction; context: Parameters<RenderDiagnoseAction>[0]
182
+ }): ReactNode {
183
+ const { row, dimensions } = useCNPGClusterAssessment(namespace, name)
184
+ const problem = !!row?.problems.length || dimensions?.some((d) => ['degraded', 'alert', 'unhealthy'].includes(d.tone))
185
+ const assessed = !!row && !!dimensions && dimensions.every((d) => d.tone !== 'unknown')
186
+ return render({ ...context, health: problem ? 'problem' : assessed ? 'healthy' : 'unknown' })
187
+ }
@@ -0,0 +1,46 @@
1
+ import type { SelectedResource } from '../../types'
2
+ import { CNPGOverview } from './CNPGOverview'
3
+ import { CNPGScreenGate } from './shared'
4
+ import { useCNPGFleet } from './useCNPGSidebarWorkspace'
5
+ import { useCNPGScreenParams } from './useCNPGScreenParams'
6
+
7
+ /**
8
+ * The CloudNativePG Cluster kind's list on its Resources route: the Clusters
9
+ * view of /cnpg, over the same data and URL parameters, inspecting rows in the
10
+ * Resources page's drawer.
11
+ */
12
+ export function CNPGClustersKindView({
13
+ namespaces,
14
+ inspected,
15
+ onInspect,
16
+ onClearNamespaces,
17
+ onCreate,
18
+ }: {
19
+ namespaces: string[]
20
+ inspected: SelectedResource | null
21
+ onInspect: (resource: SelectedResource) => void
22
+ onClearNamespaces: () => void
23
+ onCreate: () => void
24
+ }) {
25
+ const { query, fleet } = useCNPGFleet(namespaces)
26
+ const [searchParams, setParams] = useCNPGScreenParams()
27
+ return (
28
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-theme-base">
29
+ <CNPGScreenGate query={query} fleet={fleet}>
30
+ {(data, readyFleet) => (
31
+ <CNPGOverview
32
+ data={data}
33
+ fleet={readyFleet}
34
+ namespaces={namespaces}
35
+ searchParams={searchParams}
36
+ onSetParams={setParams}
37
+ onInspect={onInspect}
38
+ inspected={inspected}
39
+ onClearNamespaces={onClearNamespaces}
40
+ onCreate={onCreate}
41
+ />
42
+ )}
43
+ </CNPGScreenGate>
44
+ </div>
45
+ )
46
+ }
@@ -0,0 +1,223 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { renderToStaticMarkup } from 'react-dom/server'
5
+ import { MemoryRouter } from 'react-router-dom'
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
7
+ vi.mock('./useCNPGKubectlContext', () => ({ useCNPGKubectlContext: () => ({ name: 'kind-test', source: 'test-config' }) }))
8
+ import { CNPGConfiguration, CNPGDeclaredSettings } from './CNPGConfiguration'
9
+
10
+ const state = vi.hoisted(() => ({ cluster: {} as any, params: {} as any, ha: {} as any, row: undefined as any, workspace: {} as any, navigate: vi.fn() }))
11
+ vi.mock('../../api/client', () => ({ useResource: (kind: string, ns: string, name: string, group: string) => {
12
+ expect([kind, ns, name, group]).toEqual(['clusters', 'db', 'pg', 'postgresql.cnpg.io'])
13
+ return { data: state.cluster }
14
+ } }))
15
+ vi.mock('../../api/cnpg-inspect', () => ({ useCNPGParameters: () => state.params }))
16
+ vi.mock('./useCNPGClusterAssessment', () => ({ useCNPGClusterAssessment: () => ({ query: state.workspace, row: state.row, ha: state.ha }) }))
17
+ vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'test' } }) }))
18
+ vi.mock('./useCNPGNavigate', () => ({ useCNPGNavigate: () => state.navigate }))
19
+
20
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
21
+
22
+ const render = () => renderToStaticMarkup(<MemoryRouter><CNPGConfiguration namespace="db" name="pg" onNavigate={() => {}} onSelectTab={() => {}} /></MemoryRouter>)
23
+ beforeEach(() => {
24
+ state.cluster = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { namespace: 'db', name: 'pg' }, spec: { postgresql: { parameters: { shared_buffers: '256MB', work_mem: '8MB' } } }, status: { conditions: [{ type: 'Ready', status: 'False' }] } }
25
+ state.params = { data: { state: 'denied', declared: [], permission: { exec: 'denied', grant: { verb: 'create', resource: 'pods', subresource: 'exec', namespace: 'db' } }, instances: [] } }
26
+ state.ha = { error: new Error('HA read denied') }
27
+ state.row = undefined
28
+ state.workspace = {}
29
+ state.navigate.mockClear()
30
+ })
31
+
32
+ describe('Configuration composition', () => {
33
+ it('shows declarations when exec is denied, without duplicating status, events or certificates', () => {
34
+ const html = render()
35
+ for (const text of ['shared_buffers', '256MB', 'work_mem', '8MB', 'Reported by instance', 'create pods/exec', 'Certificates could not be read: HA read denied']) expect(html).toContain(text)
36
+ for (const text of ['Conditions', 'Recent Events', 'Audit Findings', 'Cluster Overview', 'on this tab below']) expect(html).not.toContain(text)
37
+ expect(html.match(/>Certificates</g)).toHaveLength(1)
38
+ expect(html.indexOf('Connect')).toBeLessThan(html.indexOf('PostgreSQL parameters'))
39
+ expect(html.indexOf('PostgreSQL parameters')).toBeLessThan(html.indexOf('Certificates'))
40
+ expect(html.indexOf('Certificates')).toBeLessThan(html.indexOf('Labels and annotations'))
41
+ expect(html).toContain('aria-expanded="false"')
42
+ expect(html).toContain('kubectl --context kind-test -n db port-forward service/pg-rw')
43
+ expect(html).toContain('Context as named in your kubeconfig (test-config); kubectl must read the same kubeconfig file.')
44
+ })
45
+ it.each([{ isLoading: true }, { error: new Error('runtime failed') }])('keeps Cluster declarations when observations are unavailable: %j', (query) => {
46
+ state.params = query
47
+ const html = render()
48
+ expect(html).toContain('shared_buffers')
49
+ expect(html).toContain('256MB')
50
+ expect(html).toContain('Not read:')
51
+ expect(html).not.toContain('>In effect<')
52
+ })
53
+ it('names which instances answered each parameter and which restart is pending', () => {
54
+ state.params = { data: { state: 'partial', declared: [{ name: 'shared_buffers', value: '256MB' }, { name: 'work_mem', value: '8MB' }], permission: { exec: 'allowed' }, instances: [
55
+ { pod: 'pg-1', state: 'ok', settings: [{ name: 'shared_buffers', value: '256MB', source: 'configuration file', context: 'postmaster' }, { name: 'work_mem', value: '8MB', context: 'user' }] },
56
+ { pod: 'pg-2', state: 'ok', settings: [{ name: 'shared_buffers', value: '128MB', source: 'configuration file', context: 'postmaster', pendingRestart: true }] },
57
+ { pod: 'pg-3', state: 'unreachable', error: 'timeout' },
58
+ ] } }
59
+ const html = render()
60
+ for (const text of ['pg-1: 256MB', 'pg-2: 128MB', 'Restart pending on pg-2', 'Not reported by pg-2', 'pg-3 (timeout)', '>restart<', 'reload']) expect(html).toContain(text)
61
+ })
62
+ it('shows parameters beyond the sampled set as declared and unreported', () => {
63
+ state.cluster.spec.postgresql.parameters.extra = 'x'
64
+ state.params = { data: { state: 'ok', declared: [{ name: 'shared_buffers', value: 'old declaration' }], permission: { exec: 'allowed' }, instances: [], omitted: 1 } }
65
+ const html = render()
66
+ expect(html).toContain('extra')
67
+ expect(html).toContain('256MB')
68
+ expect(html).not.toContain('old declaration')
69
+ expect(html).toContain('no instance Pod to read')
70
+ expect(html).toContain('1 declaration was not sampled')
71
+ expect(html).not.toContain('>unknown<')
72
+ })
73
+ it('renders the shared certificate section once when HA answers, including the renewal owner', () => {
74
+ state.ha = { data: { cluster: { namespace: 'db', name: 'pg' }, rwEndpoints: { state: 'denied', pods: [] }, pods: { state: 'denied' }, instances: [], certificates: [{ secret: 'pg-server', raw: '2100-01-01', expiresAt: '2100-01-01T00:00:00Z', renewal: 'operator' }] } }
75
+ const html = render()
76
+ expect(html.match(/>Certificates</g)).toHaveLength(1)
77
+ expect(html).toContain('pg-server')
78
+ expect(html).toContain('CloudNativePG renews it')
79
+ expect(html).not.toContain('Certificates could not be read')
80
+ })
81
+ it('keeps cached parameter observations with a refresh-failed notice', () => {
82
+ state.params = { isRefetchError: true, dataUpdatedAt: Date.now() - 60_000, error: new Error('refresh timed out'), data: { state: 'ok', sampledAt: '2026-10-04T00:00:00Z', declared: [{ name: 'work_mem', value: '8MB' }], permission: { exec: 'allowed' }, instances: [{ pod: 'pg-1', state: 'ok', settings: [{ name: 'work_mem', value: '8MB', context: 'user' }] }] } }
83
+ const html = render()
84
+ expect(html).toContain('Last refresh failed: refresh timed out')
85
+ expect(html).toContain('showing data from')
86
+ expect(html).toContain('>8MB</span>')
87
+ expect(html).toContain('read on pg-1')
88
+ expect(html).not.toContain('Not read: refresh timed out')
89
+ })
90
+ it('distinguishes skipped names, no readable instances and a parameter missing from a successful read', () => {
91
+ state.cluster.spec.postgresql.parameters['invalid name'] = 'x'
92
+ state.params = { data: { state: 'ok', declared: Object.entries(state.cluster.spec.postgresql.parameters).map(([name, value]) => ({ name, value })), skipped: ['invalid name'], permission: { exec: 'allowed' }, instances: [{ pod: 'pg-1', state: 'unreachable', error: 'timeout' }] } }
93
+ let html = render()
94
+ expect(html).toContain('Not read (not a parameter name): invalid name')
95
+ expect(html).toContain('no instance answered')
96
+ expect(html).not.toContain('>unknown<')
97
+ state.params.data.instances = [{ pod: 'pg-1', state: 'ok', settings: [] }]
98
+ html = render()
99
+ expect(html).toContain('Not reported by pg-1')
100
+ })
101
+ it('opens Declarations scoped to this Cluster with return context', () => {
102
+ state.cluster.spec.managed = { roles: [{ name: 'reader' }] }
103
+ const host = document.createElement('div')
104
+ const root = createRoot(host)
105
+ act(() => root.render(<MemoryRouter><CNPGConfiguration namespace="db" name="pg" onNavigate={() => {}} onSelectTab={() => {}} /></MemoryRouter>))
106
+ act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Declarations →')!.click())
107
+ expect(state.navigate).toHaveBeenCalledWith('/cnpg/declarations?cluster=db%2Fpg', { state: { returnLabel: expect.any(String), returnCtx: 'test' } })
108
+ act(() => root.unmount())
109
+ })
110
+ it('labels a retained workspace row when its Pooler refresh fails', () => {
111
+ state.row = { poolersKnown: true, poolerObjects: [{ metadata: { namespace: 'db', name: 'pg-pool' }, spec: { cluster: { name: 'pg' } } }] }
112
+ state.workspace = { isRefetchError: true, dataUpdatedAt: Date.now() - 60_000, error: new Error('workspace timed out') }
113
+ const html = render()
114
+ expect(html).toContain('pg-pool')
115
+ expect(html).toContain('Last refresh failed: workspace timed out')
116
+ expect(html).toContain('showing data from')
117
+ })
118
+ it('labels newly declared parameters as unsampled rather than inferring absent Pods', () => {
119
+ state.params = { data: { state: 'ok', declared: [], instances: [], permission: { exec: 'allowed' } } }
120
+ const html = render()
121
+ expect(html).toContain('2 declared · no parameters sampled')
122
+ expect(html).not.toContain('no instance Pod')
123
+ expect(html).toContain('no parameters sampled')
124
+ expect(html).not.toContain('>unknown<')
125
+ })
126
+ })
127
+
128
+ describe('Declared settings', () => {
129
+ const settings = () => renderToStaticMarkup(<CNPGDeclaredSettings cluster={state.cluster} onSelectTab={() => {}} onOpenDeclarations={() => {}} />)
130
+ it('omits empty cards and does not invent defaults from the restore preflight', () => {
131
+ state.cluster.spec = {}
132
+ state.cluster.status.image = 'running:17'
133
+ expect(settings()).toBe('')
134
+ state.cluster.spec = { instances: 2, enableSuperuserAccess: false }
135
+ const html = settings()
136
+ expect(html).toContain('Instances and image')
137
+ expect(html).toContain('Disabled')
138
+ for (const title of ['Placement and resources', 'Storage', 'Bootstrap and source', 'Backups configuration', 'Roles, services and monitoring']) expect(html).not.toContain(title)
139
+ })
140
+ it('omits empty storage stanzas and names PVC template size and class paths precisely', () => {
141
+ state.cluster.spec = { storage: {}, walStorage: {}, tablespaces: [], resources: { requests: {}, limits: {} }, affinity: { tolerations: [], nodeSelector: {} }, replica: {}, monitoring: {}, managed: { roles: [], services: { additional: [], disabledDefaultServices: [] } }, plugins: [] }
142
+ expect(settings()).toBe('')
143
+ state.cluster.spec.storage = { pvcTemplate: { resources: { requests: { storage: '30Gi' } }, storageClassName: 'template-class' } }
144
+ const html = settings()
145
+ for (const text of ['30Gi', 'template-class', 'spec.storage.pvcTemplate.resources.requests.storage', 'spec.storage.pvcTemplate.storageClassName']) expect(html).toContain(text)
146
+ expect(html).not.toContain('size from pvcTemplate')
147
+ })
148
+ it('shows each populated card with spec paths and links to operational evidence', () => {
149
+ state.cluster.spec = {
150
+ instances: 3, imageName: 'desired:17', primaryUpdateStrategy: 'supervised', primaryUpdateMethod: 'switchover', minSyncReplicas: 1, maxSyncReplicas: 2, postgresql: { synchronous: { method: 'any', number: 1, dataDurability: 'required' } },
151
+ affinity: { enablePodAntiAffinity: true, topologyKey: 'zone', nodeSelector: { disk: 'ssd' } },
152
+ topologySpreadConstraints: [{ topologyKey: 'zone', maxSkew: 1, whenUnsatisfiable: 'DoNotSchedule' }], resources: { requests: { cpu: '500m' }, limits: { memory: '1Gi' } }, priorityClassName: 'database',
153
+ storage: { size: '20Gi', storageClass: 'fast' }, walStorage: { size: '5Gi', storageClass: 'wal' }, tablespaces: [{ name: 'analytics', storage: { size: '10Gi', storageClass: 'slow' } }],
154
+ bootstrap: { initdb: { database: 'appdb', owner: 'owner' } }, replica: { enabled: false, source: 'origin' }, externalClusters: [{ name: 'origin', connectionParameters: { password: 'DO-NOT-DISPLAY' } }],
155
+ backup: { barmanObjectStore: { destinationPath: 's3://bucket' }, retentionPolicy: '7d', volumeSnapshot: { className: 'csi' }, target: 'primary' },
156
+ managed: { roles: [{ name: 'reader' }], services: { disabledDefaultServices: ['ro'], additional: [{ serviceTemplate: { metadata: { name: 'custom' } } }] } },
157
+ monitoring: { enablePodMonitor: false, customQueriesConfigMap: [{ name: 'queries', key: 'sql' }], customQueriesSecret: [{ name: 'secret-queries', key: 'sql' }] },
158
+ plugins: [{ name: 'barman-cloud.cloudnative-pg.io', isWALArchiver: true, parameters: { barmanObjectName: 'archive', serverName: 'origin-server' } }],
159
+ }
160
+ state.cluster.status.image = 'running:16'
161
+ const html = settings()
162
+ for (const title of ['Instances and image', 'Placement and resources', 'Storage', 'Bootstrap and source', 'Backups configuration', 'Roles, services and monitoring']) expect(html).toContain(title)
163
+ for (const value of ['desired:17', 'running:16', 'status.image', 'spec.instances', 'spec.primaryUpdateMethod', 'disk=ssd', 'max skew 1', '500m', '20Gi', '5Gi', 'analytics', 'appdb', 'owner', 'origin', 's3://bucket', 'volumeSnapshot', '7d', 'archive', 'custom', 'queries', 'secret-queries', 'Declarations', 'Usage → Storage tab', 'Runs and recovery evidence → Backups tab']) expect(html).toContain(value)
164
+ expect(html).not.toContain('DO-NOT-DISPLAY')
165
+ for (const path of ['spec.minSyncReplicas', 'spec.maxSyncReplicas', 'spec.postgresql.synchronous.method', 'spec.backup.target', '.isWALArchiver', '.parameters.serverName']) expect(html).toContain(path)
166
+ })
167
+ it('links image catalogs and preserves their declared major alongside the resolved image', () => {
168
+ state.cluster.spec = { imageCatalogRef: { kind: 'ClusterImageCatalog', name: 'catalog', major: 17 } }
169
+ state.cluster.status.image = 'resolved:17'
170
+ const html = settings()
171
+ expect(html).toContain('catalog')
172
+ expect(html).toContain('major 17')
173
+ expect(html).toContain('resolved:17')
174
+ expect(html).toContain('spec.imageCatalogRef')
175
+ })
176
+ })
177
+
178
+ it.each(['unsupervised', 'supervised'])('explains %s and anti-affinity while keeping raw fields', (strategy) => {
179
+ state.cluster.spec = { primaryUpdateStrategy: strategy, primaryUpdateMethod: 'restart', affinity: { podAntiAffinityType: 'preferred' } }
180
+ const html = renderToStaticMarkup(<CNPGDeclaredSettings cluster={state.cluster} onSelectTab={() => {}} onOpenDeclarations={() => {}} />)
181
+ expect(html).toContain(strategy === 'unsupervised' ? 'Updates the primary automatically' : 'Waits for a manual switchover')
182
+ expect(html).toContain(`${strategy} · spec.primaryUpdateStrategy`)
183
+ expect(html).toContain('Pod anti-affinity')
184
+ expect(html).toContain('Spreads instances across nodes when possible')
185
+ expect(html).toContain('preferred · spec.affinity.podAntiAffinityType')
186
+ expect(html).toContain('Updates the primary in place')
187
+ })
188
+
189
+ it('does not promise spreading when anti-affinity is disabled and respects its topology', () => {
190
+ state.cluster.spec = { affinity: { podAntiAffinityType: 'preferred', enablePodAntiAffinity: false } }
191
+ const render = () => renderToStaticMarkup(<CNPGDeclaredSettings cluster={state.cluster} onSelectTab={() => {}} onOpenDeclarations={() => {}} />)
192
+ expect(render()).toContain('Not applied: pod anti-affinity is disabled')
193
+ expect(render()).not.toContain('Spreads instances across')
194
+ state.cluster.spec.affinity = { podAntiAffinityType: 'preferred', topologyKey: 'topology.kubernetes.io/zone' }
195
+ expect(render()).toContain('Spreads instances across topology domains when possible')
196
+ expect(render()).toContain('topology.kubernetes.io/zone')
197
+ })
198
+
199
+ it('explains required anti-affinity for node and zone topology', () => {
200
+ state.cluster.spec = { affinity: { podAntiAffinityType: 'required' } }
201
+ const render = () => renderToStaticMarkup(<CNPGDeclaredSettings cluster={state.cluster} onSelectTab={() => {}} onOpenDeclarations={() => {}} />)
202
+ expect(render()).toContain('Requires instances on separate nodes')
203
+ state.cluster.spec.affinity.topologyKey = 'topology.kubernetes.io/zone'
204
+ expect(render()).toContain('Requires instances on separate topology domains')
205
+ expect(render()).toContain('required · spec.affinity.podAntiAffinityType')
206
+ })
207
+
208
+ it('shows one shared denial while retaining every declaration with empty observation cells', () => {
209
+ const host = document.createElement('div'); host.innerHTML = render()
210
+ const table = [...host.querySelectorAll('table')].find((t) => t.textContent?.includes('Reported by instance'))!
211
+ expect(table.querySelectorAll('tbody tr')).toHaveLength(2)
212
+ for (const r of table.querySelectorAll('tbody tr')) {
213
+ expect(r.children[2].textContent).toBe('')
214
+ expect(r.children[3].textContent).toBe('')
215
+ }
216
+ expect(host.textContent!.match(/Reading instance values needs/g)).toHaveLength(1)
217
+ })
218
+ it('labels the operator target image without claiming a running image', () => {
219
+ state.cluster.spec = { imageName: 'declared:17' }; state.cluster.status.image = 'target:17'
220
+ const html = render()
221
+ expect(html).toContain('Target image (status.image): target:17')
222
+ expect(html).not.toContain('Running target:17')
223
+ })