@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,510 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { CNPGClusterHA } from '@skyhook-io/k8s-ui'
3
+ import type { CNPGClusterFacts, CNPGRuntimeResponse } from '../../../api/cnpg'
4
+ import { CNPG_OP_FIRST_READ_MS, CNPG_OP_STALL_MS, CNPG_OP_UNOBSERVABLE_MS, advanceCNPGOperation, restartedSince, summarizeSteps, supersedeFor, type CNPGObservation, type CNPGTrackedOperation } from './model'
5
+
6
+ const T0 = Date.parse('2026-09-30T12:00:00Z')
7
+
8
+ function op(over: Partial<CNPGTrackedOperation>): CNPGTrackedOperation {
9
+ return {
10
+ id: 'op',
11
+ kind: 'switchover',
12
+ label: 'Switchover to pg-2',
13
+ apiBase: '/api',
14
+ context: 'kind',
15
+ namespace: 'db',
16
+ cluster: 'pg',
17
+ clusterUID: 'uid-1',
18
+ target: { name: 'pg-2' },
19
+ startedAt: T0,
20
+ baseline: { currentPrimary: 'pg-1' },
21
+ state: 'requested',
22
+ lastProgressAt: T0,
23
+ ...over,
24
+ }
25
+ }
26
+
27
+ function facts(over: Partial<CNPGClusterFacts>): CNPGClusterFacts {
28
+ return {
29
+ generation: 0,
30
+ currentPrimary: 'pg-1',
31
+ targetPrimary: 'pg-1',
32
+ phase: 'Cluster in healthy state',
33
+ hibernation: '',
34
+ hibernated: false,
35
+ archivingFailing: false,
36
+ fencedInstances: { raw: '', all: false, instances: [] },
37
+ instances: [],
38
+ backupMethods: [],
39
+ isReplicaCluster: false,
40
+ terminating: false,
41
+ maintenance: { declared: false, inProgress: false, reusePVC: true },
42
+ ...over,
43
+ }
44
+ }
45
+
46
+ function runtime(primary: string, standbys: { pod: string; state: string }[]): CNPGRuntimeResponse {
47
+ return {
48
+ cluster: { namespace: 'db', name: 'pg', uid: 'uid-1' },
49
+ sampledAt: '',
50
+ permission: { proxy: 'allowed' },
51
+ instances: [
52
+ { pod: primary, role: 'primary', status: { state: 'ok', replication: standbys.map((s) => ({ applicationName: s.pod, state: s.state })) }, metrics: { state: 'ok' } },
53
+ ...standbys.map((s) => ({ pod: s.pod, role: 'replica' as const, status: { state: 'ok' as const }, metrics: { state: 'ok' as const } })),
54
+ ],
55
+ }
56
+ }
57
+
58
+ function ha(over: Partial<CNPGClusterHA>): CNPGClusterHA {
59
+ return {
60
+ cluster: { namespace: 'db', name: 'pg', uid: 'uid-1' },
61
+ sampledAt: '',
62
+ instances: [],
63
+ pods: { state: 'ok' },
64
+ nodes: { state: 'ok' },
65
+ quorum: { enabled: false, object: { state: 'notFound' } },
66
+ pdbs: { state: 'ok', enabled: true, items: [] },
67
+ primaryLease: { state: 'notFound' },
68
+ operatorLease: { state: 'notFound' },
69
+ jobs: { state: 'ok', items: [] },
70
+ rwEndpoints: { state: 'ok', service: 'pg-rw', pods: [] },
71
+ certificates: [],
72
+ maintenance: { declared: false, inProgress: false, reusePVC: true },
73
+ ...over,
74
+ }
75
+ }
76
+
77
+ const fresh = { updatedAt: T0 + 30_000, failed: false, clusterUID: 'uid-1' }
78
+ const obs = (o: Partial<CNPGObservation>): CNPGObservation => ({
79
+ now: T0 + 30_000,
80
+ clusterUID: 'uid-1',
81
+ freshness: { clusterUID: fresh, facts: fresh, cluster: fresh, ha: fresh, runtime: fresh, backups: fresh },
82
+ ...o,
83
+ })
84
+
85
+ describe('switchover observer', () => {
86
+ it('walks from observed to completed only when the old primary streams and rw points at the target', () => {
87
+ let o = advanceCNPGOperation(op({}), obs({ facts: facts({ targetPrimary: 'pg-2', phase: 'Switchover in progress' }) }))
88
+ expect(o.state).toBe('observed')
89
+
90
+ o = advanceCNPGOperation(o, obs({ facts: facts({ currentPrimary: 'pg-2', targetPrimary: 'pg-2' }), runtime: runtime('pg-2', [{ pod: 'pg-1', state: 'startup' }]), ha: ha({ rwEndpoints: { state: 'ok', service: 'pg-rw', pods: ['pg-2'] } }) }))
91
+ expect(o.state).toBe('progressing')
92
+
93
+ o = advanceCNPGOperation(o, obs({ facts: facts({ currentPrimary: 'pg-2', targetPrimary: 'pg-2' }), runtime: runtime('pg-2', [{ pod: 'pg-1', state: 'streaming' }]), ha: ha({ rwEndpoints: { state: 'ok', service: 'pg-rw', pods: ['pg-2'] } }) }))
94
+ expect(o.state).toBe('completed')
95
+ expect(o.steps?.every((s) => s.done)).toBe(true)
96
+ })
97
+
98
+ it('is unobservable, not completed or stalled, when the rejoin cannot be read', () => {
99
+ const denied = { ...runtime('pg-2', []), permission: { proxy: 'denied' as const } }
100
+ const o = advanceCNPGOperation(op({}), obs({ now: T0 + CNPG_OP_STALL_MS * 3, facts: facts({ currentPrimary: 'pg-2', targetPrimary: 'pg-2' }), runtime: denied }))
101
+ expect(o.state).toBe('unobservable')
102
+ expect(o.detail).toContain('runtime access')
103
+ })
104
+
105
+ it('fails when a different instance became primary', () => {
106
+ const o = advanceCNPGOperation(op({}), obs({ facts: facts({ currentPrimary: 'pg-3', targetPrimary: 'pg-3' }) }))
107
+ expect(o.state).toBe('failed')
108
+ })
109
+
110
+ it('stalls only with telemetry showing no movement', () => {
111
+ const f = facts({ targetPrimary: 'pg-2', phase: 'Switchover in progress' })
112
+ let o = advanceCNPGOperation(op({}), obs({ facts: f }))
113
+ o = advanceCNPGOperation(o, obs({ now: T0 + CNPG_OP_STALL_MS + 60_000, facts: f }))
114
+ expect(o.state).toBe('stalled')
115
+ })
116
+
117
+ it('is superseded when the Cluster was recreated', () => {
118
+ const o = advanceCNPGOperation(op({}), obs({ clusterUID: 'uid-2', facts: facts({}) }))
119
+ expect(o.state).toBe('superseded')
120
+ })
121
+
122
+ it.each([
123
+ undefined,
124
+ { updatedAt: T0 - 1, failed: false },
125
+ { updatedAt: T0 + 30_000, failed: true },
126
+ ])('withholds an unverified replacement UID (%j)', (clusterUID) => {
127
+ const o = advanceCNPGOperation(op({}), obs({ clusterUID: 'uid-2', freshness: { clusterUID } }))
128
+ expect(o.state).not.toBe('superseded')
129
+ expect(o.finishedAt).toBeUndefined()
130
+ })
131
+ })
132
+
133
+ describe('restart evidence', () => {
134
+ it('counts a recreated Pod, a restarted container or a moved postmaster start', () => {
135
+ const since = T0
136
+ const h = ha({ instances: [{ pod: 'pg-1', podUID: 'new', role: 'primary', ready: true, restartCount: 0, podCreatedAt: '2026-09-30T11:00:00Z', postgresStartedAt: '2026-09-30T11:00:00Z' }] })
137
+ expect(restartedSince(obs({ ha: h }), 'pg-1', since, 'old')).toBe(true)
138
+ expect(restartedSince(obs({ ha: h }), 'pg-1', since, 'new')).toBe(false)
139
+ const rt = runtime('pg-1', [])
140
+ rt.instances[0].metrics.postmasterStartTime = (T0 + 5_000) / 1000
141
+ expect(restartedSince(obs({ ha: h, runtime: rt }), 'pg-1', since, 'new')).toBe(true)
142
+ expect(restartedSince(obs({}), 'pg-1', since)).toBeNull()
143
+ })
144
+
145
+ it('completes a rolling restart when every instance restarted and is ready', () => {
146
+ const h = ha({
147
+ instances: [
148
+ { pod: 'pg-1', podUID: 'a', role: 'primary', ready: true, restartCount: 0, postgresStartedAt: '2026-09-30T12:01:00Z' },
149
+ { pod: 'pg-2', podUID: 'b2', role: 'replica', ready: true, restartCount: 0 },
150
+ ],
151
+ })
152
+ const o = advanceCNPGOperation(op({ kind: 'restart', baseline: { instances: ['pg-1', 'pg-2'], podUIDs: { 'pg-1': 'a', 'pg-2': 'b' } } }), obs({ ha: h, facts: facts({}) }))
153
+ expect(o.state).toBe('completed')
154
+ })
155
+ })
156
+
157
+ describe('backup observer', () => {
158
+ const b = (phase?: string) => [{ metadata: { name: 'pg-1', namespace: 'db' }, status: phase ? { phase } : {} }]
159
+ it('follows the Backup phase and never guesses without access', () => {
160
+ const base = op({ kind: 'backup', target: { name: 'pg-1' } })
161
+ expect(advanceCNPGOperation(base, obs({ backups: b('running') })).state).toBe('progressing')
162
+ expect(advanceCNPGOperation(base, obs({ backups: b('completed') })).state).toBe('completed')
163
+ expect(advanceCNPGOperation(base, obs({ backups: b('failed') })).state).toBe('failed')
164
+ expect(advanceCNPGOperation(base, obs({ backups: undefined })).state).toBe('unobservable')
165
+ })
166
+
167
+ it('never calls a long backup stalled: its phase holds while the data copies', () => {
168
+ const base = op({ kind: 'backup', target: { name: 'pg-1' } })
169
+ for (const phase of ['pending', 'started', 'running', 'finalizing']) {
170
+ let o = advanceCNPGOperation(base, obs({ backups: b(phase) }))
171
+ o = advanceCNPGOperation(o, obs({ now: T0 + 2 * 3_600_000, backups: b(phase) }))
172
+ expect(o.state).toBe('progressing')
173
+ }
174
+ })
175
+
176
+ it('still stalls a Backup the operator never picked up', () => {
177
+ const base = op({ kind: 'backup', target: { name: 'pg-1' } })
178
+ let o = advanceCNPGOperation(base, obs({ backups: b() }))
179
+ o = advanceCNPGOperation(o, obs({ now: T0 + CNPG_OP_STALL_MS + 60_000, backups: b() }))
180
+ expect(o.state).toBe('stalled')
181
+ })
182
+ })
183
+
184
+ describe('reload', () => {
185
+ it('says there is no completion signal', () => {
186
+ const o = advanceCNPGOperation(op({ kind: 'reload' }), obs({}))
187
+ expect(o.state).toBe('unobservable')
188
+ expect(o.detail).toContain('Nothing in the cluster reports')
189
+ })
190
+ })
191
+
192
+ describe('supersedeFor', () => {
193
+ it('marks a conflicting unfinished operation on the same cluster', () => {
194
+ const older = op({ id: 'a' })
195
+ const next = op({ id: 'b', kind: 'hibernate', label: 'Hibernate' })
196
+ expect(supersedeFor([older], next)[0].state).toBe('superseded')
197
+ expect(supersedeFor([op({ id: 'c', cluster: 'other' })], next)[0].state).toBe('requested')
198
+ })
199
+
200
+ it('never reaches a record issued against another Radar', () => {
201
+ const next = op({ id: 'b', kind: 'hibernate', label: 'Hibernate' })
202
+ expect(supersedeFor([op({ id: 'a', apiBase: '/c/other/api' })], next)[0].state).toBe('requested')
203
+ })
204
+ })
205
+
206
+ describe('summarizeSteps', () => {
207
+ it('never completes past an unobservable step', () => {
208
+ expect(summarizeSteps([{ label: 'a', done: true }, { label: 'b', done: null }])).toBe('unobservable')
209
+ expect(summarizeSteps([{ label: 'a', done: true }, { label: 'b', done: false }])).toBe('progressing')
210
+ expect(summarizeSteps([{ label: 'a', done: false }])).toBe('requested')
211
+ })
212
+ })
213
+
214
+ describe('destroyInstance observer', () => {
215
+ const inst = (pod: string, ready: boolean) => ({ pod, podUID: `uid-${pod}`, role: 'standby' as const, ready, healthy: ready, fenced: false, podReadable: true, podExists: true })
216
+ const destroy = op({ kind: 'destroyInstance', target: { name: 'pg-3', uid: 'uid-pg-3' }, baseline: { instances: ['pg-1', 'pg-2', 'pg-3'] } })
217
+
218
+ it('stays open until a replacement instance exists and is ready', () => {
219
+ const next = advanceCNPGOperation(destroy, obs({ now: T0 + 60_000, facts: facts({ phase: 'Creating a new replica', instances: [inst('pg-1', true), inst('pg-2', true), inst('pg-4', false)] }) }))
220
+ expect(next.state).toBe('progressing')
221
+ expect(next.steps?.[0].done).toBe(true)
222
+ expect(next.steps?.[1].label).toContain('pg-4')
223
+ })
224
+
225
+ it('never completes while the destroyed instance is still there', () => {
226
+ expect(advanceCNPGOperation(destroy, obs({ now: T0 + 60_000, facts: facts({ instances: [inst('pg-1', true), inst('pg-2', true), inst('pg-3', true)] }) })).state).not.toBe('completed')
227
+ })
228
+ })
229
+
230
+ describe('operations Radar cannot observe', () => {
231
+ it('a reload finishes at once instead of being followed all session', () => {
232
+ const next = advanceCNPGOperation(op({ kind: 'reload', target: undefined }), obs({ now: T0 + 1000 }))
233
+ expect(next.state).toBe('unobservable')
234
+ expect(next.finishedAt).toBe(T0 + 1000)
235
+ })
236
+
237
+ it('anything else unobservable is left alone after the follow window', () => {
238
+ const early = advanceCNPGOperation(op({ kind: 'switchover' }), { now: T0 + 60_000 })
239
+ expect(early.finishedAt).toBeUndefined()
240
+ const late = advanceCNPGOperation(op({ kind: 'switchover' }), { now: T0 + 16 * 60_000 })
241
+ expect(late.state).toBe('unobservable')
242
+ expect(late.finishedAt).toBe(T0 + 16 * 60_000)
243
+ })
244
+
245
+ it.each(['reload', 'switchover'])('keeps an old %s operation open while its identity read is in flight', (kind) => {
246
+ const now = T0 + CNPG_OP_UNOBSERVABLE_MS + 1
247
+ const pending = advanceCNPGOperation(op({ kind }), { now, identityPending: true })
248
+ expect(pending.state).toBe('unobservable')
249
+ expect(pending.detail).toContain('identity')
250
+ expect(pending.finishedAt).toBeUndefined()
251
+ const replaced = advanceCNPGOperation(pending, obs({ now: now + 1, clusterUID: 'uid-2' }))
252
+ expect(replaced.state).toBe('superseded')
253
+ expect(replaced.finishedAt).toBe(now + 1)
254
+ })
255
+
256
+ it('bounds tracking after an unavailable identity read has settled', () => {
257
+ const now = T0 + CNPG_OP_UNOBSERVABLE_MS + 1
258
+ const unknown = advanceCNPGOperation(op({ kind: 'reload' }), { now, identityPending: false })
259
+ expect(unknown.state).toBe('unobservable')
260
+ expect(unknown.detail).toContain('identity')
261
+ expect(unknown.finishedAt).toBe(now)
262
+ })
263
+ })
264
+
265
+ describe('fence observer', () => {
266
+ const inst = (pod: string, ready: boolean) => ({ pod, podUID: `uid-${pod}`, role: 'standby' as const, ready, healthy: ready, fenced: true, podReadable: true, podExists: true })
267
+ const fenced = facts({ fencedInstances: { raw: '["pg-2"]', all: false, instances: ['pg-2'] }, instances: [inst('pg-2', false)] })
268
+ const fence = () => op({ kind: 'fence', label: 'Fence pg-2', target: undefined, baseline: { instances: ['pg-2'] } })
269
+ const withStatus = (status: Record<string, unknown>) => {
270
+ const rt = runtime('pg-1', [])
271
+ rt.instances.push({ pod: 'pg-2', role: 'replica', status, metrics: { state: 'ok' } } as never)
272
+ return rt
273
+ }
274
+
275
+ it('never claims PostgreSQL stopped: CloudNativePG reports no shutdown signal', () => {
276
+ for (const status of [{ state: 'ok' }, { state: 'ok', mightBeUnavailable: true }, { state: 'partial' }]) {
277
+ const o = advanceCNPGOperation(fence(), obs({ facts: fenced, runtime: withStatus(status) }))
278
+ expect(o.state).not.toBe('completed')
279
+ expect(o.steps?.find((x) => x.label.includes('PostgreSQL stopped'))?.done).toBeNull()
280
+ }
281
+ })
282
+ it('stops following once the fence is recorded, as unverified', () => {
283
+ const o = advanceCNPGOperation(fence(), obs({ facts: fenced }))
284
+ expect(o.state).toBe('unobservable')
285
+ expect(o.steps?.[0].done).toBe(true)
286
+ expect(o.finishedAt).toBeDefined()
287
+ })
288
+ it('keeps following until the fence annotation lands', () => {
289
+ const o = advanceCNPGOperation(fence(), obs({ facts: facts({ instances: [inst('pg-2', false)] }) }))
290
+ expect(o.steps?.[0].done).toBe(false)
291
+ expect(o.finishedAt).toBeUndefined()
292
+ })
293
+ })
294
+
295
+ describe('stale observation sources', () => {
296
+ const promoted = () => ({
297
+ facts: facts({ currentPrimary: 'pg-2', targetPrimary: 'pg-2' }),
298
+ runtime: runtime('pg-2', [{ pod: 'pg-1', state: 'streaming' }]),
299
+ ha: ha({ rwEndpoints: { state: 'ok', service: 'pg-rw', pods: ['pg-2'] } }),
300
+ })
301
+ const now = T0 + 30_000
302
+
303
+ it('an hour-old endpoints answer whose refresh failed cannot certify a switchover', () => {
304
+ const o = advanceCNPGOperation(
305
+ op({}),
306
+ obs({
307
+ ...promoted(),
308
+ freshness: {
309
+ clusterUID: fresh,
310
+ facts: { updatedAt: now, failed: false, clusterUID: 'uid-1' },
311
+ runtime: { updatedAt: now, failed: false, clusterUID: 'uid-1' },
312
+ ha: { updatedAt: T0 - 3_600_000, failed: true },
313
+ },
314
+ }),
315
+ )
316
+ expect(o.state).not.toBe('completed')
317
+ expect(o.steps?.find((s) => s.label.startsWith('Read-write Service'))?.done).toBeNull()
318
+ })
319
+ it('an answer from before the action is withheld even when its refresh did not fail', () => {
320
+ const o = advanceCNPGOperation(
321
+ op({}),
322
+ obs({ ...promoted(), freshness: { clusterUID: fresh, facts: { updatedAt: now, failed: false, clusterUID: 'uid-1' }, runtime: { updatedAt: now, failed: false, clusterUID: 'uid-1' }, ha: { updatedAt: T0 - 1, failed: false } } }),
323
+ )
324
+ expect(o.state).not.toBe('completed')
325
+ })
326
+ it('fresh sources still complete it', () => {
327
+ const o = advanceCNPGOperation(op({}), obs({ ...promoted(), freshness: { clusterUID: fresh, facts: fresh, runtime: fresh, ha: fresh } }))
328
+ expect(o.state).toBe('completed')
329
+ })
330
+
331
+ it.each(['facts', 'ha', 'runtime'] as const)('a fresh %s response from another Cluster cannot certify completion', (source) => {
332
+ const observation = obs({ ...promoted() })
333
+ if (source === 'ha') observation.ha!.cluster.uid = 'another-uid'
334
+ if (source === 'runtime') observation.runtime!.cluster.uid = 'another-uid'
335
+ observation.freshness = { ...observation.freshness, [source]: { ...fresh, clusterUID: 'another-uid' } }
336
+ const result = advanceCNPGOperation(op({}), observation)
337
+ expect(result.state).not.toBe('completed')
338
+ expect(result.finishedAt).toBeUndefined()
339
+ })
340
+ it('a source with no freshness record is withheld', () => {
341
+ const o = advanceCNPGOperation(op({}), { ...obs(promoted()), freshness: undefined })
342
+ expect(o.state).not.toBe('completed')
343
+ })
344
+ })
345
+
346
+ describe('switchover without readable endpoints', () => {
347
+ it('never completes while the read-write Service is unverified', () => {
348
+ const o = advanceCNPGOperation(
349
+ op({}),
350
+ obs({
351
+ facts: facts({ currentPrimary: 'pg-2', targetPrimary: 'pg-2' }),
352
+ runtime: runtime('pg-2', [{ pod: 'pg-1', state: 'streaming' }]),
353
+ ha: ha({ rwEndpoints: { state: 'denied', grant: { verb: 'list', group: 'discovery.k8s.io', resource: 'endpointslices', namespace: 'db' }, service: 'pg-rw', pods: [] } }),
354
+ }),
355
+ )
356
+ expect(o.state).toBe('unobservable')
357
+ const step = o.steps?.find((s) => s.label.startsWith('Read-write Service'))
358
+ expect(step?.done).toBeNull()
359
+ expect(o.detail).toContain('read-write Service is unverified')
360
+ })
361
+ })
362
+
363
+ describe('unread readiness after an observed restart', () => {
364
+ it.each(['restart', 'restartInstance'])('keeps %s unobservable rather than stalled', (kind) => {
365
+ const rt = runtime('pg-1', [])
366
+ rt.instances[0].metrics.postmasterStartTime = (T0 + 1000) / 1000
367
+ const operation = op({ kind, target: { name: 'pg-1' }, baseline: { instances: ['pg-1'] } })
368
+ const result = advanceCNPGOperation(operation, obs({ runtime: rt }))
369
+ expect(result.steps?.[0].done).toBeNull()
370
+ expect(result.state).toBe('unobservable')
371
+ expect(advanceCNPGOperation(result, obs({ now: T0 + CNPG_OP_STALL_MS + 1, runtime: rt })).state).toBe('unobservable')
372
+ })
373
+ })
374
+
375
+ it('supersedes a scheduled run when its source Cluster is recreated', () => {
376
+ const operation = op({ kind: 'run', target: { name: 'manual-backup' } })
377
+ const result = advanceCNPGOperation(operation, obs({ clusterUID: 'replacement', backups: [{ metadata: { name: 'manual-backup', namespace: 'db' }, status: { phase: 'completed' } }] }))
378
+ expect(result.state).toBe('superseded')
379
+ })
380
+
381
+ it('does not certify a backup whose source identity was never recorded', () => {
382
+ const result = advanceCNPGOperation(op({ kind: 'run', clusterUID: undefined, target: { name: 'manual-backup' } }), obs({ backups: [{ metadata: { name: 'manual-backup', namespace: 'db' }, status: { phase: 'completed' } }] }))
383
+ expect(result.state).toBe('unobservable')
384
+ })
385
+
386
+ describe('right after the request', () => {
387
+ const before = { updatedAt: T0 - 1_000, failed: false, clusterUID: 'uid-1' }
388
+
389
+ it.each([
390
+ ['no read yet', undefined],
391
+ ['only reads from before the request', { clusterUID: before, facts: before, cluster: before, ha: before, runtime: before, backups: before }],
392
+ ])('waits for the first read instead of reading "cannot be followed" (%s)', (_, freshness) => {
393
+ const o = advanceCNPGOperation(op({}), { now: T0 + 1_000, clusterUID: 'uid-1', facts: facts({}), freshness })
394
+ expect(o.state).toBe('requested')
395
+ expect(o.detail).toBe('Waiting for the first read since the request')
396
+ expect(o.finishedAt).toBeUndefined()
397
+ })
398
+
399
+ it('follows the observer once a read since the request lands', () => {
400
+ const waiting = advanceCNPGOperation(op({}), obs({ now: T0 + 1_000, freshness: { clusterUID: before, facts: before } }))
401
+ expect(waiting.state).toBe('requested')
402
+ const observed = advanceCNPGOperation(waiting, obs({ now: T0 + 5_000, facts: facts({ targetPrimary: 'pg-2', phase: 'Switchover in progress' }) }))
403
+ expect(observed.state).toBe('observed')
404
+ })
405
+
406
+ it('waits while a source the observer needs has not been re-read since the request', () => {
407
+ const fresh = { updatedAt: T0 + 500, failed: false, clusterUID: 'uid-1' }
408
+ const o = advanceCNPGOperation(op({}), { now: T0 + 1_000, clusterUID: 'uid-1', freshness: { clusterUID: fresh, facts: before } })
409
+ expect(o.state).toBe('requested')
410
+ expect(o.detail).toBe('Waiting for the first read since the request')
411
+ const later = advanceCNPGOperation(op({}), { now: T0 + CNPG_OP_FIRST_READ_MS, clusterUID: 'uid-1', freshness: { clusterUID: fresh, facts: before } })
412
+ expect(later.state).toBe('unobservable')
413
+ const failed = advanceCNPGOperation(op({}), { now: T0 + 1_000, clusterUID: 'uid-1', freshness: { clusterUID: fresh, facts: { ...before, failed: true } } })
414
+ expect(failed.state).toBe('unobservable')
415
+ })
416
+
417
+ it('an identity from before the request does not hold "requested" while the reads fail', () => {
418
+ const o = advanceCNPGOperation(op({}), { now: T0 + 1_000, identityReadFailed: true, freshness: { clusterUID: before } })
419
+ expect(o.state).toBe('unobservable')
420
+ expect(o.detail).toContain('identity')
421
+ expect(o.finishedAt).toBeUndefined()
422
+ const none = advanceCNPGOperation(op({}), { now: T0 + 1_000, identityReadFailed: true })
423
+ expect(none.state).toBe('unobservable')
424
+ })
425
+
426
+ it('a failed read makes it unobservable without ending it', () => {
427
+ const o = advanceCNPGOperation(op({}), { now: T0 + 1_000, freshness: { clusterUID: { updatedAt: T0 - 1_000, failed: true } } })
428
+ expect(o.state).toBe('unobservable')
429
+ expect(o.detail).toContain('identity')
430
+ expect(o.finishedAt).toBeUndefined()
431
+ })
432
+
433
+ it('still gives up after the follow window without a read', () => {
434
+ const now = T0 + CNPG_OP_UNOBSERVABLE_MS + 1
435
+ const o = advanceCNPGOperation(op({}), { now, freshness: { clusterUID: before } })
436
+ expect(o.state).toBe('unobservable')
437
+ expect(o.finishedAt).toBe(now)
438
+ })
439
+ })
440
+
441
+ describe('rolling restart and fencing', () => {
442
+ const restart = (fenced?: string[]) =>
443
+ op({ kind: 'restart', label: 'Rolling restart', target: undefined, baseline: { instances: ['pg-1', 'pg-2'], podUIDs: { 'pg-1': 'a', 'pg-2': 'b' }, ...(fenced ? { fenced } : {}) } })
444
+ // pg-2 restarted and is ready; the primary pg-1 has not restarted.
445
+ const h = ha({
446
+ instances: [
447
+ { pod: 'pg-1', podUID: 'a', role: 'primary', ready: false, restartCount: 0 },
448
+ { pod: 'pg-2', podUID: 'b2', role: 'replica', ready: true, restartCount: 0 },
449
+ ],
450
+ })
451
+ const primaryFenced = facts({ fencedInstances: { raw: '["pg-1"]', all: false, instances: ['pg-1'] } })
452
+ const allFenced = facts({ fencedInstances: { raw: '["*"]', all: true, instances: [] } })
453
+
454
+ it.each([
455
+ ['the primary', primaryFenced, []],
456
+ ['every instance', allFenced, []],
457
+ ['the primary, on an operation recorded without the fenced set', primaryFenced, undefined],
458
+ ])('never completes when %s was fenced mid-restart before restarting', (_, current, fenced) => {
459
+ const o = advanceCNPGOperation(restart(fenced), obs({ ha: h, facts: current }))
460
+ expect(o.state).not.toBe('completed')
461
+ expect(o.steps?.find((s) => s.label === 'pg-1 restarted and ready')?.done).toBe(false)
462
+ })
463
+
464
+ it('leaves out only the instances fenced when it was requested', () => {
465
+ const o = advanceCNPGOperation(restart(['pg-1']), obs({ ha: h, facts: primaryFenced }))
466
+ expect(o.state).toBe('completed')
467
+ expect(o.steps?.map((s) => s.label)).toEqual(['pg-2 restarted and ready'])
468
+ })
469
+
470
+ it('is superseded by a fence requested in Radar', () => {
471
+ const fence = op({ id: 'f', kind: 'fence', label: 'Fence pg-1', target: undefined, baseline: { instances: ['pg-1'] }, startedAt: T0 + 1_000 })
472
+ expect(supersedeFor([restart([])], fence)[0].state).toBe('superseded')
473
+ expect(supersedeFor([op({ kind: 'restartInstance', label: 'Restart pg-1', target: { name: 'pg-1' } })], fence)[0].state).toBe('superseded')
474
+ })
475
+
476
+ it('an instance restart is superseded only by a fence that covers its instance; a rolling restart by any fence', () => {
477
+ const restartPg2 = op({ id: 'r', kind: 'restartInstance', label: 'Restart pg-2', target: { name: 'pg-2' }, baseline: {} })
478
+ const fence = (label: string, instances: string[]) => op({ id: 'f', kind: 'fence', label, target: undefined, baseline: { instances }, startedAt: T0 + 1_000 })
479
+ expect(supersedeFor([restartPg2], fence('Fence pg-3', ['pg-3']))[0].state).toBe('requested')
480
+ expect(supersedeFor([restartPg2], fence('Fence pg-2', ['pg-2']))[0].state).toBe('superseded')
481
+ expect(supersedeFor([restartPg2], fence('Fence all instances', ['pg-1', 'pg-2', 'pg-3']))[0].state).toBe('superseded')
482
+ expect(supersedeFor([restart([])], fence('Fence pg-3', ['pg-3']))[0].state).toBe('superseded')
483
+ })
484
+ })
485
+
486
+ describe('unreadable readiness keeps a step unknown, not stalled', () => {
487
+ const unreadable = (pod: string) => ({ pod, podUID: `uid-${pod}`, role: 'standby' as const, ready: false, healthy: false, fenced: false, podReadable: false, podExists: true })
488
+ const readable = (pod: string) => ({ ...unreadable(pod), ready: true, healthy: true, podReadable: true })
489
+ const later = T0 + CNPG_OP_STALL_MS + 60_000
490
+
491
+ it('lifting a fence', () => {
492
+ const unfence = op({ kind: 'unfence', label: 'Lift fence on pg-2', target: undefined, baseline: { instances: ['pg-2'] } })
493
+ const f = facts({ instances: [readable('pg-1'), unreadable('pg-2')] })
494
+ let o = advanceCNPGOperation(unfence, obs({ facts: f }))
495
+ expect(o.steps?.map((s) => s.done)).toEqual([null, null])
496
+ expect(o.state).toBe('unobservable')
497
+ o = advanceCNPGOperation(o, obs({ now: later, facts: f }))
498
+ expect(o.state).toBe('unobservable')
499
+ })
500
+
501
+ it('destroying an instance', () => {
502
+ const destroy = op({ kind: 'destroyInstance', label: 'Destroy pg-3', target: { name: 'pg-3', uid: 'uid-pg-3' }, baseline: { instances: ['pg-1', 'pg-2', 'pg-3'] } })
503
+ const f = facts({ instances: [readable('pg-1'), readable('pg-2'), unreadable('pg-4')] })
504
+ let o = advanceCNPGOperation(destroy, obs({ facts: f }))
505
+ expect(o.steps?.map((s) => s.done)).toEqual([true, null, null, true])
506
+ expect(o.state).toBe('unobservable')
507
+ o = advanceCNPGOperation(o, obs({ now: later, facts: f }))
508
+ expect(o.state).toBe('unobservable')
509
+ })
510
+ })