@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,853 @@
1
+ import { useEffect, useMemo, useState, type ReactNode } from 'react'
2
+ import { ChevronDown, DatabaseBackup, MoreHorizontal, Repeat, X } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import { ActionConfirmDialog, DialogPortal, FoldSection, Tooltip, cnpgPDBFact, cnpgQuorumFact, toneTextClass, type ActionWrite, type CNPGClusterHA, type CNPGProblem } from '@skyhook-io/k8s-ui'
5
+ import { useCNPGAction, useCNPGClusterCapabilities, useCNPGRuntime, type CNPGActionResult, type CNPGBackupMethod, type CNPGClusterActionName, type CNPGClusterCapabilities } from '../../../api/cnpg'
6
+ import { actionDialogResult, type ActionCapability, capabilityReason } from '../../../api/actions'
7
+ import { useToast } from '../../ui/Toast'
8
+ import { useCNPGClusterHA } from '../../../api/cnpg-ha'
9
+ import { trackCNPGOperation, type TrackCNPGOperationInput } from '../operations/store'
10
+ import { useCNPGWriteGuard, type CNPGWriteScope } from './useCNPGWriteGuard'
11
+ import { cnpgOperatorActionNote } from '../operatorStatus'
12
+ import { CNPGRestoreDialog } from '../recovery/CNPGRestoreDialog'
13
+ import { CNPGReportDialog } from './CNPGReportDialog'
14
+ import { useOpenCNPGPsql } from './useOpenCNPGPsql'
15
+ import {
16
+ backupNameFor,
17
+ describeBackupMethod,
18
+ pickDefaultStandby,
19
+ switchoverCandidateFacts,
20
+ switchoverConcernWarning,
21
+ switchoverConcerns,
22
+ switchoverDefault,
23
+ switchoverLagNote,
24
+ type StandbyChoice,
25
+ } from './actionModel'
26
+ import { cnpgDetailPath } from '../routes'
27
+ import { useCNPGNavigate } from '../useCNPGNavigate'
28
+ import { useCNPGFleet } from '../useCNPGSidebarWorkspace'
29
+ import { RefreshFailedNotice } from '../../workspace/layout'
30
+ import { assessRestoreSources } from '../recovery/restoreModel'
31
+ import { lsnDistance, standbyOwnBacklog } from '../lsn'
32
+
33
+ type DialogKind = CNPGClusterActionName | 'restore' | 'report' | null
34
+
35
+ const MENU_ITEM = 'block w-full px-3 py-1.5 text-left text-sm text-theme-text-primary hover:bg-theme-hover disabled:cursor-not-allowed disabled:text-theme-text-disabled'
36
+
37
+ const RESTART_EFFECT: Record<string, string> = {
38
+ recreate: 'Will recreate the standby Pod',
39
+ skipped_fenced: 'Will skip this fenced instance',
40
+ switchover: 'Will switch over to an updated standby, then recreate the former primary Pod',
41
+ restart: 'Will restart the primary in place',
42
+ wait_for_user: 'Will wait for you to promote or restart the primary',
43
+ restart_only_instance: 'Will restart the only instance (service stops during restart)',
44
+ }
45
+
46
+ function EffectItem({ list, label }: { list: { available: boolean; reason?: string; names: string[] }; label: string }) {
47
+ if (!list.available) return <li>{label}: not known{list.reason ? ` (${list.reason})` : ''}</li>
48
+ if (list.names.length === 0) return null
49
+ return <li>{label}: {list.names.join(', ')}</li>
50
+ }
51
+
52
+ function capabilityTitle(cap: ActionCapability | undefined): string | undefined {
53
+ return cap ? capabilityReason(cap) : undefined
54
+ }
55
+
56
+ /**
57
+ * Operations on a CloudNativePG Cluster. Every button reflects the server's
58
+ * capability answer (RBAC plus the cluster's state) and says why it is
59
+ * disabled; every write goes through a confirmation that leads with the
60
+ * effect, lists the literal API writes, and names GitOps overwrite risk.
61
+ */
62
+ export function CNPGClusterActions({ namespace, name, compact = false }: { namespace: string; name: string; compact?: boolean }) {
63
+ const caps = useCNPGClusterCapabilities(namespace, name)
64
+ const [open, setOpen] = useState<DialogKind>(null)
65
+ const [menu, setMenu] = useState(false)
66
+ const [blockedAction, setBlockedAction] = useState<'backup' | 'switchover'>()
67
+
68
+ const { query: workspace, fleet } = useCNPGFleet([namespace])
69
+ const restoreSourceReason = assessRestoreSources(workspace.data, namespace, fleet?.rows.find((r) => r.name === name && r.namespace === namespace)?.cluster).disabledReason
70
+ const actions = caps.data?.actions
71
+ const openPsql = useOpenCNPGPsql()
72
+ const unavailable = caps.data
73
+ ? undefined
74
+ : caps.error
75
+ ? `Actions unavailable: what you may do could not be checked (${caps.error instanceof Error ? caps.error.message : 'unknown error'})`
76
+ : 'Checking what you may do…'
77
+ const blockedReason = blockedAction && !actions?.[blockedAction]?.allowed ? unavailable ?? capabilityTitle(actions?.[blockedAction]) : undefined
78
+
79
+ const item = (id: CNPGClusterActionName, label: string) => {
80
+ const cap = actions?.[id]
81
+ const title = unavailable ?? capabilityTitle(cap)
82
+ return (
83
+ <Tooltip key={id} content={title} position="left" wrapperClassName="w-full">
84
+ <button
85
+ type="button"
86
+ role="menuitem"
87
+ disabled={!cap?.allowed}
88
+ onClick={() => {
89
+ setMenu(false)
90
+ setOpen(id)
91
+ }}
92
+ className={MENU_ITEM}
93
+ >
94
+ {label}
95
+ {!cap?.allowed && <span className="mt-0.5 block text-xs text-theme-text-secondary">{title}</span>}
96
+ </button>
97
+ </Tooltip>
98
+ )
99
+ }
100
+
101
+ const hibernated = !!caps.data?.facts.hibernated
102
+
103
+ return (
104
+ <div className="relative flex flex-wrap items-center gap-1.5">
105
+ <Tooltip content={unavailable ?? capabilityTitle(actions?.backup) ?? 'Create an on-demand Backup'} position="bottom">
106
+ <button
107
+ type="button"
108
+ aria-disabled={!actions?.backup.allowed}
109
+ aria-label="Back up now"
110
+ onClick={() => { if (actions?.backup.allowed) { setBlockedAction(undefined); setOpen('backup') } else setBlockedAction('backup') }}
111
+ className={clsx('btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium', !actions?.backup.allowed && 'cursor-not-allowed opacity-50')}
112
+ >
113
+ <DatabaseBackup className="h-3.5 w-3.5" />
114
+ {!compact && 'Back up now'}
115
+ </button>
116
+ </Tooltip>
117
+ {!compact && (
118
+ <Tooltip content={unavailable ?? capabilityTitle(actions?.switchover) ?? 'Promote a standby to primary'} position="bottom">
119
+ <button
120
+ type="button"
121
+ aria-disabled={!actions?.switchover.allowed}
122
+ onClick={() => { if (actions?.switchover.allowed) { setBlockedAction(undefined); setOpen('switchover') } else setBlockedAction('switchover') }}
123
+ className={clsx('btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium', !actions?.switchover.allowed && 'cursor-not-allowed opacity-50')}
124
+ >
125
+ <Repeat className="h-3.5 w-3.5" />
126
+ Switchover
127
+ </button>
128
+ </Tooltip>
129
+ )}
130
+ <button
131
+ type="button"
132
+ aria-haspopup="dialog"
133
+ aria-expanded={menu}
134
+ aria-label="More cluster actions"
135
+ onClick={() => { setBlockedAction(undefined); setMenu((v) => !v) }}
136
+ className="btn-secondary inline-flex items-center gap-0.5 px-2 py-1.5 text-xs"
137
+ >
138
+ <MoreHorizontal className="h-3.5 w-3.5" />
139
+ <ChevronDown className="h-3 w-3" />
140
+ </button>
141
+ {blockedReason && <div role="status" className="basis-full text-xs text-theme-text-secondary">{blockedReason}</div>}
142
+ <DialogPortal
143
+ open={menu}
144
+ onClose={() => setMenu(false)}
145
+ ariaLabel="Cluster actions"
146
+ className="w-full max-w-sm p-2"
147
+ >
148
+ <div className="flex items-center justify-between gap-3 px-3 py-2">
149
+ <h3 className="text-sm font-semibold text-theme-text-primary">Cluster actions · {name}</h3>
150
+ <button
151
+ type="button"
152
+ onClick={() => setMenu(false)}
153
+ aria-label="Close cluster actions"
154
+ className="rounded p-1 text-theme-text-secondary hover:bg-theme-hover"
155
+ >
156
+ <X className="h-4 w-4" />
157
+ </button>
158
+ </div>
159
+ <div role="menu" className="max-h-[65vh] overflow-y-auto">
160
+ {compact && item('switchover', 'Switchover…')}
161
+ {item('restart', 'Restart instances…')}
162
+ {item('reload', 'Reload configuration…')}
163
+ {item('fence', 'Fence instances…')}
164
+ {item('unfence', 'Lift fencing…')}
165
+ {hibernated ? item('rehydrate', 'Resume from hibernation…') : item('hibernate', 'Hibernate…')}
166
+ <div className="my-1 border-t border-theme-border" />
167
+ <Tooltip content={unavailable ?? capabilityTitle(actions?.psql)} position="left" wrapperClassName="w-full">
168
+ <button
169
+ type="button"
170
+ role="menuitem"
171
+ disabled={!actions?.psql.allowed || !caps.data?.facts.currentPrimary}
172
+ onClick={() => {
173
+ setMenu(false)
174
+ if (caps.data?.facts.currentPrimary) openPsql(namespace, caps.data.facts.currentPrimary, true)
175
+ }}
176
+ className={MENU_ITEM}
177
+ >
178
+ Open psql on the primary
179
+ {(!actions?.psql.allowed || !caps.data?.facts.currentPrimary) && (
180
+ <span className="mt-0.5 block text-xs text-theme-text-secondary">
181
+ {unavailable ?? capabilityTitle(actions?.psql) ?? 'No primary instance is reported'}
182
+ </span>
183
+ )}
184
+ </button>
185
+ </Tooltip>
186
+ <div className="px-3 pb-0.5 pt-1 text-[11px] uppercase tracking-wide text-theme-text-tertiary">Advanced</div>
187
+ {caps.data?.facts.maintenance.inProgress
188
+ ? item('unsetMaintenance', 'Lift node maintenance…')
189
+ : item('setMaintenance', 'Set node maintenance…')}
190
+ <div className="my-1 border-t border-theme-border" />
191
+ <Tooltip
192
+ content={unavailable ?? capabilityTitle(actions?.restore) ?? restoreSourceReason}
193
+ position="left"
194
+ wrapperClassName="w-full"
195
+ >
196
+ <button
197
+ type="button"
198
+ role="menuitem"
199
+ disabled={!actions?.restore.allowed || !!restoreSourceReason}
200
+ className={MENU_ITEM}
201
+ onClick={() => {
202
+ setMenu(false)
203
+ setOpen('restore')
204
+ }}
205
+ >
206
+ Restore to a new cluster…
207
+ </button>
208
+ </Tooltip>
209
+ {(!actions?.restore.allowed || restoreSourceReason) && (
210
+ <div className="px-3 py-1 text-xs text-theme-text-secondary">
211
+ {unavailable ?? capabilityTitle(actions?.restore) ?? restoreSourceReason}
212
+ </div>
213
+ )}
214
+ <button
215
+ type="button"
216
+ role="menuitem"
217
+ className={MENU_ITEM}
218
+ onClick={() => {
219
+ setMenu(false)
220
+ setOpen('report')
221
+ }}
222
+ >
223
+ Download report…
224
+ </button>
225
+ </div>
226
+ </DialogPortal>
227
+ <RefreshFailedNotice queries={[caps]} />
228
+ {caps.error && !caps.data && (
229
+ <div role="status" className="text-xs text-theme-text-secondary">
230
+ Actions could not be checked: {caps.error instanceof Error ? caps.error.message : 'unknown error'}.{' '}
231
+ <button type="button" onClick={() => void caps.refetch()} disabled={caps.isFetching} className="text-accent-text hover:underline">Retry</button>
232
+ </div>
233
+ )}
234
+ {caps.data && open && open !== 'restore' && open !== 'report' && (
235
+ <ClusterActionDialog kind={open} caps={caps.data} namespace={namespace} name={name} onClose={() => setOpen(null)} />
236
+ )}
237
+ {open === 'restore' && <CNPGRestoreDialog namespace={namespace} entry={{ kind: 'cluster', name }} onClose={() => setOpen(null)} />}
238
+ {open === 'report' && <CNPGReportDialog namespace={namespace} name={name} onClose={() => setOpen(null)} />}
239
+ </div>
240
+ )
241
+ }
242
+
243
+ const UNSUPPORTED = {
244
+ title: 'Unsupported action',
245
+ confirmLabel: 'Close',
246
+ effect: '',
247
+ writes: [],
248
+ scope: { kind: 'status' as const },
249
+ success: () => '',
250
+ invalid: 'Restart a single instance from its row in the Replication tab.',
251
+ }
252
+
253
+ interface DialogSpec {
254
+ title: string
255
+ confirmLabel: string
256
+ effect: ReactNode
257
+ body?: ReactNode
258
+ notes?: ReactNode[]
259
+ warnings?: ReactNode[]
260
+ writes: ActionWrite[]
261
+ scope: CNPGWriteScope
262
+ typed?: boolean
263
+ disruptive?: boolean
264
+ params?: Record<string, unknown>
265
+ success: (result: { backup?: string }) => string
266
+ invalid?: string
267
+ /** What the form still needs; disables confirm without an alert. */
268
+ incomplete?: string
269
+ }
270
+
271
+ type TrackedSpec = Omit<TrackCNPGOperationInput, 'context' | 'namespace' | 'cluster' | 'clusterUID'>
272
+
273
+ /** What to follow after the server accepted `kind`: the target and the baseline to compare against. */
274
+ function trackedOperationFor(
275
+ kind: CNPGClusterActionName,
276
+ x: {
277
+ result: CNPGActionResult
278
+ facts: CNPGClusterCapabilities['facts']
279
+ switchTarget?: string
280
+ chosenPodUID?: string
281
+ fenceSel: string
282
+ fenced: string[]
283
+ instance?: CNPGClusterCapabilities['facts']['instances'][number]
284
+ },
285
+ ): TrackedSpec | null {
286
+ const pods = x.facts.instances.map((i) => i.pod)
287
+ const podUIDs = Object.fromEntries(x.facts.instances.filter((i) => i.podUID).map((i) => [i.pod, i.podUID]))
288
+ switch (kind) {
289
+ case 'backup':
290
+ return x.result.backup
291
+ ? { kind, label: `Backup ${x.result.backup}`, target: { name: x.result.backup }, link: { kind: 'Backup', group: 'postgresql.cnpg.io', name: x.result.backup } }
292
+ : null
293
+ case 'switchover':
294
+ return x.switchTarget
295
+ ? { kind, label: `Switchover to ${x.switchTarget}`, target: { name: x.switchTarget, uid: x.chosenPodUID }, baseline: { currentPrimary: x.facts.currentPrimary } }
296
+ : null
297
+ case 'restart':
298
+ return {
299
+ kind,
300
+ label: 'Rolling restart',
301
+ baseline: { instances: pods, podUIDs, fenced: x.facts.fencedInstances.all ? pods : x.facts.fencedInstances.instances },
302
+ }
303
+ case 'restartInstance':
304
+ return x.instance
305
+ ? { kind, label: `Restart ${x.instance.pod}`, target: { name: x.instance.pod, uid: x.instance.podUID }, baseline: { podUIDs: { [x.instance.pod]: x.instance.podUID } } }
306
+ : null
307
+ case 'reload':
308
+ return { kind, label: 'Configuration reload' }
309
+ case 'fence':
310
+ return { kind, label: x.fenceSel === '*' ? 'Fence all instances' : `Fence ${x.fenceSel}`, baseline: { instances: x.fenceSel === '*' ? pods : [x.fenceSel] } }
311
+ case 'unfence': {
312
+ const lifted = x.fenceSel === '*' ? (x.fenced.includes('*') ? pods : x.fenced) : [x.fenceSel]
313
+ return { kind, label: x.fenceSel === '*' ? 'Lift all fencing' : `Lift fence on ${x.fenceSel}`, baseline: { instances: lifted } }
314
+ }
315
+ case 'hibernate':
316
+ return { kind, label: 'Hibernate' }
317
+ case 'rehydrate':
318
+ return { kind, label: 'Resume from hibernation' }
319
+ case 'setMaintenance':
320
+ return { kind, label: 'Set node maintenance' }
321
+ case 'unsetMaintenance':
322
+ return { kind, label: 'Lift node maintenance' }
323
+ default:
324
+ return null
325
+ }
326
+ }
327
+
328
+ /** Sync requirements, quorum and disruption budgets, as they stand before a switchover. */
329
+ function SwitchoverContext({ ha, haLoading, target }: { ha?: CNPGClusterHA; haLoading: boolean; target?: string }) {
330
+ if (!ha) {
331
+ return <div className="mt-2 text-xs text-theme-text-tertiary">{haLoading ? 'Reading quorum and disruption budgets…' : 'Quorum and disruption budgets could not be read.'}</div>
332
+ }
333
+ const q = ha.quorum
334
+ const quorum = cnpgQuorumFact(q)
335
+ const pdb = cnpgPDBFact(ha.pdbs)
336
+ return (
337
+ <div className="mt-3 space-y-1 rounded-md border border-theme-border bg-theme-base p-2 text-xs text-theme-text-secondary">
338
+ <div>
339
+ <span className="text-theme-text-tertiary">Synchronous replication: </span>
340
+ {q.method || q.number !== undefined
341
+ ? `${(q.method ?? '').toUpperCase()} ${q.number ?? ''}${q.dataDurability ? ` · dataDurability ${q.dataDurability}` : ''}`.trim()
342
+ : 'not configured (asynchronous)'}
343
+ </div>
344
+ <div>
345
+ <span className="text-theme-text-tertiary">Failover quorum: </span>
346
+ {quorum.text}
347
+ {q.enabled && q.promotable && target ? (q.promotable.includes(target) ? ` · ${target} is promotable` : ` · ${target} is not counted as promotable`) : ''}
348
+ </div>
349
+ <div>
350
+ <span className="text-theme-text-tertiary">Disruption budgets: </span>
351
+ {pdb.text}
352
+ </div>
353
+ <div className="text-theme-text-tertiary">
354
+ The operator restarts the old primary itself rather than evicting it, so disruption budgets do not gate the switchover; the primary’s budget follows the new
355
+ primary for later node drains.
356
+ </div>
357
+ </div>
358
+ )
359
+ }
360
+
361
+ export function ClusterActionDialog({
362
+ kind,
363
+ caps,
364
+ namespace,
365
+ name,
366
+ onClose,
367
+ initialPod,
368
+ }: {
369
+ kind: CNPGClusterActionName
370
+ caps: CNPGClusterCapabilities
371
+ namespace: string
372
+ name: string
373
+ onClose: () => void
374
+ initialPod?: string
375
+ }) {
376
+ const navigate = useCNPGNavigate()
377
+ const facts = caps.facts
378
+ const cap = caps.actions[kind]
379
+ const mutation = useCNPGAction('clusters', namespace, name, caps.context)
380
+ const { showSuccess } = useToast()
381
+ const { fleet } = useCNPGFleet([namespace])
382
+ const runtime = useCNPGRuntime(namespace, name, kind === 'switchover')
383
+ const ha = useCNPGClusterHA(namespace, name, { enabled: kind === 'switchover' || kind === 'setMaintenance' || kind === 'unsetMaintenance', refetchInterval: false })
384
+ const [reusePVC, setReusePVC] = useState(() => facts.maintenance.reusePVC)
385
+
386
+ const backupMethods = facts.backupMethods.filter((m) => m.capability !== 'none')
387
+ const [method, setMethod] = useState<CNPGBackupMethod | undefined>(backupMethods[0])
388
+ const [target, setTarget] = useState<'' | 'primary' | 'prefer-standby'>('')
389
+ const [backupName, setBackupName] = useState(() => backupNameFor(name))
390
+
391
+ const standbys: StandbyChoice[] = useMemo(() => {
392
+ const lags = new Map<string, { replayLagSeconds?: number; replayBacklogBytes?: number; state?: string; syncState?: string }>()
393
+ const primary = runtime.data?.instances.find((i) => i.role === 'primary')
394
+ for (const r of primary?.status.replication ?? []) {
395
+ lags.set(r.applicationName, {
396
+ replayLagSeconds: r.replayLag,
397
+ replayBacklogBytes: lsnDistance(primary?.status.currentLsn, r.replayLsn),
398
+ state: r.state,
399
+ syncState: r.syncState,
400
+ })
401
+ }
402
+ return facts.instances
403
+ .filter((i) => i.pod !== facts.currentPrimary)
404
+ .map((i) => {
405
+ // A standby that isn't connected has no row; its own replayed
406
+ // position still says how far behind it is.
407
+ const own = standbyOwnBacklog(primary?.status, runtime.data?.instances.find((x) => x.pod === i.pod)?.status)
408
+ const lag = lags.get(i.pod) ?? (own !== undefined ? { replayBacklogBytes: own } : undefined)
409
+ const cap = caps.instanceActions?.[i.pod]?.switchoverTarget
410
+ const ineligible = i.fenced ? 'fenced' : !i.podExists ? 'Pod missing' : !i.ready ? 'not ready' : cap && !cap.allowed ? cap.reason ?? 'not eligible' : undefined
411
+ const concerns = switchoverConcerns(primary?.status, runtime.data?.instances.find((x) => x.pod === i.pod))
412
+ return { pod: i.pod, podUID: i.podUID, ineligible, ...lag, concerns }
413
+ })
414
+ }, [facts, runtime.data, caps.instanceActions])
415
+ const [switchTarget, setSwitchTarget] = useState<string | undefined>(() => initialPod ?? pickDefaultStandby(standbys)?.pod)
416
+ const [switchTouched, setSwitchTouched] = useState(false)
417
+ // Lag and backlog arrive with the runtime read, often after the dialog
418
+ // opened: re-pick the default until the user chooses for themselves.
419
+ useEffect(() => {
420
+ const next = switchoverDefault({ touched: switchTouched || !!initialPod, current: switchTarget, standbys })
421
+ if (next !== switchTarget) setSwitchTarget(next)
422
+ }, [standbys, switchTouched, initialPod]) // eslint-disable-line react-hooks/exhaustive-deps
423
+ const chosenStandby = standbys.find((s) => s.pod === switchTarget)
424
+
425
+ const fenced = facts.fencedInstances.all ? ['*'] : facts.fencedInstances.instances
426
+ const fencingMalformed = !!facts.fencedInstances.malformed
427
+ const allFenced = facts.fencedInstances.all
428
+ const [fenceSel, setFenceSel] = useState<string>(kind === 'fence' ? '' : initialPod ?? '*')
429
+ const instance = facts.instances.find((i) => i.pod === initialPod)
430
+ const instanceIsPrimary = !!initialPod && initialPod === facts.currentPrimary
431
+
432
+ const spec: DialogSpec = (() => {
433
+ switch (kind) {
434
+ case 'backup':
435
+ return {
436
+ title: `Back up ${name} now?`,
437
+ confirmLabel: 'Create Backup',
438
+ effect: (
439
+ <>
440
+ Creates Backup <span className="font-mono">{backupName}</span>. The operator runs it on an eligible instance; a backup that is already running
441
+ makes this one wait.
442
+ </>
443
+ ),
444
+ body: (
445
+ <div className="grid grid-cols-[7rem_minmax(0,1fr)] items-center gap-x-3 gap-y-2">
446
+ <label className="text-xs text-theme-text-secondary" htmlFor="cnpg-backup-method">Method</label>
447
+ <select
448
+ id="cnpg-backup-method"
449
+ value={method ? `${method.method}:${method.pluginName ?? ''}` : ''}
450
+ onChange={(e) => setMethod(backupMethods.find((m) => `${m.method}:${m.pluginName ?? ''}` === e.target.value))}
451
+ className="rounded-lg border border-theme-border bg-theme-base px-2 py-1 text-sm"
452
+ >
453
+ {backupMethods.map((m) => (
454
+ <option key={`${m.method}:${m.pluginName ?? ''}`} value={`${m.method}:${m.pluginName ?? ''}`}>
455
+ {describeBackupMethod(m)}
456
+ </option>
457
+ ))}
458
+ </select>
459
+ <label className="text-xs text-theme-text-secondary" htmlFor="cnpg-backup-target">Target</label>
460
+ <select id="cnpg-backup-target" value={target} onChange={(e) => setTarget(e.target.value as typeof target)} className="rounded-lg border border-theme-border bg-theme-base px-2 py-1 text-sm">
461
+ <option value="">{facts.backupTarget ? `Cluster default (${facts.backupTarget})` : 'Cluster default'}</option>
462
+ <option value="prefer-standby">Prefer a standby</option>
463
+ <option value="primary">Primary</option>
464
+ </select>
465
+ <label className="text-xs text-theme-text-secondary" htmlFor="cnpg-backup-name">Name</label>
466
+ <input id="cnpg-backup-name" value={backupName} onChange={(e) => setBackupName(e.target.value)} className="rounded-lg border border-theme-border bg-theme-base px-2 py-1 font-mono text-sm" />
467
+ </div>
468
+ ),
469
+ // A volume snapshot can be recovered without archived WAL, so only
470
+ // the object-store methods are warned.
471
+ warnings:
472
+ facts.archivingFailing && method?.method === 'barmanObjectStore'
473
+ ? ['WAL archiving is failing. The operator checks archiving before a Barman backup and marks this Backup walArchivingFailing instead of taking it: fix archiving first.']
474
+ : facts.archivingFailing && method?.method === 'plugin'
475
+ ? ['WAL archiving is failing. A base backup in the object store can only be restored once the WAL written during it reaches the archive, so fix archiving first.']
476
+ : [],
477
+ notes: [
478
+ method?.capability === 'unknown' ? `The plugin ${method.pluginName} does not report whether it can take backups; the operator will reject the Backup if it cannot.` : null,
479
+ method?.method === 'barmanObjectStore' ? 'The in-tree Barman object store is deprecated in favour of the barman-cloud plugin.' : null,
480
+ 'A Backup’s spec cannot be edited once it is created.',
481
+ ].filter(Boolean) as string[],
482
+ writes: [
483
+ {
484
+ summary: `create Backup ${namespace}/${backupName}`,
485
+ detail: JSON.stringify(
486
+ {
487
+ spec: {
488
+ cluster: { name },
489
+ method: method?.method,
490
+ ...(method?.pluginName ? { pluginConfiguration: { name: method.pluginName } } : {}),
491
+ ...(target ? { target } : {}),
492
+ },
493
+ },
494
+ null,
495
+ 2,
496
+ ),
497
+ },
498
+ ],
499
+ scope: { kind: 'create-child' },
500
+ params: { method: method?.method, pluginName: method?.pluginName, target: target || undefined, name: backupName },
501
+ invalid: !method ? 'This cluster declares no backup method.' : undefined,
502
+ incomplete: !/^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(backupName) ? 'The name must be a valid Kubernetes object name.' : undefined,
503
+ success: (r) => `Backup ${r.backup ?? backupName} requested. The operator does the rest.`,
504
+ }
505
+ case 'switchover':
506
+ return {
507
+ title: `Switch over ${name}?`,
508
+ confirmLabel: 'Switch over',
509
+ effect: (
510
+ <>
511
+ Promotes <span className="font-mono">{switchTarget ?? '—'}</span> to primary. The current primary <span className="font-mono">{facts.currentPrimary}</span> shuts
512
+ down and returns as a standby; applications lose their connection while the primary changes.
513
+ </>
514
+ ),
515
+ body: (
516
+ <fieldset className="space-y-1.5">
517
+ <legend className="mb-1 text-xs text-theme-text-secondary">New primary</legend>
518
+ {standbys.length === 0 && <div className="text-sm text-theme-text-tertiary">No standby instances.</div>}
519
+ {standbys.map((s) => {
520
+ const behind = switchoverLagNote(s)
521
+ return (
522
+ <div key={s.pod}>
523
+ <label className="flex items-center gap-2 text-sm">
524
+ <input type="radio" name="cnpg-switch" value={s.pod} disabled={!!s.ineligible} checked={switchTarget === s.pod} onChange={() => {
525
+ setSwitchTouched(true)
526
+ setSwitchTarget(s.pod)
527
+ }}
528
+ />
529
+ <span className="font-mono">{s.pod}</span>
530
+ <span className={clsx('text-xs', !s.ineligible && s.concerns?.length ? toneTextClass('degraded') : 'text-theme-text-tertiary')}>
531
+ {s.ineligible ??
532
+ (s.concerns?.length
533
+ ? s.concerns.map((c) => c.text).join(' · ')
534
+ : [s.state, s.syncState, ...(s.replayLagSeconds !== undefined || s.replayBacklogBytes !== undefined ? switchoverCandidateFacts(s) : [runtime.isLoading ? 'lag loading…' : 'lag unknown'])]
535
+ .filter(Boolean)
536
+ .join(' · '))}
537
+ </span>
538
+ </label>
539
+ {behind && switchTarget === s.pod && <div className={clsx('ml-6 mt-0.5 text-xs', toneTextClass('degraded'))}>{behind}</div>}
540
+ </div>
541
+ )
542
+ })}
543
+ <SwitchoverContext ha={ha.data} haLoading={ha.isLoading} target={switchTarget} />
544
+ </fieldset>
545
+ ),
546
+ warnings: [
547
+ ha.data?.quorum.enabled && ha.data.quorum.holds === false
548
+ ? 'The failover quorum does not hold right now (R + W ≤ N): an automatic failover would wait. A switchover you request still proceeds.'
549
+ : null,
550
+ ha.data?.quorum.enabled && switchTarget && ha.data.quorum.status && ha.data.quorum.status.standbyNames.length > 0 && !ha.data.quorum.status.standbyNames.includes(switchTarget)
551
+ ? `${switchTarget} is not among the recorded potentially synchronous standbys (${ha.data.quorum.status.standbyNames.join(', ')}).`
552
+ : null,
553
+ chosenStandby && chosenStandby.concerns?.length
554
+ ? switchoverConcernWarning(chosenStandby.pod, chosenStandby.concerns)
555
+ : chosenStandby && chosenStandby.replayLagSeconds === undefined && chosenStandby.replayBacklogBytes === undefined
556
+ ? runtime.data && runtime.data.permission.proxy !== 'denied'
557
+ ? 'How far behind this standby is was not reported.'
558
+ : 'How far behind this standby is is unknown: live instance data is not readable.'
559
+ : null,
560
+ ].filter(Boolean) as string[],
561
+ notes: ['The operator performs a controlled shutdown of the old primary; its duration depends on spec.switchoverDelay and open transactions.'],
562
+ writes: [
563
+ {
564
+ summary: `patch Cluster ${namespace}/${name} status (subresource)`,
565
+ detail: `status.targetPrimary = ${switchTarget}\nstatus.phase = "Switchover in progress"\nstatus.phaseReason = "Switching over to ${switchTarget}"`,
566
+ },
567
+ ],
568
+ scope: { kind: 'status' },
569
+ typed: true,
570
+ disruptive: true,
571
+ params: { target: switchTarget, targetPodUID: chosenStandby?.podUID },
572
+ incomplete: !switchTarget || chosenStandby?.ineligible ? 'Pick an eligible standby.' : undefined,
573
+ success: () => `Switchover to ${switchTarget} requested.`,
574
+ }
575
+ case 'restart':
576
+ return {
577
+ title: `Restart ${name}?`,
578
+ confirmLabel: 'Restart instances',
579
+ effect: 'Restarts every instance with a rolling update: standbys first, then the primary.',
580
+ body: <CNPGRestartReview caps={caps} problems={fleet?.rows.find((r) => r.namespace === namespace && r.name === name)?.problems} onOpenOverview={() => { onClose(); navigate(cnpgDetailPath({ plural: 'clusters', namespace, name }, caps.context, 'overview')) }} />,
581
+ writes: [{ summary: `patch Cluster ${namespace}/${name}`, detail: 'metadata.annotations["kubectl.kubernetes.io/restartedAt"] = <now, RFC 3339>' }],
582
+ scope: { kind: 'metadata', paths: ['metadata.annotations["kubectl.kubernetes.io/restartedAt"]'] },
583
+ typed: true,
584
+ disruptive: true,
585
+ success: () => 'Restart requested. Instances restart one at a time.',
586
+ }
587
+ case 'reload':
588
+ return {
589
+ title: `Reload ${name}’s configuration?`,
590
+ confirmLabel: 'Reload',
591
+ effect: 'Asks every instance to reload its PostgreSQL configuration and certificates without restarting.',
592
+ notes: ['Nothing reports when a reload completes.'],
593
+ writes: [{ summary: `patch Cluster ${namespace}/${name}`, detail: 'metadata.annotations["cnpg.io/reloadedAt"] = <now>' }],
594
+ scope: { kind: 'metadata', paths: ['metadata.annotations["cnpg.io/reloadedAt"]'] },
595
+ success: () => 'Reload requested.',
596
+ }
597
+ case 'fence': {
598
+ const choices = [...facts.instances.map((i) => i.pod).filter((p) => !fenced.includes(p)), '*']
599
+ // The server refuses some instances on their own, such as the target of
600
+ // a switchover or failover in progress.
601
+ const refusal = (pod: string) => {
602
+ const cap = pod === '*' ? undefined : caps.instanceActions?.[pod]?.fence
603
+ return cap && !cap.allowed ? capabilityReason(cap) : undefined
604
+ }
605
+ const refused = caps.actions.fence.allowed ? choices.flatMap((pod) => { const reason = refusal(pod); return reason ? [{ pod, reason }] : [] }) : []
606
+ const selRefusal = fenceSel ? refusal(fenceSel) : undefined
607
+ const next = fenceSel === '*' ? ['*'] : [...fenced.filter((f) => f !== '*'), fenceSel]
608
+ const fencesPrimary = fenceSel === '*' || fenceSel === facts.currentPrimary
609
+ return {
610
+ title: !fenceSel ? 'Fence instances?' : fenceSel === '*' ? `Fence every instance of ${name}?` : `Fence ${fenceSel}?`,
611
+ confirmLabel: 'Fence',
612
+ effect: !fenceSel ? 'Choose which instance to stop.' : fencesPrimary
613
+ ? 'Stops PostgreSQL on the fenced instances while keeping their Pods and volumes. Writes stop and no failover happens while the primary is fenced.'
614
+ : 'Stops PostgreSQL on this standby while keeping its Pod and volume for investigation.',
615
+ body: (
616
+ <div className="space-y-1.5">
617
+ <label className="flex items-center gap-2 text-sm">
618
+ <span className="text-xs text-theme-text-secondary">Instance</span>
619
+ <select value={fenceSel} onChange={(e) => setFenceSel(e.target.value)} className="rounded-lg border border-theme-border bg-theme-base px-2 py-1 text-sm">
620
+ <option value="" disabled>Choose an instance</option>
621
+ {choices.map((c) => (
622
+ <option key={c} value={c} disabled={!!refusal(c)}>
623
+ {c === '*' ? 'All instances — stops service' : c}{c === facts.currentPrimary ? ' (primary)' : ''}{refusal(c) ? ' — cannot be fenced' : ''}
624
+ </option>
625
+ ))}
626
+ </select>
627
+ </label>
628
+ {refused.map((r) => (
629
+ <div key={r.pod} className="text-xs text-theme-text-secondary">
630
+ <span className="font-mono">{r.pod}</span> cannot be fenced: {r.reason}
631
+ </div>
632
+ ))}
633
+ </div>
634
+ ),
635
+ writes: fenceSel ? [{ summary: `patch Cluster ${namespace}/${name}`, detail: `metadata.annotations["cnpg.io/fencedInstances"] = ${JSON.stringify(JSON.stringify(next))}` }] : [],
636
+ scope: { kind: 'metadata', paths: ['metadata.annotations["cnpg.io/fencedInstances"]'] },
637
+ typed: fencesPrimary,
638
+ disruptive: fencesPrimary,
639
+ params: { instances: fenceSel === '*' ? '*' : [fenceSel] },
640
+ invalid: fencingMalformed ? 'The cnpg.io/fencedInstances annotation is not valid JSON; fix it in YAML first.' : selRefusal ? `${fenceSel} cannot be fenced: ${selRefusal}` : undefined,
641
+ incomplete: !fenceSel ? 'Choose an instance to fence.' : undefined,
642
+ success: () => 'Fencing requested.',
643
+ }
644
+ }
645
+ case 'unfence': {
646
+ const choices = allFenced ? ['*'] : ['*', ...fenced]
647
+ return {
648
+ title: `Lift fencing on ${name}?`,
649
+ confirmLabel: 'Lift fencing',
650
+ effect: 'Starts PostgreSQL again on the selected fenced instances. An instance fenced on purpose (for example during an investigation) rejoins the cluster.',
651
+ body: (
652
+ <label className="flex items-center gap-2 text-sm">
653
+ <span className="text-xs text-theme-text-secondary">Instance</span>
654
+ <select value={fenceSel} onChange={(e) => setFenceSel(e.target.value)} className="rounded-lg border border-theme-border bg-theme-base px-2 py-1 text-sm">
655
+ {choices.map((c) => <option key={c} value={c}>{c === '*' ? 'All fenced instances' : c}</option>)}
656
+ </select>
657
+ </label>
658
+ ),
659
+ notes: allFenced ? ['The whole cluster is fenced ("*"); fencing can only be lifted for every instance at once here.'] : [],
660
+ writes: [
661
+ {
662
+ summary: `patch Cluster ${namespace}/${name}`,
663
+ detail: fenceSel === '*' ? 'metadata.annotations["cnpg.io/fencedInstances"] removed' : `metadata.annotations["cnpg.io/fencedInstances"] = ${JSON.stringify(JSON.stringify(fenced.filter((f) => f !== fenceSel)))}`,
664
+ },
665
+ ],
666
+ scope: { kind: 'metadata', paths: ['metadata.annotations["cnpg.io/fencedInstances"]'] },
667
+ typed: true,
668
+ disruptive: true,
669
+ params: { instances: fenceSel === '*' ? '*' : [fenceSel] },
670
+ invalid: fencingMalformed ? 'The cnpg.io/fencedInstances annotation is not valid JSON; fix it in YAML first.' : fenced.length === 0 ? 'No instance is fenced.' : undefined,
671
+ success: () => 'Fencing lifted.',
672
+ }
673
+ }
674
+ case 'hibernate': {
675
+ const fx = caps.hibernateEffects
676
+ return {
677
+ title: `Hibernate ${name}?`,
678
+ confirmLabel: 'Hibernate',
679
+ effect: 'Shuts down every instance and deletes their Pods, primary first. Volumes are kept so the cluster can resume where it stopped.',
680
+ body: fx ? (
681
+ <ul className="list-disc space-y-1 pl-5 text-xs text-theme-text-secondary">
682
+ <EffectItem list={{ available: fx.volumes.available, reason: fx.volumes.reason, names: fx.volumes.items.map((v) => `${v.name} (${v.capacity ? `capacity ${v.capacity}` : 'capacity not reported'}${v.requested ? `; requested ${v.requested}` : ''})`) }} label="Kept PVCs" />
683
+ <EffectItem list={fx.poolers} label="Poolers that lose their backend" />
684
+ <EffectItem list={fx.unsuspendedScheduledBackups} label="Schedules still active (each run will fail until it resumes)" />
685
+ <EffectItem list={{ available: fx.databases.available && fx.publications.available && fx.subscriptions.available, names: [...fx.databases.names, ...fx.publications.names, ...fx.subscriptions.names] }} label="Declarations that stop reconciling" />
686
+ </ul>
687
+ ) : undefined,
688
+ writes: [{ summary: `patch Cluster ${namespace}/${name}`, detail: 'metadata.annotations["cnpg.io/hibernation"] = "on"' }],
689
+ scope: { kind: 'metadata', paths: ['metadata.annotations["cnpg.io/hibernation"]'] },
690
+ typed: true,
691
+ disruptive: true,
692
+ success: () => 'Hibernation requested. Pods shut down in order.',
693
+ }
694
+ }
695
+ case 'rehydrate':
696
+ return {
697
+ title: `Resume ${name} from hibernation?`,
698
+ confirmLabel: 'Resume',
699
+ effect: 'Recreates the instance Pods on the kept volumes, primary first.',
700
+ writes: [{ summary: `patch Cluster ${namespace}/${name}`, detail: 'metadata.annotations["cnpg.io/hibernation"] = "off"' }],
701
+ scope: { kind: 'metadata', paths: ['metadata.annotations["cnpg.io/hibernation"]'] },
702
+ success: () => 'Resume requested.',
703
+ }
704
+ case 'setMaintenance':
705
+ case 'unsetMaintenance': {
706
+ const setting = kind === 'setMaintenance'
707
+ const single = facts.instances.length <= 1
708
+ return {
709
+ title: setting ? `Put ${name} in node maintenance?` : `Lift node maintenance on ${name}?`,
710
+ confirmLabel: setting ? 'Set maintenance' : 'Lift maintenance',
711
+ effect: setting ? (
712
+ <>
713
+ Tells the operator that node maintenance is under way. While it lasts, self-healing, rolling updates and the PodDisruptionBudgets are limited:
714
+ {reusePVC
715
+ ? ' an instance on a drained node waits for that node to return and restarts on the same volume, and the disruption budget is removed so the drain can proceed.'
716
+ : ' an instance on a drained node is recreated on another node with a new volume cloned from the primary, and the old volume is deleted.'}{' '}
717
+ Keep the window as short as possible.
718
+ </>
719
+ ) : (
720
+ 'Tells the operator maintenance is over: self-healing, rolling updates and disruption budgets return to normal.'
721
+ ),
722
+ body: (
723
+ <label className="flex items-start gap-2 text-sm">
724
+ <input type="checkbox" className="mt-0.5" checked={reusePVC} onChange={(e) => setReusePVC(e.target.checked)} />
725
+ <span>
726
+ Reuse volumes (<span className="font-mono">reusePVC</span>)
727
+ <span className="block text-xs text-theme-text-secondary">
728
+ {setting ? 'On: wait for the node and keep the data volume. Off: rebuild the instance elsewhere (slow for large databases).' : `Currently ${facts.maintenance.reusePVC ? 'on' : 'off'}; kubectl cnpg maintenance unset writes this value too.`}
729
+ </span>
730
+ </span>
731
+ </label>
732
+ ),
733
+ notes: [
734
+ 'CloudNativePG recommends managing drains with PodDisruptionBudgets (spec.enablePDB) instead; this mode is kept for local-storage setups.',
735
+ ...(setting && single && !reusePVC ? ['With one instance and volume reuse off, the operator still refuses to drain its node: deleting the only instance would lose the data.'] : []),
736
+ ],
737
+ warnings: setting && ha.data?.pdbs.state === 'ok' && ha.data.pdbs.items.length > 0 && reusePVC ? ['The Cluster’s PodDisruptionBudgets are removed while maintenance is in progress, so drains are no longer held back.'] : [],
738
+ writes: [{ summary: `patch Cluster ${namespace}/${name}`, detail: `spec.nodeMaintenanceWindow = {"inProgress": ${setting}, "reusePVC": ${reusePVC}}` }],
739
+ scope: { kind: 'spec', paths: ['spec.nodeMaintenanceWindow.inProgress', 'spec.nodeMaintenanceWindow.reusePVC'] },
740
+ typed: setting,
741
+ disruptive: setting,
742
+ params: { reusePVC },
743
+ success: () => (setting ? 'Maintenance mode set. Lift it as soon as the node work is done.' : 'Maintenance mode lifted.'),
744
+ }
745
+ }
746
+ case 'restartInstance':
747
+ if (!instance) return UNSUPPORTED
748
+ {
749
+ return {
750
+ title: `Restart ${instance.pod}?`,
751
+ confirmLabel: 'Restart instance',
752
+ effect: instanceIsPrimary
753
+ ? 'Restarts PostgreSQL on the primary in place. Writes stop until it is back; no switchover happens.'
754
+ : 'Deletes this standby’s Pod; the operator recreates it on the same volume and it catches up from the primary.',
755
+ warnings: [
756
+ !instanceIsPrimary && facts.instances.filter((i) => i.pod !== facts.currentPrimary).length <= 1
757
+ ? 'This is the only standby: while it restarts there is no failover target, and a synchronous quorum may block writes.'
758
+ : null,
759
+ ].filter(Boolean) as string[],
760
+ writes: instanceIsPrimary
761
+ ? [{ summary: `patch Cluster ${namespace}/${name} status (subresource)`, detail: 'status.phase = "Primary instance is being restarted in-place"\nstatus.phaseReason = "Requested by the user"' }]
762
+ : [{ summary: `delete Pod ${namespace}/${instance.pod}`, detail: `preconditions.uid = ${instance.podUID}` }],
763
+ scope: instanceIsPrimary ? { kind: 'status' } : { kind: 'delete-operator-owned', owner: `Cluster ${name}` },
764
+ typed: instanceIsPrimary,
765
+ disruptive: true,
766
+ params: { pod: instance.pod, podUID: instance.podUID },
767
+ success: () => `Restart of ${instance.pod} requested.`,
768
+ }
769
+ }
770
+ default:
771
+ return UNSUPPORTED
772
+ }
773
+ })()
774
+
775
+ const guard = useCNPGWriteGuard({ namespace, name, scope: spec.scope })
776
+ const operatorNote = cnpgOperatorActionNote(caps.operator)
777
+
778
+ const confirm = () => {
779
+ mutation.mutate(
780
+ {
781
+ action: kind,
782
+ request: { reviewedContext: caps.context, uid: caps.uid, facts: facts, params: spec.params },
783
+ },
784
+ {
785
+ onSuccess: (result) => {
786
+ showSuccess(spec.success(result))
787
+ const tracked = trackedOperationFor(kind, { result, facts, switchTarget, chosenPodUID: chosenStandby?.podUID, fenceSel, fenced, instance })
788
+ if (tracked) trackCNPGOperation({ ...tracked, context: caps.context, namespace, cluster: name, clusterUID: caps.uid })
789
+ onClose()
790
+ },
791
+ },
792
+ )
793
+ }
794
+
795
+ return (
796
+ <ActionConfirmDialog
797
+ open
798
+ onClose={onClose}
799
+ onConfirm={confirm}
800
+ title={spec.title}
801
+ subject={{ kind: 'Cluster', namespace, name }}
802
+ context={caps.context}
803
+ effect={spec.effect}
804
+ notes={operatorNote?.tone === 'info' ? [...(spec.notes ?? []), operatorNote.text] : spec.notes}
805
+ warnings={operatorNote?.tone === 'warning' ? [operatorNote.text, ...(spec.warnings ?? [])] : spec.warnings}
806
+ guard={guard.node}
807
+ guardSatisfied={guard.satisfied}
808
+ writes={spec.writes}
809
+ typedConfirmation={spec.typed ? name : undefined}
810
+ confirmLabel={spec.confirmLabel}
811
+ disruptive={spec.disruptive}
812
+ disabledReason={!cap.allowed ? capabilityTitle(cap) : spec.invalid}
813
+ incompleteReason={spec.incomplete}
814
+ isLoading={mutation.isPending}
815
+ result={actionDialogResult(mutation.outcomeError ?? mutation.error, { unknown: "An earlier write to this Cluster may have taken effect" })}
816
+ >
817
+ {spec.body}
818
+ </ActionConfirmDialog>
819
+ )
820
+ }
821
+
822
+ export function CNPGRestartReview({ caps, problems = [], onOpenOverview }: { caps: CNPGClusterCapabilities; problems?: CNPGProblem[]; onOpenOverview?: () => void }) {
823
+ const instances = caps.facts.instances
824
+ const other = instances.filter((i) => i.pod !== caps.facts.currentPrimary)
825
+ const noOtherReady = other.every((i) => i.podReadable && !i.ready)
826
+ const primary = caps.restartPlan?.steps.find((s) => s.role === 'primary')
827
+ const waiting = instances.filter((i) => i.podReadable && (!i.podExists || !i.ready))
828
+ const primaryWillRestart = primary && ['restart', 'restart_only_instance', 'switchover'].includes(primary.effect)
829
+ return (
830
+ <div className="space-y-2 text-xs text-theme-text-secondary">
831
+ {waiting.length > 0 && <p className={toneTextClass('degraded')}>The rolling restart waits for every instance to be ready. Currently blocked by {waiting.map((i) => i.pod).join(', ')}.</p>}
832
+ {primaryWillRestart && <p>Restarting the primary interrupts its connections.{primary.effect === 'restart_only_instance' ? ' There is no other instance: the cluster stops serving until the primary is back.' : noOtherReady ? ' No other instance is ready now. If the primary restarts without another ready instance, the cluster stops serving until it is back.' : !other.some((i) => i.ready) ? ' Other instances’ readiness could not be read; service continuity is unknown.' : ''}</p>}
833
+ {primary?.effect === 'wait_for_user' && <p>The primary waits for your manual promotion or restart. Restarting it later interrupts its connections.</p>}
834
+ {primary?.effect === 'skipped_fenced' && <p>The fenced primary is skipped.</p>}
835
+ {caps.restartPlan && <>
836
+ {primary && ['switchover', 'restart'].includes(primary.effect) && <p>The operator updates the primary automatically after the standbys: {primary.effect === 'switchover' ? 'it promotes an updated standby, then recreates the former primary Pod.' : 'it restarts the primary in place.'}</p>}
837
+ <div>Planned order:</div>
838
+ <ol className="list-decimal space-y-1 pl-5">
839
+ {caps.restartPlan.steps.map((s) => {
840
+ const instance = instances.find((i) => i.pod === s.instance)
841
+ const blockers = problems.filter((p) => p.instance === s.instance || p.subject.kind === 'Pod' && p.subject.name === s.instance)
842
+ return <li key={s.instance}>
843
+ <span className="font-mono">{s.instance}</span> : {RESTART_EFFECT[s.effect] ?? s.effect}
844
+ <span> (currently {instance?.podReadable ? instance.podExists ? instance.ready ? 'ready' : 'not ready' : 'absent' : 'readiness not read'})</span>
845
+ {blockers.map((p) => <div key={p.id} className={toneTextClass('degraded')}>{p.title}{p.detail ? ` · ${p.detail}` : ''}{onOpenOverview && <button type="button" onClick={onOpenOverview} className="ml-1 text-accent-text hover:underline">See Overview’s problems →</button>}</div>)}
846
+ </li>
847
+ })}
848
+ </ol>
849
+ <FoldSection title="Operator update settings" summary="" attention={false}><div>primaryUpdateStrategy: {caps.restartPlan.primaryUpdateStrategy}</div><div>primaryUpdateMethod: {caps.restartPlan.primaryUpdateMethod}</div></FoldSection>
850
+ </>}
851
+ </div>
852
+ )
853
+ }