@skyhook-io/radar-app 1.16.3 → 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 (321) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +138 -93
  4. package/src/RadarApp.tsx +27 -17
  5. package/src/api/actions.errorCode.test.ts +40 -0
  6. package/src/api/actions.test.ts +48 -0
  7. package/src/api/actions.ts +79 -0
  8. package/src/api/apiResources.navigation.test.tsx +232 -0
  9. package/src/api/apiResources.ts +46 -3
  10. package/src/api/client.goneResource.test.ts +49 -0
  11. package/src/api/client.helmSource.test.ts +39 -0
  12. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  13. package/src/api/client.namespaceSwitch.test.ts +107 -0
  14. package/src/api/client.ts +381 -47
  15. package/src/api/cnpg-ha.live.test.ts +140 -0
  16. package/src/api/cnpg-ha.test.ts +78 -0
  17. package/src/api/cnpg-ha.ts +19 -0
  18. package/src/api/cnpg-history.ts +123 -0
  19. package/src/api/cnpg-inspect.ts +102 -0
  20. package/src/api/cnpg-protection.ts +112 -0
  21. package/src/api/cnpg-recovery.test.tsx +60 -0
  22. package/src/api/cnpg-recovery.ts +262 -0
  23. package/src/api/cnpg-sessions.ts +175 -0
  24. package/src/api/cnpg-storage.ts +138 -0
  25. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  26. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  27. package/src/api/cnpg.ts +428 -10
  28. package/src/api/diagnose.test.ts +40 -0
  29. package/src/api/diagnose.ts +12 -1
  30. package/src/api/radarFeatures.render.test.tsx +65 -3
  31. package/src/api/radarFeatures.ts +6 -0
  32. package/src/api/reviewedActionMutation.test.tsx +164 -0
  33. package/src/api/reviewedActionMutation.ts +66 -0
  34. package/src/api/timelineSource.test.ts +9 -0
  35. package/src/api/timelineSource.ts +8 -0
  36. package/src/api/traffic.sources.test.ts +55 -0
  37. package/src/api/traffic.ts +173 -14
  38. package/src/components/applications/ApplicationsView.tsx +3 -0
  39. package/src/components/audit/AuditView.tsx +9 -0
  40. package/src/components/capacity/CapacityActivity.tsx +3 -31
  41. package/src/components/capacity/CapacityDemand.tsx +4 -36
  42. package/src/components/capacity/CapacityOverview.tsx +217 -56
  43. package/src/components/capacity/CapacityPoolDetail.tsx +24 -62
  44. package/src/components/capacity/CapacityView.test.tsx +354 -1
  45. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  46. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  47. package/src/components/capacity/routes.ts +42 -0
  48. package/src/components/capacity/shared.tsx +23 -150
  49. package/src/components/capacity/workspaceRoute.ts +18 -0
  50. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  51. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  52. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  53. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  54. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  55. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  56. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  57. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  58. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  59. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  60. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  61. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  62. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  63. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  64. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  65. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  66. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  67. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  68. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  69. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  70. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  71. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  72. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  73. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  74. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  75. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  76. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  77. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  78. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  79. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  80. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  81. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  82. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  83. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  84. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  85. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  86. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  87. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  88. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  89. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  90. package/src/components/cnpg/CNPGProtection.tsx +230 -124
  91. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  92. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  93. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  94. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  95. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  96. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  97. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  98. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  99. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  100. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  101. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  102. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  103. package/src/components/cnpg/CNPGView.tsx +34 -21
  104. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  105. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  106. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  107. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  108. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  109. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  110. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  111. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  112. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  113. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  114. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  115. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  116. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  117. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  118. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  119. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  120. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  121. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  122. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  123. package/src/components/cnpg/actions/actionModel.ts +127 -0
  124. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  125. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  126. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  127. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  128. package/src/components/cnpg/archivingRepair.ts +145 -0
  129. package/src/components/cnpg/baseBackup.test.ts +36 -0
  130. package/src/components/cnpg/baseBackup.ts +36 -0
  131. package/src/components/cnpg/blocking.test.ts +59 -0
  132. package/src/components/cnpg/blocking.ts +131 -0
  133. package/src/components/cnpg/coverageText.test.ts +27 -0
  134. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  135. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  136. package/src/components/cnpg/creation/targetModel.ts +149 -0
  137. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  138. package/src/components/cnpg/fleetStatus.ts +43 -0
  139. package/src/components/cnpg/formFields.ts +2 -0
  140. package/src/components/cnpg/grantText.test.tsx +11 -0
  141. package/src/components/cnpg/host.test.tsx +24 -0
  142. package/src/components/cnpg/host.tsx +101 -0
  143. package/src/components/cnpg/inspectModel.test.ts +120 -0
  144. package/src/components/cnpg/inspectModel.ts +140 -0
  145. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  146. package/src/components/cnpg/logicalSlots.ts +29 -0
  147. package/src/components/cnpg/lsn.test.ts +40 -0
  148. package/src/components/cnpg/lsn.ts +43 -0
  149. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  150. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  151. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  152. package/src/components/cnpg/operations/model.test.ts +510 -0
  153. package/src/components/cnpg/operations/model.ts +499 -0
  154. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  155. package/src/components/cnpg/operations/presentation.ts +84 -0
  156. package/src/components/cnpg/operations/store.ts +98 -0
  157. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  158. package/src/components/cnpg/operatorStatus.ts +194 -0
  159. package/src/components/cnpg/paths.test.ts +54 -0
  160. package/src/components/cnpg/paths.ts +47 -4
  161. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  162. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  163. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  164. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  165. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  166. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  167. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  168. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  169. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  172. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  173. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  174. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  175. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  176. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  177. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  178. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  179. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  180. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  181. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  182. package/src/components/cnpg/refresh.test.ts +38 -0
  183. package/src/components/cnpg/refresh.ts +17 -0
  184. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  185. package/src/components/cnpg/routes.test.ts +34 -3
  186. package/src/components/cnpg/routes.ts +60 -48
  187. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  188. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  189. package/src/components/cnpg/runtimeModel.ts +196 -0
  190. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  191. package/src/components/cnpg/runtimeParts.tsx +79 -0
  192. package/src/components/cnpg/shared.test.tsx +59 -0
  193. package/src/components/cnpg/shared.tsx +100 -182
  194. package/src/components/cnpg/storageModel.test.ts +107 -0
  195. package/src/components/cnpg/storageModel.ts +101 -0
  196. package/src/components/cnpg/trendSamples.test.ts +148 -0
  197. package/src/components/cnpg/trendSamples.ts +222 -0
  198. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  199. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  200. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  201. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  202. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  203. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  204. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  205. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  206. package/src/components/cnpg/workspaceRoute.ts +23 -0
  207. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  208. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  209. package/src/components/cost/CostView.test.ts +2 -1
  210. package/src/components/cost/CostView.tsx +0 -2
  211. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  212. package/src/components/cost/errors.ts +3 -1
  213. package/src/components/cost/source.test.ts +10 -0
  214. package/src/components/cost/source.ts +5 -1
  215. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  216. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  217. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  218. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  219. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  220. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  221. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  222. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  223. package/src/components/diagnose/InvestigationView.tsx +39 -2
  224. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  225. package/src/components/diagnose/agentCatalog.ts +2 -2
  226. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  227. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  228. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  229. package/src/components/dock/BottomDock.tsx +2 -0
  230. package/src/components/dock/TerminalTab.test.tsx +17 -0
  231. package/src/components/dock/TerminalTab.tsx +7 -3
  232. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  233. package/src/components/execution/BatchExecutionView.tsx +11 -11
  234. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  235. package/src/components/gitops/GitOpsView.tsx +16 -23
  236. package/src/components/helm/ChartBrowser.tsx +3 -3
  237. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  238. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  239. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  240. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  241. package/src/components/helm/RevisionHistory.tsx +3 -2
  242. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  243. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  244. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  245. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  246. package/src/components/helm/ValuesViewer.tsx +169 -78
  247. package/src/components/home/ActivitySummary.tsx +2 -1
  248. package/src/components/home/ClusterHealthCard.tsx +7 -0
  249. package/src/components/home/HomeView.tsx +20 -9
  250. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  251. package/src/components/home/TopologyPreview.tsx +2 -2
  252. package/src/components/home/mcpToolCatalog.ts +1 -1
  253. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  254. package/src/components/issues/IssuesPane.test.ts +65 -1
  255. package/src/components/issues/IssuesPane.tsx +188 -25
  256. package/src/components/issues/uncheckedSources.ts +21 -0
  257. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  258. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  259. package/src/components/resource/PVCUsageBar.tsx +15 -6
  260. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  261. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  262. package/src/components/resources/CompositeRenderer.tsx +4 -4
  263. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  264. package/src/components/resources/ResourcesView.tsx +26 -12
  265. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  266. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  267. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  268. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  269. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  270. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  271. package/src/components/settings/SettingsDialog.tsx +150 -34
  272. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  273. package/src/components/timeline/TimelineList.tsx +25 -24
  274. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  275. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  276. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  277. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  278. package/src/components/timeline/TimelineView.tsx +25 -45
  279. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  280. package/src/components/timeline/useStickyError.test.tsx +47 -0
  281. package/src/components/timeline/useStickyError.ts +15 -0
  282. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  283. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  284. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  285. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  286. package/src/components/traffic/TrafficGraph.tsx +109 -26
  287. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  288. package/src/components/traffic/TrafficView.tsx +582 -149
  289. package/src/components/traffic/trafficFilters.test.ts +398 -1
  290. package/src/components/traffic/trafficFilters.ts +408 -0
  291. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  292. package/src/components/ui/Markdown.test.tsx +65 -0
  293. package/src/components/ui/Markdown.tsx +144 -127
  294. package/src/components/whats-new/releaseNotes.ts +63 -1
  295. package/src/components/workload/WorkloadView.test.ts +11 -1
  296. package/src/components/workload/WorkloadView.tsx +162 -529
  297. package/src/components/workspace/layout.test.tsx +29 -0
  298. package/src/components/workspace/layout.tsx +259 -0
  299. package/src/components/workspace/table.ts +7 -0
  300. package/src/context/TimelineCustomization.tsx +15 -0
  301. package/src/contexts/CapabilitiesContext.tsx +1 -1
  302. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  303. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  304. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  305. package/src/integrations/resourceHost.test.tsx +55 -0
  306. package/src/integrations/resourceHost.ts +99 -0
  307. package/src/integrations/resourceHosts.test.tsx +141 -0
  308. package/src/integrations/resourceHosts.tsx +119 -0
  309. package/src/integrations/workspaceRoute.ts +20 -0
  310. package/src/integrations/workspaceRoutes.test.ts +40 -0
  311. package/src/integrations/workspaceRoutes.ts +22 -0
  312. package/src/integrations/workspaceScreens.tsx +14 -0
  313. package/src/utils/drawer-trail.ts +35 -0
  314. package/src/utils/kindFilterStore.test.ts +147 -0
  315. package/src/utils/kindFilterStore.ts +91 -0
  316. package/src/utils/navigation.test.ts +13 -1
  317. package/src/utils/navigation.ts +39 -2
  318. package/src/utils/page-links.ts +21 -0
  319. package/src/utils/tabStorage.ts +40 -0
  320. package/src/utils/viewMemory.test.ts +78 -0
  321. package/src/utils/viewMemory.ts +54 -0
