@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
@@ -1,6 +1,7 @@
1
- import { useCallback, useEffect, useMemo, useRef } from 'react'
2
- import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
3
- import { ResourcesSidebar, type SelectedKindInfo } from '@skyhook-io/k8s-ui'
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { useLocation, useSearchParams } from 'react-router-dom'
3
+ import { DialogPortal, ResourcesSidebar, type SelectedKindInfo } from '@skyhook-io/k8s-ui'
4
+ import { PanelLeft, X } from 'lucide-react'
4
5
  import type { SelectedResource } from '../../types'
5
6
  import { useAPIResources } from '../../api/apiResources'
6
7
  import { usePinnedKinds } from '../../hooks/useFavorites'
@@ -11,9 +12,14 @@ import { CNPGDeclarations } from './CNPGDeclarations'
11
12
  import { CNPGPooling } from './CNPGPooling'
12
13
  import { CNPGOperator } from './CNPGOperator'
13
14
  import { CNPGScreenGate } from './shared'
15
+ import { CNPGCreateClusterDialog } from './CNPGCreateClusterDialog'
14
16
  import { CNPGDetailPage } from './CNPGDetailPage'
15
- import { decodeDrawerTrail, encodeDrawerTrail, parseCNPGRoute, sameResource } from './routes'
17
+ import { parseCNPGRoute } from './routes'
16
18
  import { useCNPGFleet, useCNPGSidebarWorkspace } from './useCNPGSidebarWorkspace'
19
+ import { decodeDrawerTrail, encodeDrawerTrail, sameSelectedResource } from '../../utils/drawer-trail'
20
+ import { useCNPGNavigate } from './useCNPGNavigate'
21
+ import { useCNPGScreenParams } from './useCNPGScreenParams'
22
+ import { useMediaQuery } from '../../hooks/useMediaQuery'
17
23
 
