@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,40 @@
1
+ // @vitest-environment jsdom
2
+ import { act, type ReactNode } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { MemoryRouter } from 'react-router-dom'
5
+ import { expect, it, vi } from 'vitest'
6
+ vi.mock('../../../api/cnpg-ha', () => ({ useCNPGClusterHA: () => ({ data: { rwEndpoints: { state: 'ok', pods: ['orders-1'] }, pods: { state: 'ok' }, instances: [{ pod: 'orders-1', role: 'primary', ready: true }] } }) }))
7
+ vi.mock('../useCNPGKubectlContext', () => ({ useCNPGKubectlContext: () => ({ name: 'kind-test', source: 'test-config' }) }))
8
+ import { CNPGConnectButton } from './CNPGConnectButton'
9
+ const state = vi.hoisted(() => ({ refetch: vi.fn(), retained: false, row: undefined as any }))
10
+ vi.mock('../useCNPGSidebarWorkspace', () => ({ useCNPGFleet: () => ({ fleet: state.row ? { rows: [state.row] } : state.retained ? { rows: [] } : null, query: { data: state.retained ? { installed: true } : undefined, error: new Error('Read failed'), refetch: state.refetch, isRefetchError: state.retained, dataUpdatedAt: Date.now() - 120_000 } }) }))
11
+ vi.mock('@skyhook-io/k8s-ui', async (original) => ({ ...(await original<typeof import('@skyhook-io/k8s-ui')>()), DialogPortal: ({ open, children }: { open: boolean; children: ReactNode }) => open ? <div>{children}</div> : null }))
12
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
13
+ it('shows and retries a failed Cluster read in Connect', () => {
14
+ const host = document.createElement('div'); document.body.append(host); const root = createRoot(host)
15
+ act(() => root.render(<MemoryRouter><CNPGConnectButton namespace="db" name="pg" /></MemoryRouter>))
16
+ act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Connect')!.click())
17
+ expect(host.textContent).toContain('Cluster could not be read: Read failed')
18
+ expect(host.textContent).not.toContain('Reading the Cluster')
19
+ act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Retry')!.click())
20
+ expect(state.refetch).toHaveBeenCalledTimes(1)
21
+ state.retained = true
22
+ act(() => root.render(<MemoryRouter><CNPGConnectButton namespace="db" name="pg" /></MemoryRouter>))
23
+ expect(host.textContent).toContain('Radar cannot read this Cluster with your access')
24
+ expect(host.textContent).toContain('Last refresh failed: Read failed')
25
+ expect(host.textContent).toContain('2m ago')
26
+ expect(host.textContent).not.toContain('Cluster could not be read:')
27
+ act(() => root.unmount()); host.remove()
28
+ })
29
+
30
+ it('passes the known host context and independently read HA availability into the Connect dialog', () => {
31
+ state.row = { namespace: 'db', name: 'orders', cluster: { metadata: { name: 'orders', namespace: 'db' }, spec: {} } }
32
+ const host = document.createElement('div'); document.body.append(host); const root = createRoot(host)
33
+ act(() => root.render(<MemoryRouter><CNPGConnectButton namespace="db" name="orders" /></MemoryRouter>))
34
+ act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Connect')!.click())
35
+ expect(host.textContent).toContain('kubectl --context kind-test -n db port-forward service/orders-rw')
36
+ expect(host.textContent).toContain('Context as named in your kubeconfig (test-config); kubectl must read the same kubeconfig file.')
37
+ expect(host.textContent).toContain('Unavailable: no ready standby')
38
+ expect(host.textContent).toContain('Ready endpoints')
39
+ act(() => root.unmount()); host.remove(); state.row = undefined
40
+ })
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { claimConnectRequest, cnpgConnectParamValue, registerConnectButton } from './CNPGConnectButton'
3
+
4
+ describe('Connect requests', () => {
5
+ it('names the Cluster and the surface', () => {
6
+ expect(cnpgConnectParamValue('db', 'pg', 'page')).toBe('db/pg')
7
+ expect(cnpgConnectParamValue('db', 'pg', 'drawer')).toBe('db/pg@drawer')
8
+ })
9
+
10
+ it('lets one mounted button answer a request, and a remount answer it again', () => {
11
+ const page = Symbol('page')
12
+ const drawer = Symbol('drawer')
13
+ const unregisterPage = registerConnectButton(page)
14
+ const unregisterDrawer = registerConnectButton(drawer)
15
+ expect(claimConnectRequest('entry-1', page)).toBe(true)
16
+ expect(claimConnectRequest('entry-1', drawer)).toBe(false)
17
+ expect(claimConnectRequest('entry-2', drawer)).toBe(true)
18
+
19
+ unregisterPage()
20
+ const remounted = Symbol('page again')
21
+ registerConnectButton(remounted)
22
+ unregisterDrawer()
23
+ expect(claimConnectRequest('entry-2', remounted)).toBe(true)
24
+ })
25
+ })
@@ -0,0 +1,131 @@
1
+ import { useCNPGClusterHA } from '../../../api/cnpg-ha'
2
+ import { useCNPGKubectlContext } from '../useCNPGKubectlContext'
3
+ import { useEffect, useId, useMemo, useState } from 'react'
4
+ import { useLocation, useSearchParams } from 'react-router-dom'
5
+ import { Plug, X } from 'lucide-react'
6
+ import { CNPGConnectSection, DialogPortal, Tooltip, type ResourceRef, type NavigateToResource } from '@skyhook-io/k8s-ui'
7
+ import { buildWorkloadPath, refToSelectedResource } from '../../../utils/navigation'
8
+ import { useCNPGFleet } from '../useCNPGSidebarWorkspace'
9
+ import { Notice, RefreshFailedNotice } from '../../workspace/layout'
10
+ import { useCNPGNavigate } from '../useCNPGNavigate'
11
+
12
+ // A link can ask for the dialog (the restore "Next steps") with this param;
13
+ // the button opens it and drops the param, so closing it and following a link
14
+ // from it never write the URL at once. The value names the Cluster and the
15
+ // surface, so a drawer showing the page's own Cluster never opens a second one.
16
+ export const CNPG_CONNECT_PARAM = 'connect'
17
+
18
+ export function cnpgConnectParamValue(namespace: string, name: string, surface: 'page' | 'drawer'): string {
19
+ return surface === 'drawer' ? `${namespace}/${name}@drawer` : `${namespace}/${name}`
20
+ }
21
+
22
+ // A request is answered by one button: a full-screen drawer can carry the
23
+ // same surface as the page beneath it, and both see the request in the same
24
+ // commit. A button that answered and then unmounted (before the param was
25
+ // dropped) gives the request back, so its remount can answer it.
26
+ const mountedButtons = new Set<symbol>()
27
+ let answered: { key: string; by: symbol } | null = null
28
+
29
+ export function registerConnectButton(id: symbol): () => void {
30
+ mountedButtons.add(id)
31
+ return () => {
32
+ mountedButtons.delete(id)
33
+ }
34
+ }
35
+
36
+ /** True when this button should open the dialog for the request at locationKey. */
37
+ export function claimConnectRequest(locationKey: string, id: symbol): boolean {
38
+ if (answered && answered.key === locationKey && answered.by !== id && mountedButtons.has(answered.by)) return false
39
+ answered = { key: locationKey, by: id }
40
+ return true
41
+ }
42
+
43
+ export function CNPGConnectButton({
44
+ namespace,
45
+ name,
46
+ compact = false,
47
+ onNavigate,
48
+ }: {
49
+ namespace: string
50
+ name: string
51
+ compact?: boolean
52
+ onNavigate?: NavigateToResource
53
+ }) {
54
+ const kubeconfigContext = useCNPGKubectlContext()
55
+ const titleId = useId()
56
+ const location = useLocation()
57
+ const [params, setParams] = useSearchParams()
58
+ const [open, setOpen] = useState(false)
59
+ const ha = useCNPGClusterHA(namespace, name, { enabled: open, refetchInterval: false })
60
+ const id = useMemo(() => Symbol('cnpg-connect'), [])
61
+ useEffect(() => registerConnectButton(id), [id])
62
+ const requested = params.get(CNPG_CONNECT_PARAM) === cnpgConnectParamValue(namespace, name, compact ? 'drawer' : 'page')
63
+ useEffect(() => {
64
+ if (!requested || !claimConnectRequest(location.key, id)) return
65
+ setOpen(true)
66
+ // Keeps the page's return label, which lives in the history state.
67
+ setParams(
68
+ (prev) => {
69
+ const p = new URLSearchParams(prev)
70
+ p.delete(CNPG_CONNECT_PARAM)
71
+ return p
72
+ },
73
+ { replace: true, state: location.state },
74
+ )
75
+ }, [requested, id, setParams, location.key, location.state])
76
+ const { query, fleet } = useCNPGFleet([namespace], open)
77
+ const row = fleet?.rows.find((r) => r.namespace === namespace && r.name === name)
78
+ const go = onNavigate
79
+ ? (ref: ResourceRef) => {
80
+ setOpen(false)
81
+ onNavigate(refToSelectedResource(ref))
82
+ }
83
+ : undefined
84
+ const navigate = useCNPGNavigate()
85
+ const openReachability = (svc: { namespace: string; name: string }) => {
86
+ setOpen(false)
87
+ navigate(buildWorkloadPath({ kind: 'services', group: '', namespace: svc.namespace, name: svc.name, tab: 'reachability' }))
88
+ }
89
+
90
+ return (
91
+ <>
92
+ <Tooltip content="Hosts, database, owner and the credentials Secret applications use" position="bottom">
93
+ <button
94
+ type="button"
95
+ onClick={() => setOpen(true)}
96
+ aria-label={compact ? 'Connect' : undefined}
97
+ className="btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium"
98
+ >
99
+ <Plug className="h-3.5 w-3.5" />
100
+ {!compact && 'Connect'}
101
+ </button>
102
+ </Tooltip>
103
+ <DialogPortal open={open} onClose={() => setOpen(false)} className="w-full max-w-3xl" ariaLabelledBy={titleId}>
104
+ <div className="flex items-start gap-3 border-b border-theme-border p-4">
105
+ <div className="min-w-0 flex-1">
106
+ <h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">
107
+ Connect to {name}
108
+ </h3>
109
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">From the Cluster spec · hosts resolve inside the Kubernetes cluster</p>
110
+ </div>
111
+ <button type="button" onClick={() => setOpen(false)} aria-label="Close" className="rounded p-1 text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary">
112
+ <X className="h-5 w-5" />
113
+ </button>
114
+ </div>
115
+ <div className="max-h-[70vh] overflow-y-auto p-4">
116
+ <RefreshFailedNotice queries={[query, ha]} />
117
+ {row?.cluster ? (
118
+ <CNPGConnectSection ha={ha.data} haUnavailableReason={ha.isLoading ? 'Reading availability…' : ha.error instanceof Error ? `Availability could not be read: ${ha.error.message}` : undefined} kubeconfigContext={kubeconfigContext?.name} kubeconfigSource={kubeconfigContext?.source} cluster={row.cluster} poolers={row.poolerObjects} poolersKnown={row.poolersKnown} onNavigate={go} onOpenReachability={openReachability} showHeading={false} />
119
+ ) : query.error && !query.data ? (
120
+ <Notice>
121
+ Cluster could not be read: {query.error instanceof Error ? query.error.message : 'unknown error'}.{' '}
122
+ <button type="button" onClick={() => void query.refetch()} disabled={query.isFetching} className="text-accent-text hover:underline">Retry</button>
123
+ </Notice>
124
+ ) : (
125
+ <div className="text-sm text-theme-text-tertiary">{fleet ? 'Radar cannot read this Cluster with your access.' : 'Reading the Cluster…'}</div>
126
+ )}
127
+ </div>
128
+ </DialogPortal>
129
+ </>
130
+ )
131
+ }
@@ -0,0 +1,185 @@
1
+ import { useState } from 'react'
2
+ import { ActionConfirmDialog, PaneLoader } from '@skyhook-io/k8s-ui'
3
+ import { cnpgActionErrorCode, useCNPGAction } from '../../../api/cnpg'
4
+ import { actionCompleted, actionDialogResult } from '../../../api/actions'
5
+ import { useCNPGDestroyPlan } from '../../../api/cnpg-sessions'
6
+ import { useToast } from '../../ui/Toast'
7
+ import { trackCNPGOperation } from '../operations/store'
8
+ import { cnpgDestroyBlocker } from './actionModel'
9
+
10
+ /**
11
+ * `kubectl cnpg destroy` for one standby: its volumes are deleted (or kept,
12
+ * detached), then its Pod and its Jobs. The operator replaces it with a new
13
+ * instance under a new name. Binds the Pod and volume identities reviewed.
14
+ */
15
+ export function CNPGDestroyInstanceDialog({
16
+ namespace,
17
+ cluster,
18
+ pod,
19
+ onClose,
20
+ onFenceFirst,
21
+ }: {
22
+ namespace: string
23
+ cluster: string
24
+ pod: string
25
+ onClose: () => void
26
+ /** Opens the fence dialog for this instance; destroy requires the fence. */
27
+ onFenceFirst?: () => void
28
+ }) {
29
+ const plan = useCNPGDestroyPlan(namespace, cluster, pod)
30
+ const mutation = useCNPGAction('clusters', namespace, cluster, plan.data?.context)
31
+ const { showSuccess } = useToast()
32
+ const [keep, setKeep] = useState(false)
33
+ const data = plan.data
34
+ const cap = data ? (keep ? data.actions.keep : data.actions.delete) : undefined
35
+ const pvcNames = data?.pvcs.map((p) => p.name) ?? []
36
+ const partial = cnpgActionErrorCode(mutation.outcomeError ?? mutation.error) === 'partial'
37
+ const fenced = !!data && (data.facts.fencedInstances.all || data.facts.fencedInstances.instances.includes(pod))
38
+ const completed = actionCompleted(mutation.outcomeError ?? mutation.error)
39
+ const fenceOffered = !!data && !fenced && !!onFenceFirst
40
+
41
+ return (
42
+ <ActionConfirmDialog
43
+ open
44
+ onClose={onClose}
45
+ onConfirm={() => {
46
+ if (!data) return
47
+ mutation.mutate(
48
+ {
49
+ action: 'destroyInstance',
50
+ request: {
51
+ reviewedContext: data.context,
52
+ uid: data.uid,
53
+ facts: data.facts,
54
+ params: { pod, podUID: data.podUID, keepPVC: keep, pvcs: data.pvcs.map((p) => ({ name: p.name, uid: p.uid })), jobs: data.jobs },
55
+ },
56
+ },
57
+ {
58
+ onSuccess: (r) => {
59
+ showSuccess(r.message)
60
+ trackCNPGOperation({
61
+ kind: 'destroyInstance',
62
+ label: `Destroy ${pod}`,
63
+ context: data.context,
64
+ namespace,
65
+ cluster,
66
+ clusterUID: data.uid,
67
+ target: { name: pod, uid: data.podUID },
68
+ baseline: { instances: data.facts.instances.map((i) => i.pod) },
69
+ })
70
+ onClose()
71
+ },
72
+ },
73
+ )
74
+ }}
75
+ title={`Destroy instance ${pod}?`}
76
+ subject={{ kind: 'Cluster', namespace, name: cluster }}
77
+ context={data?.context}
78
+ effect={
79
+ keep
80
+ ? `Deletes the Pod ${pod} and detaches its volumes from the cluster, keeping their data. The operator then creates a new instance with a new name and fresh volumes, which joins as a standby.`
81
+ : `Deletes the Pod ${pod} and its volumes${pvcNames.length ? ` (${pvcNames.join(', ')})` : ''}; their data is gone. The operator then creates a new instance with a new name and fresh volumes, which joins as a standby.`
82
+ }
83
+ disruptive
84
+ typedConfirmation={pod}
85
+ confirmLabel={keep ? 'Destroy, keep volumes' : 'Destroy instance'}
86
+ writes={
87
+ data
88
+ ? [
89
+ ...data.pvcs.map((p) =>
90
+ keep && p.owned
91
+ ? { summary: `update PersistentVolumeClaim ${namespace}/${p.name}`, detail: `ownerReferences: Cluster ${cluster} removed\nmetadata.annotations["cnpg.io/pvcStatus"] = "detached"` }
92
+ : keep
93
+ ? { summary: `leave PersistentVolumeClaim ${namespace}/${p.name}`, detail: 'already detached' }
94
+ : { summary: `delete PersistentVolumeClaim ${namespace}/${p.name}`, detail: `preconditions.uid = ${p.uid}` },
95
+ ),
96
+ { summary: `delete Pod ${namespace}/${pod}`, detail: data.podUID ? `preconditions.uid = ${data.podUID}` : 'the Pod no longer exists' },
97
+ { summary: `delete reviewed Jobs of ${pod}`, detail: data.jobsReadable ? (data.jobs.length ? data.jobs.map((j) => `${j.name} · preconditions.uid = ${j.uid}`).join('\n') : 'none now') : 'not readable; review is unavailable' },
98
+ { summary: `patch Cluster ${namespace}/${cluster}`, detail: `metadata.annotations["cnpg.io/fencedInstances"]: ${pod} removed (unless fenced with ["*"])` },
99
+ ]
100
+ : []
101
+ }
102
+ notes={[
103
+ 'The volumes are handled before the Pod, as kubectl cnpg destroy does, so the operator never recreates this Pod on them.',
104
+ fenceOffered
105
+ ? `Once ${pod} is destroyed its name is removed from the fence (a fence on every instance, ["*"], stays).`
106
+ : `${pod} must be fenced first; its name is removed from the fence once it is destroyed (a fence on every instance, ["*"], stays).`,
107
+ 'Use this for a standby that cannot rejoin (for example after a failed pg_rewind or a corrupted volume). A plain restart deletes only the Pod and keeps the volumes attached.',
108
+ 'Only volumes owned by this Cluster or detached by Radar from this Cluster are included.',
109
+ keep ? 'Kept volumes stay in the namespace, no longer owned by the cluster; delete them yourself once you no longer need them.' : null,
110
+ ].filter(Boolean) as string[]}
111
+ disabledReason={
112
+ plan.isLoading
113
+ ? 'Reading the instance’s volumes…'
114
+ : !data
115
+ ? plan.error instanceof Error ? plan.error.message : 'The destroy plan could not be read'
116
+ : cnpgDestroyBlocker(cap, fenceOffered)
117
+ }
118
+ guardSatisfied={!!cap?.allowed}
119
+ isLoading={mutation.isPending}
120
+ result={actionDialogResult(mutation.outcomeError ?? mutation.error, { unknown: 'An earlier write to this Cluster may have taken effect', partial: 'Earlier writes to this Cluster partially completed' })}
121
+ >
122
+ {partial && completed.length > 0 && (
123
+ <div className="rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm">
124
+ <div className="mb-1 text-xs text-theme-text-secondary">Writes completed by that earlier attempt</div>
125
+ <ul className="space-y-0.5">
126
+ {completed.map((c) => (
127
+ <li key={c} className="font-mono text-xs text-theme-text-primary">
128
+ {c}
129
+ </li>
130
+ ))}
131
+ </ul>
132
+ <div className="mt-2 text-xs text-theme-text-tertiary">Check the resources named above before doing anything else; writes to this Cluster are disabled.</div>
133
+ </div>
134
+ )}
135
+ {plan.isLoading && <PaneLoader label="Reading the instance’s volumes…" className="h-16" />}
136
+ {fenceOffered && (
137
+ <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
138
+ <span>{pod} must be fenced before it is destroyed: the operator never promotes a fenced instance, so a failover cannot make it primary meanwhile.</span>
139
+ <button type="button" onClick={onFenceFirst} className="btn-brand rounded-lg px-3 py-1.5 text-sm font-medium">
140
+ Fence {pod} first
141
+ </button>
142
+ </div>
143
+ )}
144
+ {data && (
145
+ <div className="space-y-3">
146
+ <div>
147
+ <div className="mb-1 text-xs text-theme-text-secondary">Volumes of {pod}</div>
148
+ {!data.pvcsReadable ? (
149
+ <div className="text-sm text-theme-text-tertiary">Not readable: {data.pvcReason}</div>
150
+ ) : data.pvcs.length === 0 ? (
151
+ <div className="text-sm text-theme-text-tertiary">None found for this instance.</div>
152
+ ) : (
153
+ <ul className="space-y-0.5 text-sm">
154
+ {data.pvcs.map((p) => (
155
+ <li key={p.uid} className="font-mono text-xs">
156
+ {p.name}
157
+ <span className="font-sans text-theme-text-tertiary">
158
+ {' · '}
159
+ {[p.role, p.tablespace, p.capacity, p.storageClass, p.detached ? 'detached earlier' : null].filter(Boolean).join(' · ')}
160
+ </span>
161
+ </li>
162
+ ))}
163
+ </ul>
164
+ )}
165
+ </div>
166
+ <fieldset className="space-y-1.5">
167
+ <legend className="mb-1 text-xs text-theme-text-secondary">Volumes</legend>
168
+ <label className="flex items-start gap-2 text-sm">
169
+ <input type="radio" name="cnpg-destroy-pvc" checked={!keep} onChange={() => setKeep(false)} className="mt-1" />
170
+ <span>
171
+ Delete them <span className="text-theme-text-tertiary">— the data on this standby is lost; the primary and the other standbys are unaffected</span>
172
+ </span>
173
+ </label>
174
+ <label className="flex items-start gap-2 text-sm">
175
+ <input type="radio" name="cnpg-destroy-pvc" checked={keep} onChange={() => setKeep(true)} className="mt-1" />
176
+ <span>
177
+ Keep them, detached <span className="text-theme-text-tertiary">(--keep-pvc) — for inspecting the old data; they still use storage</span>
178
+ </span>
179
+ </label>
180
+ </fieldset>
181
+ </div>
182
+ )}
183
+ </ActionConfirmDialog>
184
+ )
185
+ }
@@ -0,0 +1,38 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { afterEach, expect, it, vi } from 'vitest'
5
+ import { CNPGInstanceActions } from './CNPGInstanceActions'
6
+
7
+ const state = vi.hoisted(() => ({ data: {} as any }))
8
+ vi.mock('../../../api/cnpg', () => ({ useCNPGClusterCapabilities: () => ({ data: state.data }) }))
9
+ vi.mock('./useOpenCNPGPsql', () => ({ useOpenCNPGPsql: () => vi.fn() }))
10
+ vi.mock('./CNPGClusterActions', () => ({ ClusterActionDialog: () => <div>Restart review opened</div> }))
11
+ vi.mock('./CNPGDestroyInstanceDialog', () => ({ CNPGDestroyInstanceDialog: () => null }))
12
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
13
+ let root: ReturnType<typeof createRoot>
14
+ let host: HTMLDivElement
15
+ afterEach(() => { act(() => root?.unmount()); host?.remove(); vi.useRealTimers() })
16
+ it('keeps blocked restart focusable, reveals its reason on hover, focus and click, and never opens the write dialog', () => {
17
+ vi.useFakeTimers()
18
+ const reason = 'A standby is still joining; wait until it is ready'
19
+ state.data = { facts: { instances: [{ pod: 'orders-1' }], currentPrimary: 'orders-1', fencedInstances: { all: false, instances: [] } }, actions: {}, instanceActions: { 'orders-1': { restart: { allowed: false, reason }, psql: { allowed: true }, fence: { allowed: true } } } }
20
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
21
+ act(() => root.render(<CNPGInstanceActions namespace="db" cluster="orders" pod="orders-1" />))
22
+ const restart = [...host.querySelectorAll('button')].find((b) => b.textContent === 'Restart')!
23
+ expect(restart.disabled).toBe(false)
24
+ expect(restart.getAttribute('aria-disabled')).toBe('true')
25
+ act(() => { restart.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); vi.advanceTimersByTime(350) })
26
+ expect(document.body.textContent).toContain(reason)
27
+ act(() => { restart.focus(); vi.advanceTimersByTime(350) })
28
+ expect(document.activeElement).toBe(restart)
29
+ expect(document.body.textContent).toContain(reason)
30
+ act(() => restart.click())
31
+ expect(host.querySelector('[role="status"]')?.textContent).toBe(reason)
32
+ expect(host.textContent).not.toContain('Restart review opened')
33
+ state.data.instanceActions['orders-1'].restart = { allowed: true }
34
+ act(() => root.render(<CNPGInstanceActions namespace="db" cluster="orders" pod="orders-1" />))
35
+ expect(host.querySelector('[role="status"]')).toBeNull()
36
+ act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Restart')!.click())
37
+ expect(host.textContent).toContain('Restart review opened')
38
+ })
@@ -0,0 +1,74 @@
1
+ import { useState } from 'react'
2
+ import { useCNPGClusterCapabilities, type CNPGClusterActionName } from '../../../api/cnpg'
3
+ import { type ActionCapability, capabilityReason } from '../../../api/actions'
4
+ import { ClusterActionDialog } from './CNPGClusterActions'
5
+ import { CNPGDestroyInstanceDialog } from './CNPGDestroyInstanceDialog'
6
+ import { useOpenCNPGPsql } from './useOpenCNPGPsql'
7
+ import { Tooltip } from '@skyhook-io/k8s-ui'
8
+
9
+ const LINK = 'text-accent-text hover:underline aria-disabled:cursor-not-allowed aria-disabled:text-theme-text-disabled aria-disabled:no-underline'
10
+
11
+ /** Per-instance operations shown on a replication row. */
12
+ export function CNPGInstanceActions({ namespace, cluster, pod }: { namespace: string; cluster: string; pod: string }) {
13
+ const caps = useCNPGClusterCapabilities(namespace, cluster)
14
+ const [open, setOpen] = useState<CNPGClusterActionName | null>(null)
15
+ const [blockedAction, setBlockedAction] = useState<CNPGClusterActionName | 'psql' | 'destroy'>()
16
+ const [destroying, setDestroying] = useState(false)
17
+ const openPsql = useOpenCNPGPsql()
18
+ const data = caps.data
19
+ if (!data) return null
20
+ const inst = data.facts.instances.find((i) => i.pod === pod)
21
+ if (!inst) return null
22
+ const isPrimary = pod === data.facts.currentPrimary
23
+ const allFenced = data.facts.fencedInstances.all
24
+ const isFenced = allFenced || data.facts.fencedInstances.instances.includes(pod)
25
+ const per = data.instanceActions?.[pod]
26
+ const capFor = (id: CNPGClusterActionName) =>
27
+ id === 'switchover' ? per?.switchoverTarget : id === 'restartInstance' ? per?.restart : id === 'fence' ? per?.fence : id === 'unfence' ? per?.unfence : data.actions[id]
28
+
29
+ const button = (key: CNPGClusterActionName | 'psql' | 'destroy', label: string, reason: string | undefined, onClick: () => void) => (
30
+ <Tooltip key={key} content={reason}>
31
+ <button type="button" aria-disabled={!!reason} onClick={() => { if (reason) setBlockedAction(key); else { setBlockedAction(undefined); onClick() } }} className={LINK}>
32
+ {label}
33
+ </button>
34
+ </Tooltip>
35
+ )
36
+ const link = (id: CNPGClusterActionName, label: string, extraReason?: string) => {
37
+ const cap = capFor(id) ?? data.actions[id]
38
+ return button(id, label, extraReason ?? capabilityReason(cap), () => setOpen(id))
39
+ }
40
+ const reasonOf = (cap: ActionCapability | undefined) => (!cap ? 'Not available for this instance' : capabilityReason(cap))
41
+
42
+ const blockedReason = blockedAction === 'psql' ? reasonOf(per?.psql) : blockedAction === 'destroy' ? (isFenced ? reasonOf(per?.destroy) : undefined) : blockedAction === 'unfence' && allFenced ? 'The whole cluster is fenced; lift it from the cluster menu' : blockedAction ? capabilityReason(capFor(blockedAction) ?? data.actions[blockedAction]) : undefined
43
+
44
+ return (
45
+ <>
46
+ {button('psql', 'psql', reasonOf(per?.psql), () => openPsql(namespace, pod, isPrimary))}
47
+ {!isPrimary && link('switchover', 'Promote')}
48
+ {link('restartInstance', 'Restart')}
49
+ {isFenced
50
+ ? link('unfence', 'Lift fence', allFenced ? 'The whole cluster is fenced; lift it from the cluster menu' : undefined)
51
+ : link('fence', 'Fence')}
52
+ {/* Unfenced, the dialog opens anyway: it explains the fence and offers it. */}
53
+ {!isPrimary && button('destroy', 'Destroy…', isFenced ? reasonOf(per?.destroy) : undefined, () => setDestroying(true))}
54
+ {blockedReason && <div role="status" className="basis-full text-xs text-theme-text-secondary">{blockedReason}</div>}
55
+ {open && <ClusterActionDialog kind={open} caps={data} namespace={namespace} name={cluster} initialPod={pod} onClose={() => setOpen(null)} />}
56
+ {destroying && (
57
+ <CNPGDestroyInstanceDialog
58
+ namespace={namespace}
59
+ cluster={cluster}
60
+ pod={pod}
61
+ onClose={() => setDestroying(false)}
62
+ onFenceFirst={
63
+ per?.fence?.allowed
64
+ ? () => {
65
+ setDestroying(false)
66
+ setOpen('fence')
67
+ }
68
+ : undefined
69
+ }
70
+ />
71
+ )}
72
+ </>
73
+ )
74
+ }
@@ -0,0 +1,53 @@
1
+ import { useState } from 'react'
2
+ import { AlertBanner, Tooltip } from '@skyhook-io/k8s-ui'
3
+ import { useCNPGClusterCapabilities } from '../../../api/cnpg'
4
+ import { ClusterActionDialog } from './CNPGClusterActions'
5
+ import { capabilityReason } from '../../../api/actions'
6
+
7
+ /**
8
+ * Standing notice while spec.nodeMaintenanceWindow.inProgress is true. The
9
+ * mode suspends self-healing, so it must never be forgotten on: the way out is
10
+ * on the banner itself.
11
+ */
12
+ export function CNPGMaintenanceBanner({
13
+ namespace,
14
+ name,
15
+ maintenance,
16
+ }: {
17
+ namespace: string
18
+ name: string
19
+ maintenance?: { inProgress: boolean; reusePVC: boolean }
20
+ }) {
21
+ const caps = useCNPGClusterCapabilities(namespace, name, !!maintenance?.inProgress)
22
+ const [open, setOpen] = useState(false)
23
+ if (!maintenance?.inProgress) return null
24
+ const cap = caps.data?.actions.unsetMaintenance
25
+ return (
26
+ <div className="mb-3">
27
+ <AlertBanner
28
+ variant="warning"
29
+ title="Node maintenance in progress"
30
+ message={
31
+ maintenance.reusePVC
32
+ ? 'Self-healing, rolling updates and disruption budgets are limited: an instance on a drained node waits for that node and keeps its volume. Lift this as soon as the node work is done.'
33
+ : 'Self-healing, rolling updates and disruption budgets are limited: an instance on a drained node is rebuilt elsewhere on a new volume. Lift this as soon as the node work is done.'
34
+ }
35
+ >
36
+ <div className="mt-2">
37
+ <Tooltip content={cap ? capabilityReason(cap) : undefined}>
38
+ <button
39
+ type="button"
40
+ disabled={!cap?.allowed}
41
+ onClick={() => setOpen(true)}
42
+ className="btn-brand px-3 py-1 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
43
+ >
44
+ Lift maintenance…
45
+ </button>
46
+ </Tooltip>
47
+ <span className="ml-2 text-xs text-theme-text-tertiary">spec.nodeMaintenanceWindow.inProgress</span>
48
+ </div>
49
+ </AlertBanner>
50
+ {open && caps.data && <ClusterActionDialog kind="unsetMaintenance" caps={caps.data} namespace={namespace} name={name} onClose={() => setOpen(false)} />}
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,35 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+ import { CNPGPoolerActions } from './CNPGPoolerActions'
6
+
7
+ const mocks = vi.hoisted(() => ({ mutate: vi.fn(), success: vi.fn() }))
8
+ vi.mock('../../ui/Toast', () => ({ useToast: () => ({ showSuccess: mocks.success }) }))
9
+ vi.mock('./useCNPGWriteGuard', () => ({ useCNPGWriteGuard: () => ({ node: null, satisfied: true }) }))
10
+ vi.mock('../../../api/cnpg', () => ({ useCNPGAction: () => ({ mutate: mocks.mutate, isPending: false }) }))
11
+ vi.mock('../../../api/cnpg-sessions', () => ({
12
+ useCNPGPoolerCapabilities: () => ({ data: {
13
+ uid: 'pooler-uid', resourceVersion: '9', context: 'kind-test',
14
+ facts: { generation: 4, cluster: 'payments', type: 'rw', paused: true, parameters: {}, terminating: false, deployment: { name: 'pooler', state: 'ok', readyReplicas: 2 }, service: { name: 'pooler', state: 'ok' } },
15
+ actions: { pause: { allowed: true }, resume: { allowed: true }, observeState: { allowed: true } },
16
+ } }),
17
+ }))
18
+
19
+ it('binds the reviewed generation along with the paused state', async () => {
20
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
21
+ const host = document.createElement('div')
22
+ document.body.append(host)
23
+ const root = createRoot(host)
24
+ try {
25
+ await act(async () => root.render(<CNPGPoolerActions namespace="prod" name="pooler" />))
26
+ await act(async () => [...host.querySelectorAll('button')].find((button) => button.textContent === 'Resume')!.click())
27
+ const confirm = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find((button) => button.textContent === 'Resume')!
28
+ expect(confirm).toBeDefined()
29
+ await act(async () => confirm.click())
30
+ expect(mocks.mutate.mock.calls[0][0]).toMatchObject({ action: 'resume', request: { reviewedContext: 'kind-test', uid: 'pooler-uid', facts: { paused: true, generation: 4 } } })
31
+ } finally {
32
+ await act(async () => root.unmount())
33
+ host.remove()
34
+ }
35
+ })