@@ -0,0 +1,247 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { beforeEach, expect, it, vi } from 'vitest'
5
+ import { CNPG_OP_UNOBSERVABLE_MS, type CNPGTrackedOperation } from './model'
6
+
7
+ const state = vi.hoisted(() => ({
8
+ operations: [] as CNPGTrackedOperation[],
9
+ invalidateQueries: vi.fn(),
10
+ caps: vi.fn(() => ({ data: undefined as { uid: string } | undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })),
11
+ ha: vi.fn(() => ({ data: undefined as { cluster: { uid: string } } | undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })),
12
+ workspace: vi.fn(() => ({ dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })),
13
+ runtime: vi.fn(() => ({ data: undefined as { cluster: { uid: string } } | undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })),
14
+ }))
15
+ vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: state.invalidateQueries }) }))
16
+ vi.mock('../../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'kind-db' } }) }))
17
+ vi.mock('../../../api/cnpg', () => ({
18
+ useCNPGClusterCapabilities: state.caps,
19
+ useCNPGRuntime: state.runtime,
20
+ useCNPGWorkspace: state.workspace,
21
+ }))
22
+ vi.mock('../../../api/cnpg-ha', () => ({ useCNPGClusterHA: state.ha }))
23
+ vi.mock('./store', () => ({
24
+ useCNPGOperations: () => state.operations,
25
+ updateCNPGOperations: (update: (all: CNPGTrackedOperation[]) => CNPGTrackedOperation[]) => { state.operations = update(state.operations) },
26
+ dismissCNPGOperation: vi.fn(),
27
+ }))
28
+
29
+ const { CNPGOperationTracker } = await import('./CNPGOperationTracker')
30
+
31
+ beforeEach(() => {
32
+ state.invalidateQueries.mockClear()
33
+ state.workspace.mockReturnValue({ dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
34
+ state.caps.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
35
+ state.ha.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
36
+ state.runtime.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
37
+ })
38
+
39
+ it('uses a fresh runtime identity to supersede a hidden predecessor when other reads fail', () => {
40
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
41
+ const startedAt = Date.now() - 1_000
42
+ state.operations = [{ id: 'predecessor', kind: 'restart', label: 'Restart', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'old-uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: { instances: ['pg-1'], podUIDs: { 'pg-1': 'old-pod' } } }]
43
+ state.caps.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: true, isFetching: false, isFetchedAfterMount: true })
44
+ state.ha.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: true, isFetching: false, isFetchedAfterMount: true })
45
+ state.runtime.mockReturnValue({ data: { cluster: { uid: 'new-uid' } }, dataUpdatedAt: startedAt + 1, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
46
+ const host = document.createElement('div')
47
+ const root = createRoot(host)
48
+ try {
49
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
50
+ expect(state.operations[0].state).toBe('superseded')
51
+ expect(state.operations[0].finishedAt).toBeDefined()
52
+ } finally {
53
+ act(() => root.unmount())
54
+ }
55
+ })
56
+
57
+ it('observes a lone predecessor record even when identity filtering hides it', () => {
58
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
59
+ const op: CNPGTrackedOperation = { id: 'old', kind: 'reload', label: 'Reload', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'old-uid', startedAt: 1, lastProgressAt: 1, state: 'requested', baseline: {} }
60
+ const completed = { ...op, id: 'finished', state: 'completed' as const, finishedAt: 2 }
61
+ const otherContext = { ...op, id: 'other-context', context: 'prod' }
62
+ const otherCluster = { ...op, id: 'other-cluster', cluster: 'other' }
63
+ state.operations = [op, completed, otherContext, otherCluster]
64
+ state.caps.mockReturnValue({ data: { uid: 'new-uid' }, dataUpdatedAt: Date.now(), errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
65
+ const host = document.createElement('div')
66
+ const root = createRoot(host)
67
+ try {
68
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
69
+ expect(host.textContent).toBe('')
70
+ expect(state.ha).toHaveBeenCalledWith('db', 'pg', { enabled: true, refetchInterval: 5_000 })
71
+ expect(state.operations[0]).toMatchObject({ state: 'superseded', clusterUID: 'old-uid' })
72
+ expect(state.operations[0].finishedAt).toBeGreaterThan(2)
73
+ expect(state.operations.slice(1)).toEqual([completed, otherContext, otherCluster])
74
+ } finally {
75
+ act(() => root.unmount())
76
+ }
77
+ })
78
+
79
+ it.each([true, false])('keeps the current operation when an old capability UID has failed=%s', (failed) => {
80
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
81
+ const startedAt = Date.now() - 1_000
82
+ state.operations = [{ id: 'current', kind: 'switchover', label: 'Switch over', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'new-uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: {} }]
83
+ state.caps.mockReturnValue({ data: { uid: 'old-uid' }, dataUpdatedAt: startedAt - 1, errorUpdatedAt: 0, isError: failed, isFetching: false, isFetchedAfterMount: true })
84
+ state.ha.mockReturnValue({ data: { cluster: { uid: 'new-uid' } }, dataUpdatedAt: startedAt + 1, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
85
+ const host = document.createElement('div')
86
+ const root = createRoot(host)
87
+ try {
88
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
89
+ expect(state.operations[0].state).not.toBe('superseded')
90
+ expect(state.operations[0].finishedAt).toBeUndefined()
91
+ expect(state.operations[0].clusterUID).toBe('new-uid')
92
+ } finally {
93
+ act(() => root.unmount())
94
+ }
95
+ })
96
+
97
+
98
+ it.each(['reload', 'restart'])('waits for the first identity response before finalizing an old hidden %s', (kind) => {
99
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
100
+ const startedAt = Date.now() - CNPG_OP_UNOBSERVABLE_MS - 1_000
101
+ state.operations = [{ id: 'predecessor', kind, label: kind, apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'old-uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: {} }]
102
+ state.caps.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: true, isFetchedAfterMount: false })
103
+ const host = document.createElement('div')
104
+ const root = createRoot(host)
105
+ try {
106
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
107
+ expect(host.textContent).toBe('')
108
+ expect(state.operations[0].state).toBe('unobservable')
109
+ expect(state.operations[0].finishedAt).toBeUndefined()
110
+ state.caps.mockReturnValue({ data: { uid: 'new-uid' }, dataUpdatedAt: Date.now(), errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
111
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
112
+ expect(state.operations[0].state).toBe('superseded')
113
+ expect(state.operations[0].finishedAt).toBeDefined()
114
+ } finally {
115
+ act(() => root.unmount())
116
+ }
117
+ })
118
+
119
+
120
+ it('bounds old unobservable operations during background refetches after initial reads settled', () => {
121
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
122
+ const startedAt = Date.now() - CNPG_OP_UNOBSERVABLE_MS - 1_000
123
+ state.operations = [{ id: 'predecessor', kind: 'reload', label: 'Reload', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'old-uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: {} }]
124
+ state.caps.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: true, isFetching: true, isFetchedAfterMount: true })
125
+ state.ha.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: true, isFetching: true, isFetchedAfterMount: true })
126
+ state.workspace.mockReturnValue({ dataUpdatedAt: 0, errorUpdatedAt: 0, isError: true, isFetching: true, isFetchedAfterMount: true })
127
+ const host = document.createElement('div')
128
+ const root = createRoot(host)
129
+ try {
130
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
131
+ expect(state.operations[0].state).toBe('unobservable')
132
+ expect(state.operations[0].detail).toContain('identity')
133
+ expect(state.operations[0].finishedAt).toBeDefined()
134
+ } finally {
135
+ act(() => root.unmount())
136
+ }
137
+ })
138
+
139
+
140
+ it('advances the follow-window clock when only repeated failure timestamps change', () => {
141
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
142
+ const startedAt = Date.now()
143
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(startedAt + 1_000)
144
+ state.operations = [{ id: 'outage', kind: 'restart', label: 'Restart', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'old-uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: {} }]
145
+ const failed = { data: undefined, dataUpdatedAt: 0, errorUpdatedAt: startedAt + 1_000, isError: true, isFetching: false, isFetchedAfterMount: true }
146
+ state.caps.mockReturnValue(failed)
147
+ state.ha.mockReturnValue(failed)
148
+ state.runtime.mockReturnValue(failed)
149
+ state.workspace.mockReturnValue(failed)
150
+ const host = document.createElement('div')
151
+ const root = createRoot(host)
152
+ try {
153
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
154
+ expect(state.operations[0].finishedAt).toBeUndefined()
155
+ const settledAt = startedAt + CNPG_OP_UNOBSERVABLE_MS + 1
156
+ clock.mockReturnValue(settledAt)
157
+ state.caps.mockReturnValue({ ...failed, errorUpdatedAt: settledAt })
158
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
159
+ expect(state.operations[0].state).toBe('unobservable')
160
+ expect(state.operations[0].finishedAt).toBe(settledAt)
161
+ expect(state.operations[0].lastCheckedAt).toBe(settledAt)
162
+ } finally {
163
+ act(() => root.unmount())
164
+ clock.mockRestore()
165
+ }
166
+ })
167
+
168
+ it('preserves an in-flight initial identity read when its next poll is due', () => {
169
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
170
+ vi.useFakeTimers()
171
+ const startedAt = Date.now() - CNPG_OP_UNOBSERVABLE_MS - 1_000
172
+ state.operations = [{ id: 'slow', kind: 'reload', label: 'Reload', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'old-uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: {} }]
173
+ state.caps.mockReturnValue({ data: { uid: 'old-uid' }, dataUpdatedAt: startedAt - 1, errorUpdatedAt: 0, isError: false, isFetching: true, isFetchedAfterMount: false })
174
+ const host = document.createElement('div')
175
+ const root = createRoot(host)
176
+ try {
177
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
178
+ act(() => vi.advanceTimersByTime(5_000))
179
+ expect(state.operations[0].finishedAt).toBeUndefined()
180
+ expect(state.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cnpg', 'capabilities', 'clusters', 'db', 'pg'] }, { cancelRefetch: false })
181
+ expect(state.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cnpg', 'workspace', 'db'] }, { cancelRefetch: false })
182
+ } finally {
183
+ act(() => root.unmount())
184
+ vi.useRealTimers()
185
+ }
186
+ })
187
+
188
+ it('reads "requested", not "cannot be followed", until the first read since the request lands', () => {
189
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
190
+ const startedAt = Date.now() - 1_000
191
+ state.operations = [{ id: 'fresh', kind: 'switchover', label: 'Switch over', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'uid', target: { name: 'pg-2' }, startedAt, lastProgressAt: startedAt, state: 'requested', baseline: { currentPrimary: 'pg-1' } }]
192
+ state.caps.mockReturnValue({ data: { uid: 'uid' }, dataUpdatedAt: startedAt - 1, errorUpdatedAt: 0, isError: false, isFetching: true, isFetchedAfterMount: true })
193
+ const host = document.createElement('div')
194
+ const root = createRoot(host)
195
+ try {
196
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="uid" />))
197
+ expect(state.operations[0]).toMatchObject({ state: 'requested', detail: 'Waiting for the first read since the request' })
198
+ expect(host.textContent).toContain('requested')
199
+ state.caps.mockReturnValue({ data: undefined, dataUpdatedAt: startedAt - 1, errorUpdatedAt: Date.now(), isError: true, isFetching: false, isFetchedAfterMount: true })
200
+ state.ha.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: Date.now(), isError: true, isFetching: false, isFetchedAfterMount: true })
201
+ state.workspace.mockReturnValue({ dataUpdatedAt: 0, errorUpdatedAt: Date.now(), isError: true, isFetching: false, isFetchedAfterMount: true })
202
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="uid" />))
203
+ expect(state.operations[0].state).toBe('unobservable')
204
+ expect(state.operations[0].finishedAt).toBeUndefined()
205
+ } finally {
206
+ act(() => root.unmount())
207
+ }
208
+ })
209
+
210
+ it('does not let runtime cached by another view stand in for the identity while the polled reads fail', () => {
211
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
212
+ const startedAt = Date.now() - 1_000
213
+ state.operations = [{ id: 'fence', kind: 'fence', label: 'Fence pg-2', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: { instances: ['pg-2'] } }]
214
+ const failing = { data: undefined, dataUpdatedAt: 0, errorUpdatedAt: Date.now(), isError: true, isFetching: false, isFetchedAfterMount: true }
215
+ state.caps.mockReturnValue(failing)
216
+ state.ha.mockReturnValue(failing)
217
+ state.workspace.mockReturnValue(failing)
218
+ state.runtime.mockReturnValue({ data: { cluster: { uid: 'uid' } }, dataUpdatedAt: startedAt - 1, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
219
+ const host = document.createElement('div')
220
+ const root = createRoot(host)
221
+ try {
222
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="uid" />))
223
+ expect(state.runtime).toHaveBeenLastCalledWith('db', 'pg', false)
224
+ expect(state.operations[0].state).toBe('unobservable')
225
+ expect(state.operations[0].detail).toContain('identity')
226
+ expect(state.operations[0].finishedAt).toBeUndefined()
227
+ } finally {
228
+ act(() => root.unmount())
229
+ }
230
+ })
231
+
232
+ it('leaves records issued against another Radar alone', () => {
233
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
234
+ const startedAt = Date.now() - 1_000
235
+ const other: CNPGTrackedOperation = { id: 'east', kind: 'reload', label: 'Reload', apiBase: '/c/east/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: {} }
236
+ state.operations = [other]
237
+ state.caps.mockReturnValue({ data: { uid: 'uid' }, dataUpdatedAt: Date.now(), errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
238
+ const host = document.createElement('div')
239
+ const root = createRoot(host)
240
+ try {
241
+ act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="uid" />))
242
+ expect(host.textContent).toBe('')
243
+ expect(state.operations).toEqual([other])
244
+ } finally {
245
+ act(() => root.unmount())
246
+ }
247
+ })
@@ -0,0 +1,255 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { useQueryClient } from '@tanstack/react-query'
3
+ import { Check, Circle, CircleHelp, Copy, X } from 'lucide-react'
4
+ import { Badge, DialogPortal, StatusDot, coverageReadable, formatAge } from '@skyhook-io/k8s-ui'
5
+ import { copyText } from '@skyhook-io/k8s-ui/utils/clipboard'
6
+ import { getApiBase, routePath } from '../../../api/config'
7
+ import { cnpgClusterFullPath } from '../paths'
8
+ import { useToast } from '../../ui/Toast'
9
+ import { useConnection } from '../../../context/ConnectionContext'
10
+ import { useCNPGClusterCapabilities, useCNPGRuntime, useCNPGWorkspace } from '../../../api/cnpg'
11
+ import { useCNPGClusterHA } from '../../../api/cnpg-ha'
12
+ import {
13
+ CNPG_OP_TERMINAL,
14
+ advanceCNPGOperation,
15
+ cnpgOperationFollowed,
16
+ type CNPGObservation,
17
+ type CNPGTrackedOperation,
18
+ } from './model'
19
+ import {
20
+ CNPG_OP_STATE_TEXT as STATE_TEXT,
21
+ CNPG_OP_STATE_TONE as STATE_TONE,
22
+ CNPG_OP_STATE_SEVERITY as STATE_SEVERITY,
23
+ cnpgOperationHandoff,
24
+ cnpgOperationsForCluster,
25
+ } from './presentation'
26
+ import { dismissCNPGOperation, updateCNPGOperations, useCNPGOperations } from './store'
27
+
28
+ const POLL_MS = 5_000
29
+
30
+ function sourceFreshness(q: { dataUpdatedAt: number; errorUpdatedAt: number; isError: boolean; isFetching: boolean; isFetchedAfterMount: boolean }) {
31
+ return { updatedAt: q.dataUpdatedAt, checkedAt: Math.max(q.dataUpdatedAt, q.errorUpdatedAt), failed: q.isError, initialPending: q.isFetching && !q.isFetchedAfterMount }
32
+ }
33
+
34
+ /**
35
+ * Follows the operations requested on this Cluster in this browser session
36
+ * and shows them in the header until they finish. Reads only what the caller
37
+ * can read; a step it cannot see stays "unknown".
38
+ */
39
+ export function CNPGOperationTracker({ namespace, name, uid }: { namespace: string; name: string; uid: string }) {
40
+ const { connection } = useConnection()
41
+ const context = connection.context
42
+ const apiBase = getApiBase()
43
+ const allOps = useCNPGOperations()
44
+ const ops = cnpgOperationsForCluster(allOps, { apiBase, namespace, name, context, uid })
45
+ const active = ops.filter(cnpgOperationFollowed)
46
+ const followed = cnpgOperationsForCluster(allOps, { apiBase, namespace, name, context }).filter(cnpgOperationFollowed)
47
+ const following = followed.length > 0
48
+ const needsRuntime = followed.some((o) => ['switchover', 'unfence', 'restart', 'restartInstance', 'destroyInstance'].includes(o.kind))
49
+
50
+ const queryClient = useQueryClient()
51
+ const caps = useCNPGClusterCapabilities(namespace, name, following)
52
+ const ha = useCNPGClusterHA(namespace, name, { enabled: following, refetchInterval: following ? POLL_MS : false })
53
+ const runtime = useCNPGRuntime(namespace, name, following && needsRuntime)
54
+ const workspace = useCNPGWorkspace([namespace], { enabled: following })
55
+
56
+ useEffect(() => {
57
+ if (!following) return
58
+ const t = setInterval(() => {
59
+ if (document.visibilityState === 'hidden') return
60
+ queryClient.invalidateQueries({ queryKey: ['cnpg', 'capabilities', 'clusters', namespace, name] }, { cancelRefetch: false })
61
+ queryClient.invalidateQueries({ queryKey: ['cnpg', 'workspace', namespace] }, { cancelRefetch: false })
62
+ }, POLL_MS)
63
+ return () => clearInterval(t)
64
+ }, [following, queryClient, namespace, name])
65
+
66
+ const capsFresh = sourceFreshness(caps)
67
+ const haFresh = sourceFreshness(ha)
68
+ const runtimeFresh = sourceFreshness(runtime)
69
+ const workspaceFresh = sourceFreshness(workspace)
70
+ const observation = useMemo<CNPGObservation | null>(() => {
71
+ if (!following) return null
72
+ const ws = workspace.data
73
+ const cluster = ws?.objects.clusters?.find(
74
+ (c: any) => c?.metadata?.namespace === namespace && c?.metadata?.name === name,
75
+ )
76
+ const backupsReadable = ws?.coverage.backups ? coverageReadable(ws.coverage.backups, namespace) : false
77
+ // Runtime that is not polled is a cached answer from another view, not a
78
+ // check of the Cluster now.
79
+ const identity = [
80
+ { uid: caps.data?.uid, updatedAt: capsFresh.updatedAt, failed: capsFresh.failed },
81
+ { uid: ha.data?.cluster.uid, updatedAt: haFresh.updatedAt, failed: haFresh.failed },
82
+ ...(needsRuntime ? [{ uid: runtime.data?.cluster.uid, updatedAt: runtimeFresh.updatedAt, failed: runtimeFresh.failed }] : []),
83
+ { uid: cluster?.metadata?.uid, updatedAt: workspaceFresh.updatedAt, failed: workspaceFresh.failed },
84
+ ].filter((source) => source.uid && !source.failed).sort((a, b) => b.updatedAt - a.updatedAt)[0]
85
+ return {
86
+ // Failed polls must advance elapsed-time checks even when no data changes.
87
+ now: Math.max(Date.now(), capsFresh.checkedAt, haFresh.checkedAt, runtimeFresh.checkedAt, workspaceFresh.checkedAt),
88
+ clusterUID: identity?.uid,
89
+ identityPending: capsFresh.initialPending || haFresh.initialPending || workspaceFresh.initialPending || (needsRuntime && runtimeFresh.initialPending),
90
+ identityReadFailed: capsFresh.failed || haFresh.failed || workspaceFresh.failed || (needsRuntime && runtimeFresh.failed),
91
+ facts: caps.data?.facts,
92
+ cluster,
93
+ ha: ha.data,
94
+ runtime: runtime.data,
95
+ backups: backupsReadable ? (ws?.objects.backups ?? []) : undefined,
96
+ freshness: {
97
+ clusterUID: identity ? { updatedAt: identity.updatedAt, failed: false } : undefined,
98
+ facts: { updatedAt: capsFresh.updatedAt, failed: capsFresh.failed, clusterUID: caps.data?.uid },
99
+ cluster: { updatedAt: workspaceFresh.updatedAt, failed: workspaceFresh.failed, clusterUID: cluster?.metadata?.uid },
100
+ backups: { updatedAt: workspaceFresh.updatedAt, failed: workspaceFresh.failed },
101
+ ha: { updatedAt: haFresh.updatedAt, failed: haFresh.failed, clusterUID: ha.data?.cluster.uid },
102
+ runtime: { updatedAt: runtimeFresh.updatedAt, failed: runtimeFresh.failed, clusterUID: runtime.data?.cluster.uid },
103
+ },
104
+ }
105
+ }, [
106
+ following,
107
+ needsRuntime,
108
+ namespace,
109
+ name,
110
+ caps.data,
111
+ ha.data,
112
+ runtime.data,
113
+ workspace.data,
114
+ capsFresh.updatedAt,
115
+ capsFresh.failed,
116
+ haFresh.updatedAt,
117
+ haFresh.failed,
118
+ runtimeFresh.updatedAt,
119
+ runtimeFresh.failed,
120
+ workspaceFresh.updatedAt,
121
+ workspaceFresh.failed,
122
+ capsFresh.initialPending,
123
+ haFresh.initialPending,
124
+ workspaceFresh.initialPending,
125
+ runtimeFresh.initialPending,
126
+ capsFresh.checkedAt,
127
+ haFresh.checkedAt,
128
+ workspaceFresh.checkedAt,
129
+ runtimeFresh.checkedAt,
130
+ ])
131
+
132
+ useEffect(() => {
133
+ if (!observation) return
134
+ updateCNPGOperations((all) => {
135
+ let changed = false
136
+ const next = all.map((op) => {
137
+ if (op.apiBase !== apiBase || op.namespace !== namespace || op.cluster !== name || op.context !== context || !cnpgOperationFollowed(op))
138
+ return op
139
+ const adv = advanceCNPGOperation(op, observation)
140
+ if (JSON.stringify(adv) !== JSON.stringify(op)) changed = true
141
+ return adv
142
+ })
143
+ return changed ? next : all
144
+ })
145
+ }, [observation, apiBase, namespace, name, context])
146
+
147
+ const [open, setOpen] = useState(false)
148
+ if (ops.length === 0) return null
149
+ const lead = active[active.length - 1] ?? ops[ops.length - 1]
150
+
151
+ return (
152
+ <div className="relative">
153
+ <button
154
+ type="button"
155
+ aria-haspopup="dialog"
156
+ aria-expanded={open}
157
+ onClick={() => setOpen((v) => !v)}
158
+ className="btn-secondary inline-flex max-w-[22rem] items-center gap-1.5 px-2.5 py-1.5 text-xs"
159
+ >
160
+ <StatusDot tone={STATE_TONE[lead.state]} />
161
+ <span className="truncate">{lead.label}</span>
162
+ <span className="shrink-0 text-theme-text-tertiary">· {STATE_TEXT[lead.state]}</span>
163
+ {ops.length > 1 && <span className="shrink-0 text-theme-text-tertiary">+{ops.length - 1}</span>}
164
+ </button>
165
+ <DialogPortal
166
+ open={open}
167
+ onClose={() => setOpen(false)}
168
+ ariaLabel="Operations on this cluster"
169
+ className="w-full max-w-xl p-4"
170
+ >
171
+ <div className="mb-3 flex items-center justify-between gap-3">
172
+ <h3 className="text-sm font-semibold text-theme-text-primary">Operations in this tab</h3>
173
+ <button
174
+ type="button"
175
+ onClick={() => setOpen(false)}
176
+ aria-label="Close operations"
177
+ className="rounded p-1 text-theme-text-secondary hover:bg-theme-hover"
178
+ >
179
+ <X className="h-4 w-4" />
180
+ </button>
181
+ </div>
182
+ <div className="max-h-[65vh] space-y-2 overflow-y-auto">
183
+ {[...ops].reverse().map((op) => (
184
+ <OperationRow key={op.id} op={op} />
185
+ ))}
186
+ </div>
187
+ </DialogPortal>
188
+ </div>
189
+ )
190
+ }
191
+
192
+ function OperationRow({ op }: { op: CNPGTrackedOperation }) {
193
+ const terminal = CNPG_OP_TERMINAL.has(op.state)
194
+ const { showSuccess, showError } = useToast()
195
+ const handoff = async () => {
196
+ const path = cnpgClusterFullPath(op.namespace, op.cluster, op.context)
197
+ const url = new URL(routePath(path), window.location.origin).href
198
+ const copied = await copyText(cnpgOperationHandoff(op, url))
199
+ if (copied) showSuccess('Handoff copied · the link opens current Cluster facts')
200
+ else showError('Could not copy the handoff')
201
+ }
202
+ return (
203
+ <div className="rounded-md border border-theme-border bg-theme-base p-2 text-xs">
204
+ <div className="flex items-center gap-2">
205
+ <span className="min-w-0 flex-1 truncate font-medium text-theme-text-primary">{op.label}</span>
206
+ <Badge severity={STATE_SEVERITY[op.state]} size="sm">
207
+ {STATE_TEXT[op.state]}
208
+ </Badge>
209
+ {(terminal || op.state === 'unobservable') && (
210
+ <button
211
+ type="button"
212
+ aria-label={`Dismiss ${op.label}`}
213
+ onClick={() => dismissCNPGOperation(op.id)}
214
+ className="rounded p-0.5 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
215
+ >
216
+ <X className="h-3.5 w-3.5" />
217
+ </button>
218
+ )}
219
+ </div>
220
+ <div className="mt-0.5 text-theme-text-tertiary">
221
+ requested {formatAge(new Date(op.startedAt).toISOString())} ago
222
+ </div>
223
+ <div className="mt-0.5 text-theme-text-tertiary">
224
+ {op.lastCheckedAt
225
+ ? `Last checked in this tab ${formatAge(new Date(op.lastCheckedAt).toISOString())} ago`
226
+ : 'Not yet checked in this tab'}
227
+ </div>
228
+ {op.detail && <div className="mt-1 text-theme-text-secondary">{op.detail}</div>}
229
+ {op.steps && op.steps.length > 0 && (
230
+ <ul className="mt-1.5 space-y-0.5">
231
+ {op.steps.map((s) => (
232
+ <li key={s.label} className="flex items-center gap-1.5 text-theme-text-secondary">
233
+ {s.done === true ? (
234
+ <Check className="h-3 w-3 shrink-0 text-accent" aria-label="done" />
235
+ ) : s.done === false ? (
236
+ <Circle className="h-3 w-3 shrink-0 text-theme-text-tertiary" aria-label="not yet" />
237
+ ) : (
238
+ <CircleHelp className="h-3 w-3 shrink-0 text-theme-text-tertiary" aria-label="not observable" />
239
+ )}
240
+ <span>{s.label}</span>
241
+ </li>
242
+ ))}
243
+ </ul>
244
+ )}
245
+ <button
246
+ type="button"
247
+ onClick={() => void handoff()}
248
+ className="mt-2 inline-flex items-center gap-1 text-accent-text hover:underline"
249
+ >
250
+ <Copy className="h-3 w-3" />
251
+ Copy handoff
252
+ </button>
253
+ </div>
254
+ )
255
+ }