18
24
  interface CNPGViewProps {
19
25
  namespaces: string[]
@@ -33,9 +39,14 @@ interface CNPGViewProps {
33
39
  */
34
40
  export function CNPGView({ namespaces, selectedResource, onOpenResource, onCloseResource, onClearNamespaces }: CNPGViewProps) {
35
41
  const location = useLocation()
36
- const navigate = useNavigate()
42
+ const navigate = useCNPGNavigate()
37
43
  const [searchParams, setSearchParams] = useSearchParams()
38
44
  const route = parseCNPGRoute(location.pathname)
45
+ const laptop = useMediaQuery('(max-width: 1300px)')
46
+ const narrow = useMediaQuery('(max-width: 1100px)')
47
+ const compactNavigation = narrow || (laptop && !!route.detail)
48
+ const [navigationOpen, setNavigationOpen] = useState(false)
49
+ useEffect(() => setNavigationOpen(false), [location.pathname, location.search, compactNavigation])
39
50
  const { data: apiResources } = useAPIResources()
40
51
  const { data: counts } = useResourceCounts(namespaces)
41
52
  const { pinned, togglePin, isPinned } = usePinnedKinds()
@@ -59,7 +70,7 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
59
70
  useEffect(() => {
60
71
  if (targetKey !== (lastSynced.current ?? '')) {
61
72
  lastSynced.current = targetKey
62
- if (drawerTarget && !sameResource(drawerTarget, selectedResource)) onOpenResource(drawerTarget)
73
+ if (drawerTarget && !sameSelectedResource(drawerTarget, selectedResource)) onOpenResource(drawerTarget)
63
74
  else if (!drawerTarget && selectedResource) onCloseResource()
64
75
  return
65
76
  }
@@ -69,7 +80,7 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
69
80
  if (!selectedResource) {
70
81
  params.delete('drawer')
71
82
  } else {
72
- const idx = trail.findIndex((r) => sameResource(r, selectedResource))
83
+ const idx = trail.findIndex((r) => sameSelectedResource(r, selectedResource))
73
84
  const next = idx >= 0 ? trail.slice(0, idx + 1) : [...trail, selectedResource]
74
85
  params.set('drawer', encodeDrawerTrail(next))
75
86
  }
@@ -86,17 +97,8 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
86
97
  [searchParams, setSearchParams, location.state],
87
98
  )
88
99
 
89
- const setParams = useCallback(
90
- (update: Record<string, string | null>) => {
91
- const params = new URLSearchParams(searchParams)
92
- for (const [k, v] of Object.entries(update)) {
93
- if (v === null || v === '') params.delete(k)
94
- else params.set(k, v)
95
- }
96
- setSearchParams(params, { replace: true, state: location.state })
97
- },
98
- [searchParams, setSearchParams, location.state],
99
- )
100
+ const [, setParams] = useCNPGScreenParams()
101
+ const [creating, setCreating] = useState(false)
100
102
 
101
103
  const selectKind = useCallback(
102
104
  (kind: SelectedKindInfo) => {
@@ -105,9 +107,7 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
105
107
  [navigate],
106
108
  )
107
109
 
108
- return (
109
- <div className="flex h-full min-h-0 w-full">
110
- <ResourcesSidebar
110
+ const sidebar = <ResourcesSidebar
111
111
  selectedKind={null}
112
112
  onSelectedKindChange={selectKind}
113
113
  apiResources={apiResources}
@@ -118,8 +118,17 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
118
118
  togglePin={togglePin}
119
119
  isPinned={(kind: string, group?: string) => isPinned(kind, group ?? '')}
120
120
  categoryWorkspaces={sidebarWorkspace}
121
+ className={compactNavigation ? '!w-full !border-r-0' : undefined}
121
122
  />
123
+ return (
124
+ <div className="flex h-full min-h-0 w-full">
125
+ {!compactNavigation && sidebar}
126
+ <DialogPortal open={navigationOpen && compactNavigation} onClose={() => setNavigationOpen(false)} ariaLabel="Resource navigation" className="flex h-[min(80vh,44rem)] w-full max-w-md flex-col overflow-hidden">
127
+ <div className="flex items-center justify-between border-b border-theme-border px-4 py-3"><h2 className="font-medium text-theme-text-primary">Resources</h2><button type="button" aria-label="Close resource navigation" onClick={() => setNavigationOpen(false)} className="rounded p-1 text-theme-text-secondary hover:bg-theme-hover"><X className="h-4 w-4" /></button></div>
128
+ <div className="flex min-h-0 flex-1">{sidebar}</div>
129
+ </DialogPortal>
122
130
  <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-theme-base">
131
+ {compactNavigation && <div className="border-b border-theme-border px-5 py-2"><button type="button" aria-haspopup="dialog" aria-expanded={navigationOpen} onClick={() => setNavigationOpen(true)} className="btn-secondary inline-flex items-center gap-1.5 px-2.5 py-1 text-xs"><PanelLeft className="h-3.5 w-3.5" />Resources</button></div>}
123
132
  {route.detail ? (
124
133
  <CNPGDetailPage target={route.detail} namespaces={namespaces} onOpenResource={onOpenResource} />
125
134
  ) : (
@@ -134,6 +143,7 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
134
143
  onInspect: inspect,
135
144
  inspected: drawerTarget,
136
145
  onClearNamespaces,
146
+ onCreate: () => setCreating(true),
137
147
  }
138
148
  switch (route.screen) {
139
149
  case 'protection':
@@ -151,6 +161,9 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
151
161
  </CNPGScreenGate>
152
162
  )}
153
163
  </div>
164
+ {creating && (
165
+ <CNPGCreateClusterDialog namespaces={namespaces} onClose={() => setCreating(false)} onCreated={inspect} />
166
+ )}
154
167
  </div>
155
168
  )
156
169
  }
@@ -0,0 +1,90 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { beforeEach, expect, it, vi } from 'vitest'
3
+ import { CNPG_WORKSPACE_KEYS, ResourcesSidebar } from '@skyhook-io/k8s-ui'
4
+ import { useCNPGFleet, useCNPGSidebarWorkspace } from './useCNPGSidebarWorkspace'
5
+ import { radarFeatureSupport } from '../../api/radarFeatures'
6
+
7
+ const state = vi.hoisted(() => ({ capabilities: { features: { resourceIssues: true } } as any, diskEnabled: [] as boolean[], metricsEnabled: [] as boolean[], result: undefined as any, workspace: undefined as any }))
8
+ const oldKinds = ['clusters', 'backups', 'scheduledBackups', 'poolers', 'databases', 'publications', 'subscriptions', 'imageCatalogs', 'clusterImageCatalogs', 'objectStores', 'pods']
9
+ const cluster = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { namespace: 'db', name: 'pg' }, spec: { instances: 1 }, status: { phase: 'Cluster in healthy state', readyInstances: 1, currentPrimary: 'pg-1' } }
10
+ const workspace = { installed: true, context: 'test', namespaces: ['db'], coverage: Object.fromEntries(oldKinds.map(k => [k, { state: 'full' }])), objects: { ...Object.fromEntries(oldKinds.map(k => [k, []])), clusters: [cluster], pods: [{ apiVersion: 'v1', kind: 'Pod', metadata: { namespace: 'db', name: 'pg-1', labels: { 'cnpg.io/cluster': 'pg', 'cnpg.io/instanceRole': 'primary' } }, status: { conditions: [{ type: 'Ready', status: 'True' }] } }] }, issues: [], audit: [], backupsOmitted: 0 }
11
+ vi.mock('../../api/cnpg', () => ({ useCNPGWorkspace: () => ({ data: state.workspace ?? workspace }) }))
12
+ vi.mock('../../api/cnpg-storage', () => ({ useCNPGFleetDisk: (_ns: string[], enabled: boolean) => { state.diskEnabled.push(enabled); return { error: new Error('Live disk needs a newer Radar') } } }))
13
+ vi.mock('../../api/cnpg-history', () => ({ useCNPGFleetMetrics: (_ns: string[], enabled: boolean) => { state.metricsEnabled.push(enabled); return {} } }))
14
+ vi.mock('../../api/client', () => ({ useRadarFeature: () => ({ support: radarFeatureSupport('cnpgWorkspace', state.capabilities, 'v1.16.0') }) }))
15
+ vi.mock('./useCNPGNavigate', () => ({ useCNPGNavigate: () => vi.fn() }))
16
+ beforeEach(() => { state.capabilities = { features: { resourceIssues: true } }; state.diskEnabled = []; state.metricsEnabled = []; state.workspace = undefined })
17
+ function Host() {
18
+ const { fleet } = useCNPGFleet(['db'])
19
+ const sidebar = useCNPGSidebarWorkspace({ apiResources: [{ name: 'clusters', kind: 'Cluster', group: 'postgresql.cnpg.io', namespaced: true }] as any, namespaces: ['db'] })
20
+ state.result = { fleet, sidebar }
21
+ return <div>{fleet?.rows[0].cluster.status.phase}{sidebar?.CloudNativePG.destinations.map(d => <span key={d.id}>{d.label}</span>)}</div>
22
+ }
23
+ it('keeps the ordinary workspace destinations and CRD facts without new measurement failures', () => {
24
+ const html = renderToStaticMarkup(<Host />)
25
+ expect(html).toContain('Cluster in healthy state')
26
+ expect(html).toContain('Clusters')
27
+ expect(html).toContain('Backups')
28
+ expect(state.diskEnabled).toEqual([false, false])
29
+ expect(state.metricsEnabled).toEqual([false, false])
30
+ expect(state.result.fleet.rows[0].disk).toBeUndefined()
31
+ expect(state.result.fleet.incompleteKinds).toContain('databaseRoles')
32
+ expect(state.result.fleet.rows[0].declarations.summary.text).toContain('DatabaseRoles not read by this Radar')
33
+ expect(state.result.sidebar.CloudNativePG.destinations[0]).toMatchObject({ count: null, countLowerBound: true })
34
+ })
35
+ it('enables new measurements only once the server advertises their contracts', () => {
36
+ state.capabilities = { features: { cnpgWorkspace: true } }
37
+ renderToStaticMarkup(<Host />)
38
+ expect(state.diskEnabled).toEqual([true, false])
39
+ expect(state.metricsEnabled).toEqual([true, false])
40
+ expect(state.result.fleet.rows[0].disk).toMatchObject({ text: 'Unavailable', tone: 'unknown', source: 'Disk usage could not be read: Live disk needs a newer Radar' })
41
+ })
42
+ it('the Resources sidebar reads disk and lag only on the CloudNativePG views', () => {
43
+ state.capabilities = { features: { cnpgWorkspace: true } }
44
+ const apiResources = [{ name: 'clusters', kind: 'Cluster', group: 'postgresql.cnpg.io', namespaced: true }] as any
45
+ function Sidebar({ active }: { active: boolean }) {
46
+ state.result = useCNPGSidebarWorkspace({ apiResources, namespaces: ['db'], active: active ? { screen: 'overview' } : undefined })
47
+ return null
48
+ }
49
+ renderToStaticMarkup(<Sidebar active={false} />)
50
+ expect(state.diskEnabled).toEqual([false])
51
+ renderToStaticMarkup(<Sidebar active />)
52
+ expect(state.diskEnabled).toEqual([false, true])
53
+ expect(state.metricsEnabled).toEqual([false, true])
54
+ })
55
+
56
+ it('outside the CloudNativePG views the Clusters badge is a lower bound, and its zero is not none', () => {
57
+ state.capabilities = { features: { cnpgWorkspace: true } }
58
+ const apiResources = [{ name: 'clusters', kind: 'Cluster', group: 'postgresql.cnpg.io', namespaced: true }] as any
59
+ const fullCoverage = Object.fromEntries(CNPG_WORKSPACE_KEYS.map((k) => [k, { state: 'full' }]))
60
+ const clusterIssue = { id: 'i1', severity: 'warning', kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'db', name: 'pg', reason: 'CNPGNotReady', message: 'not ready' }
61
+ function Sidebar({ active }: { active: boolean }) {
62
+ state.result = useCNPGSidebarWorkspace({ apiResources, namespaces: ['db'], active: active ? { screen: 'overview' } : undefined })
63
+ return null
64
+ }
65
+ const overview = (active: boolean) => {
66
+ renderToStaticMarkup(<Sidebar active={active} />)
67
+ return state.result.CloudNativePG.destinations.find((d: any) => d.id === 'overview')
68
+ }
69
+ const crd = [{ group: 'postgresql.cnpg.io', version: 'v1', kind: 'Cluster', name: 'clusters', namespaced: true, isCrd: true, verbs: ['list'] }] as any
70
+ const badge = (destination: any) => {
71
+ const html = renderToStaticMarkup(
72
+ <ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={crd} resourceCounts={{ 'postgresql.cnpg.io/Cluster': 1 }} categoryWorkspaces={{ CloudNativePG: { destinations: [destination] } }} />,
73
+ )
74
+ return html.match(new RegExp(`>${destination.label}</span>(.*?)</button>`))?.[1] ?? ''
75
+ }
76
+
77
+ state.workspace = { ...workspace, coverage: fullCoverage }
78
+ const calm = overview(false)
79
+ expect(calm).toMatchObject({ count: null, countLowerBound: true })
80
+ expect(calm.countTitle).toBe('None found in the workspace read. Disk use and replication lag are counted on the CloudNativePG views.')
81
+ expect(badge(calm)).toMatch(/>–</)
82
+ expect(overview(true)).toMatchObject({ count: 0, countLowerBound: false })
83
+
84
+ state.workspace = { ...workspace, coverage: fullCoverage, issues: [clusterIssue] }
85
+ const flagged = overview(false)
86
+ expect(flagged).toMatchObject({ count: 1, countLowerBound: true })
87
+ expect(flagged.countTitle).toBe('At least 1 clusters need attention. Disk use and replication lag are counted on the CloudNativePG views.')
88
+ expect(badge(flagged)).toMatch(/≥(<!-- -->)?1/)
89
+ expect(overview(true)).toMatchObject({ count: 1, countLowerBound: false })
90
+ })
@@ -0,0 +1,79 @@
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 { ClusterActionDialog } from './CNPGClusterActions'
6
+ const state = vi.hoisted(() => ({ dialog: {} as any, mutate: vi.fn() }))
7
+ vi.mock('../../../api/cnpg', () => ({ useCNPGAction: () => ({ mutate: state.mutate }), useCNPGRuntime: () => ({}), useCNPGClusterCapabilities: () => ({}) }))
8
+ vi.mock('../../../api/cnpg-ha', () => ({ useCNPGClusterHA: () => ({}) }))
9
+ vi.mock('../useCNPGSidebarWorkspace', () => ({ useCNPGFleet: () => ({}) }))
10
+ vi.mock('../useCNPGNavigate', () => ({ useCNPGNavigate: () => vi.fn() }))
11
+ vi.mock('../../ui/Toast', () => ({ useToast: () => ({ showSuccess: vi.fn() }) }))
12
+ vi.mock('./useCNPGWriteGuard', () => ({ useCNPGWriteGuard: () => ({ node: null, satisfied: true }) }))
13
+ vi.mock('@skyhook-io/k8s-ui', async (original) => ({ ...(await original<typeof import('@skyhook-io/k8s-ui')>()), ActionConfirmDialog: (props: any) => { state.dialog = props; return <div>{props.children}<button disabled={!!props.disabledReason || !!props.incompleteReason} onClick={props.onConfirm}>Confirm</button></div> } }))
14
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
15
+ let host: HTMLDivElement, root: ReturnType<typeof createRoot>
16
+ afterEach(() => { act(() => root.unmount()); host.remove(); state.mutate.mockClear() })
17
+ const facts = { maintenance: {}, backupMethods: [], currentPrimary: 'orders-1', fencedInstances: { all: false, instances: [] }, instances: [{ pod: 'orders-1', podExists: true, ready: true }, { pod: 'orders-2', podExists: false, ready: false }] }
18
+ function render(kind: 'fence' | 'unfence' | 'hibernate', extra = {}, initialPod?: string) {
19
+ host = document.createElement('div'); root = createRoot(host)
20
+ act(() => root.render(<ClusterActionDialog kind={kind} caps={{ facts, actions: { [kind]: { allowed: true } }, context: 'kind-orders', uid: 'uid', ...extra } as any} namespace="db" name="orders" initialPod={initialPod} onClose={() => {}} />))
21
+ }
22
+ it('requires an explicit fencing choice, puts All last and keeps primary typed confirmation', () => {
23
+ render('fence', {}, 'orders-2')
24
+ const select = host.querySelector('select')!
25
+ expect(select.value).toBe('')
26
+ expect(host.querySelector('button')!.disabled).toBe(true)
27
+ expect(state.dialog.disabledReason).toBeUndefined()
28
+ expect(state.dialog.incompleteReason).toBe('Choose an instance to fence.')
29
+ expect([...select.options].map((o) => o.textContent)).toEqual(['Choose an instance', 'orders-1 (primary)', 'orders-2', 'All instances — stops service'])
30
+ act(() => { select.value = 'orders-2'; select.dispatchEvent(new Event('change', { bubbles: true })) })
31
+ expect(host.querySelector('button')!.disabled).toBe(false)
32
+ expect(state.dialog.incompleteReason).toBeUndefined()
33
+ expect(state.dialog.typedConfirmation).toBeUndefined()
34
+ act(() => host.querySelector('button')!.click())
35
+ expect(state.mutate.mock.calls[0][0].request.params.instances).toEqual(['orders-2'])
36
+ act(() => { select.value = '*'; select.dispatchEvent(new Event('change', { bubbles: true })) })
37
+ expect(state.dialog.typedConfirmation).toBe('orders')
38
+ expect(state.dialog.effect).toContain('Writes stop')
39
+ })
40
+ it('disables fencing an instance the server refuses on its own, with the reason, and keeps All', () => {
41
+ const target = 'It is the target of a switchover or failover in progress'
42
+ render('fence', { instanceActions: { 'orders-1': { fence: { allowed: true } }, 'orders-2': { fence: { allowed: false, reason: target, permission: 'allowed' } } } })
43
+ const select = host.querySelector('select')!
44
+ const option = (value: string) => [...select.options].find((o) => o.value === value)!
45
+ expect(option('orders-2').disabled).toBe(true)
46
+ expect(option('orders-2').textContent).toBe('orders-2 — cannot be fenced')
47
+ expect(option('orders-1').disabled).toBe(false)
48
+ expect(option('*').disabled).toBe(false)
49
+ expect(host.textContent).toContain(`orders-2 cannot be fenced: ${target}`)
50
+ act(() => { select.value = 'orders-2'; select.dispatchEvent(new Event('change', { bubbles: true })) })
51
+ expect(state.dialog.disabledReason).toBe(`orders-2 cannot be fenced: ${target}`)
52
+ act(() => host.querySelector('button')!.click())
53
+ expect(state.mutate).not.toHaveBeenCalled()
54
+ act(() => { select.value = '*'; select.dispatchEvent(new Event('change', { bubbles: true })) })
55
+ expect(state.dialog.disabledReason).toBeUndefined()
56
+ act(() => host.querySelector('button')!.click())
57
+ expect(state.mutate.mock.calls[0][0].request.params.instances).toBe('*')
58
+ })
59
+ it('keeps a malformed fencing annotation as a real blocker after selecting an instance', () => {
60
+ render('fence', { facts: { ...facts, fencedInstances: { all: false, instances: [], malformed: true } } })
61
+ const select = host.querySelector('select')!
62
+ act(() => { select.value = 'orders-2'; select.dispatchEvent(new Event('change', { bubbles: true })) })
63
+ expect(state.dialog.disabledReason).toBe('The cnpg.io/fencedInstances annotation is not valid JSON; fix it in YAML first.')
64
+ expect(state.dialog.incompleteReason).toBeUndefined()
65
+ expect(host.querySelector('button')!.disabled).toBe(true)
66
+ act(() => host.querySelector('button')!.click())
67
+ expect(state.mutate).not.toHaveBeenCalled()
68
+ })
69
+ it('preserves the lift-all fencing default', () => {
70
+ render('unfence', { facts: { ...facts, fencedInstances: { all: true, instances: [] } } })
71
+ expect(host.querySelector('select')!.value).toBe('*')
72
+ expect(host.querySelector('button')!.disabled).toBe(false)
73
+ })
74
+ it('labels kept PVCs and separates capacity from requested size', () => {
75
+ const list = { available: true, names: [] }
76
+ render('hibernate', { hibernateEffects: { volumes: { available: true, items: [{ name: 'orders-1', capacity: '1Gi', requested: '2Gi' }, { name: 'orders-2', requested: '2Gi' }, { name: 'orders-3' }] }, poolers: list, unsuspendedScheduledBackups: list, databases: list, publications: list, subscriptions: list } })
77
+ expect(host.textContent).toContain('Kept PVCs: orders-1 (capacity 1Gi; requested 2Gi), orders-2 (capacity not reported; requested 2Gi), orders-3 (capacity not reported)')
78
+ expect(state.dialog.typedConfirmation).toBe('orders')
79
+ })
@@ -0,0 +1,142 @@
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 { CNPGClusterActions, CNPGRestartReview } from './CNPGClusterActions'
6
+ import { renderToStaticMarkup } from 'react-dom/server'
7
+
8
+ const state = vi.hoisted(() => ({ caps: {} as any, workspace: {} as any, refetch: vi.fn() }))
9
+ vi.mock('../../../api/cnpg', () => ({ useCNPGClusterCapabilities: () => state.caps }))
10
+ vi.mock('../useCNPGSidebarWorkspace', () => ({ useCNPGFleet: () => state.workspace }))
11
+ vi.mock('./useOpenCNPGPsql', () => ({ useOpenCNPGPsql: () => vi.fn() }))
12
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
13
+ let root: ReturnType<typeof createRoot>
14
+ let host: HTMLDivElement
15
+ afterEach(() => { act(() => root?.unmount()); host?.remove(); vi.clearAllMocks(); vi.useRealTimers() })
16
+ function render() {
17
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
18
+ act(() => root.render(<CNPGClusterActions namespace="db" name="pg" />))
19
+ }
20
+ function menu() { act(() => host.querySelector<HTMLButtonElement>('[aria-label="More cluster actions"]')!.click()) }
21
+ it('shows a failed capability read with Retry instead of checking forever', () => {
22
+ state.workspace = { query: {} }
23
+ state.caps = { error: new Error('Gateway timeout'), refetch: state.refetch, isFetching: false }
24
+ render()
25
+ expect(host.textContent).toContain('Actions could not be checked: Gateway timeout')
26
+ expect(host.textContent).not.toContain('Checking permissions')
27
+ const retry = [...host.querySelectorAll('button')].find((b) => b.textContent === 'Retry')!
28
+ act(() => retry.click())
29
+ expect(state.refetch).toHaveBeenCalledTimes(1)
30
+ })
31
+
32
+ it('keeps blocked header buttons focusable, explains on focus and click, and prints menu reasons', () => {
33
+ vi.useFakeTimers()
34
+ const cap = { allowed: false, reason: 'Configure a backup destination on orders first' }
35
+ state.caps = { data: { actions: { backup: cap, switchover: { allowed: false, reason: 'No ready standby' }, unfence: { allowed: false, reason: 'No instances are fenced' }, psql: { allowed: false, reason: 'Needs create pods/exec' }, restore: { allowed: false, reason: 'Needs create clusters' } }, facts: { maintenance: {} } } }
36
+ state.workspace = { query: {} }
37
+ render()
38
+ const backup = host.querySelector<HTMLButtonElement>('[aria-label="Back up now"]')!
39
+ expect(backup.disabled).toBe(false)
40
+ expect(backup.getAttribute('aria-disabled')).toBe('true')
41
+ act(() => { backup.focus(); vi.advanceTimersByTime(350) })
42
+ expect(document.activeElement).toBe(backup)
43
+ expect(document.body.textContent).toContain(cap.reason)
44
+ act(() => backup.click())
45
+ expect(host.querySelector('[role="status"]')?.textContent).toBe(cap.reason)
46
+ const switchover = [...host.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent === 'Switchover')!
47
+ expect(switchover.disabled).toBe(false)
48
+ expect(switchover.getAttribute('aria-disabled')).toBe('true')
49
+ act(() => switchover.click())
50
+ expect(host.querySelector('[role="status"]')?.textContent).toBe('No ready standby')
51
+ state.caps.data.actions.switchover.allowed = true
52
+ act(() => root.render(<CNPGClusterActions namespace="db" name="pg" />))
53
+ expect(host.querySelector('[role="status"]')).toBeNull()
54
+ menu()
55
+ const dialog = document.querySelector('[role=dialog][aria-label="Cluster actions"]')!
56
+ expect(dialog.textContent).toContain('No instances are fenced')
57
+ expect(dialog.textContent).toContain('Needs create pods/exec')
58
+ expect(dialog.textContent).toContain('Needs create clusters')
59
+ expect(host.contains(dialog)).toBe(false)
60
+ act(() => dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
61
+ expect(host.querySelector('[aria-label="More cluster actions"]')?.getAttribute('aria-expanded')).toBe('false')
62
+ })
63
+
64
+ it('reviews each restart step with current readiness, scheduling blockers and primary downtime', () => {
65
+ const caps = { facts: { currentPrimary: 'orders-1', instances: [{ pod: 'orders-1', ready: true, podReadable: true, podExists: true }, { pod: 'orders-2', ready: false, podReadable: true, podExists: false }] }, restartPlan: { primaryUpdateStrategy: 'unsupervised', primaryUpdateMethod: 'restart', steps: [{ instance: 'orders-2', role: 'standby', effect: 'recreate' }, { instance: 'orders-1', role: 'primary', effect: 'restart' }] } } as any
66
+ const problem = { id: 'join', instance: 'orders-2', title: "New standby orders-2: Can't be scheduled", detail: '2 nodes insufficient pods', subject: { kind: 'Pod', name: 'orders-2-join' } } as any
67
+ const html = renderToStaticMarkup(<CNPGRestartReview caps={caps} problems={[problem]} onOpenOverview={() => {}} />)
68
+ for (const text of ['currently ready', 'currently absent', 'insufficient pods', 'Restarting the primary interrupts its connections.', 'The rolling restart waits for every instance to be ready.', 'Currently blocked by orders-2.', 'If the primary restarts without another ready instance, the cluster stops serving until it is back.']) expect(html).toContain(text)
69
+ expect(html).toContain('See Overview’s problems →')
70
+ caps.facts.instances[1].ready = true
71
+ expect(renderToStaticMarkup(<CNPGRestartReview caps={caps} />)).not.toContain('cluster stops serving')
72
+ caps.facts.instances[1].ready = false; caps.facts.instances[1].podReadable = false
73
+ expect(renderToStaticMarkup(<CNPGRestartReview caps={caps} />)).toContain('service continuity is unknown')
74
+ caps.restartPlan.steps[1].effect = 'wait_for_user'
75
+ const supervised = renderToStaticMarkup(<CNPGRestartReview caps={caps} />)
76
+ expect(supervised).toContain('primary waits for your manual promotion or restart')
77
+ expect(supervised).not.toContain('Restarting the primary interrupts its connections.')
78
+ caps.restartPlan.steps[1].effect = 'skipped_fenced'
79
+ expect(renderToStaticMarkup(<CNPGRestartReview caps={caps} />)).toContain('fenced primary is skipped')
80
+ caps.restartPlan.steps = [{ instance: 'orders-1', role: 'primary', effect: 'restart_only_instance' }]
81
+ caps.facts.instances = [caps.facts.instances[0]]
82
+ expect(renderToStaticMarkup(<CNPGRestartReview caps={caps} />)).toContain('There is no other instance: the cluster stops serving until the primary is back.')
83
+ })
84
+ it('disables header restore for known no sources and keeps unread sources enabled', () => {
85
+ const cluster = { metadata: { name: 'pg', namespace: 'db' }, spec: {} }
86
+ state.caps = { data: { actions: { restore: { allowed: true }, backup: { allowed: false }, switchover: { allowed: false }, psql: { allowed: false } }, facts: { maintenance: {} } } }
87
+ state.workspace = { query: { data: { coverage: { backups: { state: 'full' } }, objects: { backups: [] } } }, fleet: { rows: [{ name: 'pg', namespace: 'db', cluster }] } }
88
+ render(); menu()
89
+ let restore = [...document.querySelectorAll<HTMLButtonElement>('[role=menu] button')].find((b) => b.textContent?.includes('Restore to a new cluster'))!
90
+ expect(restore.disabled).toBe(true)
91
+ expect(document.querySelector('[role=menu]')?.textContent).toContain('Nothing to restore from yet')
92
+ state.workspace.query.data.coverage.backups.state = 'denied'
93
+ act(() => root.render(<CNPGClusterActions namespace="db" name="pg" />))
94
+ restore = [...document.querySelectorAll<HTMLButtonElement>('[role=menu] button')].find((b) => b.textContent?.includes('Restore to a new cluster'))!
95
+ expect(restore.disabled).toBe(false)
96
+ expect(document.querySelector('[role=menu]')?.textContent).not.toContain('Nothing to restore from yet')
97
+ state.caps.isRefetchError = true
98
+ state.caps.error = new Error('Refresh timeout')
99
+ state.caps.dataUpdatedAt = Date.now() - 180_000
100
+ act(() => root.render(<CNPGClusterActions namespace="db" name="pg" />))
101
+ expect(host.textContent).toContain('Last refresh failed: Refresh timeout')
102
+ expect(host.textContent).toContain('3m ago')
103
+ expect(host.textContent).not.toContain('Actions could not be checked')
104
+ expect(restore.disabled).toBe(false)
105
+ })
106
+
107
+ it('carries a verified join-Job blocker into the matching restart step and opens Overview', () => {
108
+ const open = vi.fn()
109
+ const caps = { facts: { currentPrimary: 'orders-1', instances: [{ pod: 'orders-2', ready: false, podReadable: true, podExists: false }] }, restartPlan: { steps: [{ instance: 'orders-2', role: 'standby', effect: 'recreate' }] } } as any
110
+ const problem = { id: 'join', instance: 'orders-2', title: "New standby orders-2: Can't be scheduled", detail: 'Cannot be scheduled: both nodes have reached their Pod limit', subject: { kind: 'Pod', name: 'orders-2-join-dz4fc' } } as any
111
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
112
+ act(() => root.render(<CNPGRestartReview caps={caps} problems={[problem]} onOpenOverview={open} />))
113
+ const step = host.querySelector('li')!
114
+ expect(step.textContent).toContain('orders-2 : Will recreate the standby Pod (currently absent)')
115
+ expect(step.textContent).toContain('both nodes have reached their Pod limit')
116
+ act(() => step.querySelector('button')!.click())
117
+ expect(open).toHaveBeenCalledOnce()
118
+ })
119
+
120
+ it('states restart intentions separately from observations and folds the raw update policy', () => {
121
+ const caps = { facts: { currentPrimary: 'orders-1', instances: [{ pod: 'orders-1', ready: true, podReadable: true, podExists: true }, { pod: 'orders-2', podReadable: true, podExists: false }] }, restartPlan: { primaryUpdateStrategy: 'unsupervised', primaryUpdateMethod: 'restart', steps: [{ instance: 'orders-2', role: 'standby', effect: 'recreate' }, { instance: 'orders-1', role: 'primary', effect: 'restart' }] } } as any
122
+ const html = renderToStaticMarkup(<CNPGRestartReview caps={caps} />)
123
+ expect(html).toContain('Will recreate the standby Pod')
124
+ expect(html).toContain('(currently absent)')
125
+ expect(html).toContain('Will restart the primary in place')
126
+ expect(html).toContain('(currently ready)')
127
+ expect(html).toContain('The operator updates the primary automatically after the standbys')
128
+ expect(html).toContain('Operator update settings')
129
+ expect(html).toContain('aria-expanded="false"')
130
+ })
131
+
132
+ it.each(['skipped_fenced', 'restart_only_instance', 'wait_for_user'])('explains the %s plan without promising a configured switchover', (effect) => {
133
+ const caps = { facts: { currentPrimary: 'orders-1', instances: [{ pod: 'orders-1', ready: true, podReadable: true, podExists: true }] }, restartPlan: { primaryUpdateStrategy: 'unsupervised', primaryUpdateMethod: 'switchover', steps: [{ instance: 'orders-1', role: 'primary', effect }] } } as any
134
+ const html = renderToStaticMarkup(<CNPGRestartReview caps={caps} />)
135
+ expect(html).not.toContain('updates the primary automatically after the standbys')
136
+ expect(html).not.toContain('it promotes an updated standby')
137
+ expect(html).toContain(effect === 'skipped_fenced' ? 'The fenced primary is skipped.' : effect === 'restart_only_instance' ? 'There is no other instance' : 'The primary waits for your manual promotion or restart.')
138
+ })
139
+ it('explains an automatic switchover from the planned effect rather than the raw method', () => {
140
+ const caps = { facts: { currentPrimary: 'orders-1', instances: [] }, restartPlan: { primaryUpdateStrategy: 'unsupervised', primaryUpdateMethod: 'restart', steps: [{ instance: 'orders-1', role: 'primary', effect: 'switchover' }] } } as any
141
+ expect(renderToStaticMarkup(<CNPGRestartReview caps={caps} />)).toContain('it promotes an updated standby, then recreates the former primary Pod.')
142
+ })