@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,40 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { ApiError } from './client'
3
+ import { cnpgActionErrorCode } from './cnpg'
4
+ import { actionCompleted, actionOutcomeLocked } from './actions'
5
+
6
+ describe('cnpgActionErrorCode', () => {
7
+ it('keeps the server code when there is one', () => {
8
+ expect(cnpgActionErrorCode(new ApiError('changed', 409, { code: 'changed' }))).toBe('changed')
9
+ })
10
+ it('treats a lost connection or an apiserver timeout as an unknown outcome', () => {
11
+ expect(cnpgActionErrorCode(new TypeError('Failed to fetch'))).toBe('outcome_unknown')
12
+ expect(cnpgActionErrorCode(new ApiError('timeout', 504))).toBe('outcome_unknown')
13
+ expect(cnpgActionErrorCode(new ApiError('internal', 500))).toBe('outcome_unknown')
14
+ })
15
+ it('treats an uncoded 503 as unknown: a proxy may have forwarded the write before the upstream reset', () => {
16
+ const reset = new ApiError('upstream connect error or disconnect/reset before headers', 503)
17
+ expect(cnpgActionErrorCode(reset)).toBe('outcome_unknown')
18
+ expect(actionOutcomeLocked(reset)).toBe(true)
19
+ })
20
+ it('treats refusals before any write as known failures', () => {
21
+ expect(cnpgActionErrorCode(new ApiError('webhook down', 503, { code: 'operator_webhook_unavailable' }))).toBe('operator_webhook_unavailable')
22
+ expect(actionOutcomeLocked(new ApiError('webhook down', 503, { code: 'operator_webhook_unavailable' }))).toBe(false)
23
+ expect(actionOutcomeLocked(new ApiError('Not connected to cluster', 503, { error: 'Not connected to cluster', code: 'unavailable' }))).toBe(false)
24
+ expect(cnpgActionErrorCode(new ApiError('forbidden', 403))).toBeUndefined()
25
+ expect(cnpgActionErrorCode(null)).toBeUndefined()
26
+ })
27
+ })
28
+
29
+ describe('partial outcomes', () => {
30
+ const partial = new ApiError('stopped', 403, { code: 'partial', completed: ['deleted PVC pg-2', 'deleted PVC pg-2-wal'] })
31
+ it('locks confirm and lists what already happened, whatever the status', () => {
32
+ expect(cnpgActionErrorCode(partial)).toBe('partial')
33
+ expect(actionOutcomeLocked(partial)).toBe(true)
34
+ expect(actionCompleted(partial)).toEqual(['deleted PVC pg-2', 'deleted PVC pg-2-wal'])
35
+ })
36
+ it('leaves known refusals unlocked', () => {
37
+ expect(actionOutcomeLocked(new ApiError('changed', 409, { code: 'changed' }))).toBe(false)
38
+ expect(actionCompleted(new ApiError('changed', 409, { code: 'changed' }))).toEqual([])
39
+ })
40
+ })
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { capabilityReason, actionErrorCode, actionOutcomeLocked, actionDialogResult } from './actions'
3
+ import { ApiError } from './client'
4
+ import { RadarFeatureUnsupportedError } from './radarFeatures'
5
+
6
+ describe('capabilityReason', () => {
7
+ it('says nothing for an allowed action, and the server reason when there is one', () => {
8
+ expect(capabilityReason({ allowed: true, permission: 'allowed' })).toBeUndefined()
9
+ expect(capabilityReason({ allowed: false, permission: 'allowed', reason: 'The Cluster is hibernated' })).toBe('The Cluster is hibernated')
10
+ })
11
+ it('names the missing grant when the reason is absent', () => {
12
+ expect(capabilityReason({ allowed: false, permission: 'denied', grant: { verb: 'create', group: 'postgresql.cnpg.io', resource: 'backups', namespace: 'db' } })).toBe(
13
+ 'Your account may not do this (create backups (postgresql.cnpg.io) in namespace db)',
14
+ )
15
+ expect(capabilityReason({ allowed: false, permission: 'unknown' })).toBe('Not available')
16
+ })
17
+ })
18
+
19
+ it('does not call a pre-request feature refusal an unknown write outcome', () => {
20
+ const error = new RadarFeatureUnsupportedError('cnpgWorkspace', {})
21
+ expect(actionErrorCode(error)).toBeUndefined()
22
+ expect(actionOutcomeLocked(error)).toBe(false)
23
+ expect(actionDialogResult(error)?.state).toBe('error')
24
+ })
25
+
26
+ describe('actionDialogResult', () => {
27
+ it.each([
28
+ [new ApiError('Review changed', 409, { code: 'changed' }), 'error'],
29
+ [new ApiError('upstream connect error or disconnect/reset before headers', 503), 'unknown'],
30
+ [new ApiError('The operator webhook did not answer', 503, { code: 'operator_webhook_unavailable' }), 'error'],
31
+ [new ApiError('Gateway timeout', 504), 'unknown'],
32
+ [new Error('Connection lost'), 'unknown'],
33
+ [new ApiError('Write result missing', 500, { code: 'outcome_unknown' }), 'unknown'],
34
+ [new ApiError('One volume removed', 409, { code: 'partial', completed: ['delete volume'] }), 'partial'],
35
+ ] as const)('keeps the result classification for %s', (error, state) => {
36
+ expect(actionDialogResult(error)?.state).toBe(state)
37
+ expect(actionDialogResult(error)?.message).toBe(error.message)
38
+ })
39
+
40
+ it('selects the heading belonging to the result and retains no empty result', () => {
41
+ const titles = { error: 'Review could not be loaded', unknown: 'The Cluster may have changed', partial: 'Earlier writes partially completed' }
42
+ expect(actionDialogResult(new ApiError('Refused', 409), titles)?.title).toBe(titles.error)
43
+ expect(actionDialogResult(new Error('Connection lost'), titles)?.title).toBe(titles.unknown)
44
+ expect(actionDialogResult(new ApiError('Partial', 409, { code: 'partial' }), titles)?.title).toBe(titles.partial)
45
+ expect(actionDialogResult(new ApiError('Partial', 409, { code: 'partial' }), { unknown: 'May have changed' })?.title).toBeUndefined()
46
+ expect(actionDialogResult(null)).toBeUndefined()
47
+ })
48
+ })
@@ -0,0 +1,79 @@
1
+ // The client half of the reviewed-action contract shared by workspace
2
+ // integrations. A capability says
3
+ // whether an action is offered and why not; a request carries the context,
4
+ // object UID and facts the user confirmed, and the server refuses it with a
5
+ // stable code when any of them no longer holds.
6
+
7
+ import { formatGrant, type Grant, type ActionConfirmResult } from '@skyhook-io/k8s-ui'
8
+ import { ApiError } from './client'
9
+ import { isRadarFeatureUnsupported } from './radarFeatures'
10
+
11
+ export interface ActionCapability {
12
+ allowed: boolean
13
+ reason?: string
14
+ reasonCode?: string
15
+ /** `unknown`: the permission check itself failed; the apiserver decides on submit. */
16
+ permission: 'allowed' | 'denied' | 'unknown'
17
+ grant?: Grant
18
+ }
19
+
20
+ export interface ActionRequest<Facts extends object = object, Params extends object = object> {
21
+ reviewedContext: string
22
+ uid: string
23
+ facts: Facts
24
+ params?: Params
25
+ }
26
+
27
+ /**
28
+ * Refusals every action family shares. An integration adds its own codes by
29
+ * widening the type parameter of actionErrorCode.
30
+ */
31
+ export type ActionErrorCode = 'context_changed' | 'changed' | 'blocked' | 'partial' | 'unavailable' | 'outcome_unknown'
32
+
33
+ // A request that timed out or lost its connection may have been applied by the
34
+ // apiserver anyway. A 503 without a code is just as uncertain: an Envoy-based
35
+ // ingress (Istio, Contour, Gateway API implementations) answers 503 when the
36
+ // upstream connection reset after it forwarded the request. Radar's own
37
+ // refusals before any write carry the code `unavailable`.
38
+ const AMBIGUOUS_STATUSES = new Set([500, 502, 503, 504])
39
+
40
+ export function actionErrorCode<C extends string = ActionErrorCode>(err: unknown): C | ActionErrorCode | undefined {
41
+ if (!err || isRadarFeatureUnsupported(err)) return undefined
42
+ if (!(err instanceof ApiError)) return 'outcome_unknown'
43
+ const code = err.data?.code
44
+ if (typeof code === 'string') return code as C
45
+ return AMBIGUOUS_STATUSES.has(err.status) ? 'outcome_unknown' : undefined
46
+ }
47
+
48
+ /**
49
+ * Confirm stays locked when the last attempt may have taken effect (unknown)
50
+ * or partly did (partial): repeating it would act on a target that moved.
51
+ */
52
+ export function actionOutcomeLocked(err: unknown): boolean {
53
+ const code = actionErrorCode(err)
54
+ return code === 'outcome_unknown' || code === 'partial'
55
+ }
56
+
57
+ export function actionDialogResult(
58
+ error: Error | null | undefined,
59
+ titles: { error?: string; unknown?: string; partial?: string } = {},
60
+ ): ActionConfirmResult | undefined {
61
+ if (!error) return undefined
62
+ const code = actionErrorCode(error)
63
+ const state = code === 'outcome_unknown' ? 'unknown' : code === 'partial' ? 'partial' : 'error'
64
+ return { state, message: error.message, title: state === 'error' ? titles.error : state === 'partial' ? titles.partial : titles.unknown }
65
+ }
66
+
67
+ /** The mutations a `partial` refusal reports as already done. */
68
+ export function actionCompleted(err: unknown): string[] {
69
+ if (!(err instanceof ApiError) || err.data?.code !== 'partial') return []
70
+ const done = err.data.completed
71
+ return Array.isArray(done) ? done.filter((d): d is string => typeof d === 'string') : []
72
+ }
73
+
74
+ /** Why an action is not offered, or undefined when it is. */
75
+ export function capabilityReason(cap: ActionCapability): string | undefined {
76
+ if (cap.allowed) return undefined
77
+ if (cap.reason) return cap.reason
78
+ return cap.permission === 'denied' ? `Your account may not do this (${formatGrant(cap.grant) ?? 'permission denied'})` : 'Not available'
79
+ }
@@ -0,0 +1,232 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
6
+ import {
7
+ customResourceDefinitionRef,
8
+ kindToPluralWithGroup,
9
+ knownKindForPluralWithGroup,
10
+ resetNavigationMap,
11
+ } from "@skyhook-io/k8s-ui/utils/navigation";
12
+ import { useAPIResources } from "./apiResources";
13
+ import { MetadataSection } from "@skyhook-io/k8s-ui/components/ui/drawer-components";
14
+
15
+ beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true));
16
+ afterEach(() => {
17
+ vi.useRealTimers();
18
+ vi.unstubAllGlobals();
19
+ resetNavigationMap();
20
+ });
21
+
22
+ it.each(["unwatched", "syncing", "denied", "deferred", "unsupported", "synced", undefined])(
23
+ "bounds definition polling for inventory state %s",
24
+ async (state) => {
25
+ vi.useFakeTimers();
26
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
27
+ const root = createRoot(document.createElement("div"));
28
+ const fetch = vi.fn(() => Promise.resolve(Response.json([{
29
+ group: "apiextensions.k8s.io", version: "v1", kind: "CustomResourceDefinition",
30
+ name: "customresourcedefinitions", namespaced: false, isCrd: false, verbs: ["get", "list", "watch"],
31
+ ...(state ? { observation: { state } } : {}),
32
+ }])));
33
+ vi.stubGlobal("fetch", fetch);
34
+ function Probe() {
35
+ useAPIResources();
36
+ return null;
37
+ }
38
+ try {
39
+ await act(async () => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
40
+ await act(async () => { await vi.advanceTimersByTimeAsync(120_001); });
41
+ const calls = fetch.mock.calls.length;
42
+ if (state === "unwatched" || state === "syncing") expect(calls).toBeGreaterThan(1);
43
+ else expect(calls).toBe(1);
44
+ await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
45
+ expect(fetch).toHaveBeenCalledTimes(calls);
46
+ } finally {
47
+ await act(async () => root.unmount());
48
+ client.clear();
49
+ }
50
+ },
51
+ );
52
+
53
+ it("refreshes definitions until their inventory syncs, without relying on topology", async () => {
54
+ vi.useFakeTimers();
55
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
56
+ const root = createRoot(document.createElement("div"));
57
+ let synced = false;
58
+ const fetch = vi.fn(() => Promise.resolve(Response.json([
59
+ {
60
+ group: "apiextensions.k8s.io", version: "v1", kind: "CustomResourceDefinition",
61
+ name: "customresourcedefinitions", namespaced: false, isCrd: false, verbs: ["get", "list", "watch"],
62
+ observation: { state: synced ? "synced" : "syncing" },
63
+ },
64
+ {
65
+ group: "plants.example.io", version: "v1", kind: "Cactus", name: "cacti",
66
+ namespaced: true, isCrd: true, verbs: ["get"],
67
+ ...(synced ? { definitionName: "cacti.plants.example.io" } : {}),
68
+ },
69
+ ])));
70
+ vi.stubGlobal("fetch", fetch);
71
+ function Probe() {
72
+ useAPIResources();
73
+ return null;
74
+ }
75
+ try {
76
+ await act(async () => {
77
+ root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>);
78
+ });
79
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
80
+ expect(fetch).toHaveBeenCalledTimes(1);
81
+ expect(customResourceDefinitionRef("plants.example.io/v1", "Cactus")).toBeNull();
82
+ synced = true;
83
+ await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
84
+ await act(async () => { await vi.advanceTimersByTimeAsync(1); });
85
+ expect(fetch).toHaveBeenCalledTimes(2);
86
+ expect(customResourceDefinitionRef("plants.example.io/v1", "Cactus")?.name).toBe("cacti.plants.example.io");
87
+ await act(async () => { await vi.advanceTimersByTimeAsync(6000); });
88
+ expect(fetch).toHaveBeenCalledTimes(2);
89
+ } finally {
90
+ await act(async () => root.unmount());
91
+ client.clear();
92
+ }
93
+ });
94
+
95
+ it("renders a definition link when discovery arrives after the instance", async () => {
96
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
97
+ const element = document.createElement("div");
98
+ const root = createRoot(element);
99
+ const resource = {
100
+ group: "plants.example.io", version: "v1", kind: "Cactus", name: "cacti",
101
+ namespaced: true, isCrd: true, verbs: ["get"], definitionName: "cacti.plants.example.io",
102
+ };
103
+ let finishDiscovery!: (response: Response) => void;
104
+ let fail = false;
105
+ vi.stubGlobal("fetch", vi.fn(() => fail
106
+ ? Promise.resolve(new Response("{}", { status: 500 }))
107
+ : new Promise<Response>((resolve) => { finishDiscovery = resolve; })));
108
+ function Probe() {
109
+ useAPIResources();
110
+ return <MetadataSection data={{ apiVersion: "plants.example.io/v1", kind: "Cactus", metadata: { name: "live" } }} />;
111
+ }
112
+ try {
113
+ await act(async () => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
114
+ expect(element.textContent).not.toContain("cacti.plants.example.io");
115
+ await act(async () => { finishDiscovery(Response.json([resource])); });
116
+ await vi.waitFor(async () => {
117
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
118
+ expect(element.textContent).toContain("cacti.plants.example.io");
119
+ });
120
+ fail = true;
121
+ await act(async () => { await client.invalidateQueries({ queryKey: ["api-resources"] }); });
122
+ await vi.waitFor(async () => {
123
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
124
+ expect(element.textContent).not.toContain("cacti.plants.example.io");
125
+ });
126
+ } finally {
127
+ await act(async () => root.unmount());
128
+ client.clear();
129
+ }
130
+ });
131
+
132
+ it("clears confirmed definitions after a failed refresh and cancels discovery from an old cluster", async () => {
133
+ const client = new QueryClient({
134
+ defaultOptions: { queries: { retry: false } },
135
+ });
136
+ const root = createRoot(document.createElement("div"));
137
+ const resource = {
138
+ group: "plants.example.io",
139
+ version: "v1",
140
+ kind: "Cactus",
141
+ name: "cacti",
142
+ namespaced: true,
143
+ isCrd: true,
144
+ verbs: ["get"],
145
+ definitionName: "cacti.plants.example.io",
146
+ };
147
+ let fail = false;
148
+ let hold = false;
149
+ let pendingSignal: AbortSignal | undefined;
150
+ vi.stubGlobal(
151
+ "fetch",
152
+ vi.fn((_input: unknown, init?: RequestInit) => {
153
+ if (hold) {
154
+ pendingSignal = init?.signal as AbortSignal;
155
+ return new Promise<Response>(() => {});
156
+ }
157
+ return Promise.resolve(
158
+ fail ? new Response("{}", { status: 500 }) : Response.json([resource]),
159
+ );
160
+ }),
161
+ );
162
+ function Probe() {
163
+ useAPIResources();
164
+ return null;
165
+ }
166
+ const settle = async () => {
167
+ await act(async () => {
168
+ await new Promise((resolve) => setTimeout(resolve, 0));
169
+ });
170
+ };
171
+ try {
172
+ await act(async () => {
173
+ root.render(
174
+ <QueryClientProvider client={client}>
175
+ <Probe />
176
+ </QueryClientProvider>,
177
+ );
178
+ });
179
+ await vi.waitFor(async () => {
180
+ await settle();
181
+ expect(
182
+ customResourceDefinitionRef("plants.example.io/v1", "Cactus")?.name,
183
+ ).toBe(resource.definitionName);
184
+ });
185
+ expect(
186
+ knownKindForPluralWithGroup("helmreleases", "helm.toolkit.fluxcd.io"),
187
+ ).toBe("HelmRelease");
188
+ expect(
189
+ customResourceDefinitionRef("helm.toolkit.fluxcd.io/v2", "HelmRelease"),
190
+ ).toBeNull();
191
+ fail = true;
192
+ await act(async () => {
193
+ await client.invalidateQueries({ queryKey: ["api-resources"] });
194
+ });
195
+ await vi.waitFor(async () => {
196
+ await settle();
197
+ expect(
198
+ customResourceDefinitionRef("plants.example.io/v1", "Cactus"),
199
+ ).toBeNull();
200
+ });
201
+ expect(
202
+ knownKindForPluralWithGroup("helmreleases", "helm.toolkit.fluxcd.io"),
203
+ ).toBe("HelmRelease");
204
+ expect(kindToPluralWithGroup("Cactus", "plants.example.io")).toBe("cacti");
205
+ fail = false;
206
+ await act(async () => {
207
+ await client.invalidateQueries({ queryKey: ["api-resources"] });
208
+ });
209
+ await vi.waitFor(async () => {
210
+ await settle();
211
+ expect(
212
+ customResourceDefinitionRef("plants.example.io/v1", "Cactus")?.name,
213
+ ).toBe(resource.definitionName);
214
+ });
215
+ hold = true;
216
+ await act(async () => {
217
+ void client.invalidateQueries({ queryKey: ["api-resources"] });
218
+ });
219
+ expect(pendingSignal?.aborted).toBe(false);
220
+ resetNavigationMap();
221
+ await act(async () => {
222
+ client.removeQueries();
223
+ });
224
+ expect(pendingSignal?.aborted).toBe(true);
225
+ expect(
226
+ customResourceDefinitionRef("plants.example.io/v1", "Cactus"),
227
+ ).toBeNull();
228
+ } finally {
229
+ await act(async () => root.unmount());
230
+ client.clear();
231
+ }
232
+ });
@@ -1,8 +1,12 @@
1
+ import { useEffect } from "react";
2
+ import { initNavigationMap } from "@skyhook-io/k8s-ui/utils/navigation";
1
3
  import { useQuery } from "@tanstack/react-query";
2
4
  import type { APIResource } from "../types";
3
5
  import { apiUrl, getAuthHeaders, getCredentialsMode } from "./config";
4
6
  import { readErrorBody } from "./httpErrors";
5
7
 
8
+ const definitionPollDeadlines = new WeakMap<object, number>();
9
+
6
10
  // Re-export pure functions from package
7
11
  export {
8
12
  categorizeResources,
@@ -15,8 +19,9 @@ export {
15
19
  } from "@skyhook-io/k8s-ui";
16
20
  export type { ResourceCategory } from "@skyhook-io/k8s-ui";
17
21
 
18
- async function fetchJSON<T>(path: string): Promise<T> {
22
+ async function fetchJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
19
23
  const response = await fetch(apiUrl(path), {
24
+ signal,
20
25
  credentials: getCredentialsMode(),
21
26
  headers: getAuthHeaders(),
22
27
  });
@@ -27,13 +32,51 @@ async function fetchJSON<T>(path: string): Promise<T> {
27
32
  return response.json();
28
33
  }
29
34
 
35
+ // Known GitOps navigation identities, not installation or CRD-existence evidence.
36
+ export const GITOPS_KINDS: APIResource[] = [
37
+ { name: 'applications', kind: 'Application', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
38
+ { name: 'applicationsets', kind: 'ApplicationSet', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
39
+ { name: 'appprojects', kind: 'AppProject', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
40
+ { name: 'kustomizations', kind: 'Kustomization', group: 'kustomize.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
41
+ { name: 'helmreleases', kind: 'HelmRelease', group: 'helm.toolkit.fluxcd.io', version: 'v2', namespaced: true, verbs: ['list', 'get'], isCrd: true },
42
+ { name: 'gitrepositories', kind: 'GitRepository', group: 'source.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
43
+ { name: 'ocirepositories', kind: 'OCIRepository', group: 'source.toolkit.fluxcd.io', version: 'v1beta2', namespaced: true, verbs: ['list', 'get'], isCrd: true },
44
+ { name: 'helmrepositories', kind: 'HelmRepository', group: 'source.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
45
+ { name: 'alerts', kind: 'Alert', group: 'notification.toolkit.fluxcd.io', version: 'v1beta3', namespaced: true, verbs: ['list', 'get'], isCrd: true },
46
+ ]
47
+
48
+
30
49
  // Fetch all API resources from the cluster
31
50
  export function useAPIResources() {
32
- return useQuery<APIResource[]>({
51
+ const result = useQuery<APIResource[]>({
33
52
  queryKey: ["api-resources"],
34
- queryFn: () => fetchJSON("/api-resources"),
53
+ queryFn: ({ signal }) => fetchJSON("/api-resources", signal),
35
54
  staleTime: 5 * 60 * 1000, // 5 minutes - resources don't change often
55
+ refetchInterval: (query) => {
56
+ const state = query.state.data?.find(resource =>
57
+ resource.group === "apiextensions.k8s.io" && resource.name === "customresourcedefinitions")?.observation?.state;
58
+ if (state !== "unwatched" && state !== "syncing") {
59
+ definitionPollDeadlines.delete(query);
60
+ return false;
61
+ }
62
+ let deadline = definitionPollDeadlines.get(query);
63
+ if (deadline === undefined) {
64
+ deadline = Date.now() + 2 * 60 * 1000;
65
+ definitionPollDeadlines.set(query, deadline);
66
+ }
67
+ return Date.now() < deadline ? 3000 : false;
68
+ },
36
69
  });
70
+ useEffect(() => {
71
+ // Route identities survive a failed refresh; permission-dependent links do not.
72
+ const actual = result.isError
73
+ ? (result.data ?? []).map(resource => ({ ...resource, definitionName: undefined }))
74
+ : result.data ?? [];
75
+ const knownOnly = GITOPS_KINDS.filter(known => !actual.some(resource =>
76
+ resource.group === known.group && (resource.kind === known.kind || resource.name === known.name)));
77
+ initNavigationMap([...actual, ...knownOnly]);
78
+ }, [result.data, result.isError]);
79
+ return result;
37
80
  }
38
81
 
39
82
  // The report families the server watches (reportGroups in
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ApiError, goneRecheckInterval, refetchOnResourceEvents, resourceEventKey } from "./client";
3
+
4
+ const notFound = new ApiError('pods "web-1" not found', 404);
5
+ const detail = (namespace: string, name: string, error: unknown) => ({
6
+ queryKey: ["resource", "pods", namespace, name, undefined],
7
+ state: { error },
8
+ });
9
+
10
+ describe("refetchOnResourceEvents", () => {
11
+ it("refetches a live detail view on any change to its kind", () => {
12
+ expect(refetchOnResourceEvents(detail("shop", "web-1", null), "pods", new Set())).toBe(true);
13
+ });
14
+
15
+ it("refetches a detail view that failed for a reason other than 404", () => {
16
+ const failed = detail("shop", "web-1", new ApiError("unavailable", 503));
17
+ expect(refetchOnResourceEvents(failed, "pods", new Set())).toBe(true);
18
+ });
19
+
20
+ it("leaves a deleted object alone when other objects of its kind change", () => {
21
+ // The production shape: pods churning next to an open view of a deleted
22
+ // pod refetched it, 404 every time, for as long as the view stayed open.
23
+ const named = new Set([resourceEventKey("pods", "shop", "web-2")]);
24
+ expect(refetchOnResourceEvents(detail("shop", "web-1", notFound), "pods", named)).toBe(false);
25
+ });
26
+
27
+ it("refetches a deleted object as soon as an event names it", () => {
28
+ // A StatefulSet recreates its pod under the same name.
29
+ const named = new Set([resourceEventKey("pods", "shop", "web-1")]);
30
+ expect(refetchOnResourceEvents(detail("shop", "web-1", notFound), "pods", named)).toBe(true);
31
+ });
32
+
33
+ it("matches a cluster-scoped object, whose namespace is empty", () => {
34
+ const query = { queryKey: ["resource", "nodes", "", "node-a", undefined], state: { error: notFound } };
35
+ expect(refetchOnResourceEvents(query, "nodes", new Set([resourceEventKey("nodes", "", "node-a")]))).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe("goneRecheckInterval", () => {
40
+ it("re-checks a deleted object once a minute", () => {
41
+ expect(goneRecheckInterval({ state: { error: notFound } }, undefined)).toBe(60_000);
42
+ expect(goneRecheckInterval({ state: { error: notFound } }, 5_000)).toBe(60_000);
43
+ });
44
+
45
+ it("keeps the caller's interval otherwise", () => {
46
+ expect(goneRecheckInterval({ state: { error: null } }, 5_000)).toBe(5_000);
47
+ expect(goneRecheckInterval({ state: { error: new ApiError("unavailable", 503) } }, undefined)).toBeUndefined();
48
+ });
49
+ });
@@ -0,0 +1,39 @@
1
+ import { QueryClient, QueryObserver } from '@tanstack/react-query'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { invalidateHelmAfterSourceChange } from './client'
4
+
5
+ describe('invalidateHelmAfterSourceChange', () => {
6
+ it('refreshes OCI inventory and an open release source status query', async () => {
7
+ const queryClient = new QueryClient()
8
+ const inventoryKey = ['helm-oci-sources']
9
+ const statusKey = ['helm-source-status', 'production', 'release']
10
+ let sourceStatus = {
11
+ configured: true,
12
+ available: true,
13
+ candidates: [{ type: 'oci' as const, reference: 'oci://registry.example.com/charts/example' }],
14
+ }
15
+ const observer = new QueryObserver(queryClient, {
16
+ queryKey: statusKey,
17
+ queryFn: async () => sourceStatus,
18
+ })
19
+ const unsubscribe = observer.subscribe(() => undefined)
20
+
21
+ const versionsKey = ['helm-release-versions', 'production', 'release']
22
+ queryClient.setQueryData(inventoryKey, ['oci://registry.example.com/charts'])
23
+ queryClient.setQueryData(versionsKey, ['1.0.0'])
24
+ await observer.refetch()
25
+
26
+ sourceStatus = { configured: false, available: false, candidates: [] }
27
+ await invalidateHelmAfterSourceChange(queryClient)
28
+
29
+ expect(queryClient.getQueryState(inventoryKey)?.isInvalidated).toBe(true)
30
+ expect(queryClient.getQueryState(versionsKey)?.isInvalidated).toBe(true)
31
+ expect(observer.getCurrentResult().data).toEqual({
32
+ configured: false,
33
+ available: false,
34
+ candidates: [],
35
+ })
36
+
37
+ unsubscribe()
38
+ })
39
+ })
@@ -0,0 +1,54 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { afterEach, expect, it, vi } from "vitest";
6
+ import { useSetActiveNamespace } from "./client";
7
+
8
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ vi.unstubAllGlobals();
13
+ });
14
+
15
+ // The server already reports the requested pick, but the POST never answers.
16
+ async function timedOutSwitch(cacheScoped: boolean) {
17
+ vi.useFakeTimers();
18
+ const scope = { actives: ["team-a"], kubeconfigNamespace: "", mode: "namespace", accessibleNamespaces: ["team-a"], deniedNamespaces: [], authoritative: true, canClearNamespace: true, cacheScoped };
19
+ const calls: string[] = [];
20
+ vi.stubGlobal("fetch", vi.fn((url: string, init?: RequestInit) => {
21
+ calls.push(`${init?.method ?? "GET"} ${new URL(url, "http://radar").pathname}`);
22
+ if (init?.method === "POST") {
23
+ return new Promise((_, reject) => init.signal?.addEventListener("abort", () => reject(Object.assign(new Error("aborted"), { name: "AbortError" }))));
24
+ }
25
+ return Promise.resolve(new Response(JSON.stringify(scope), { status: 200, headers: { "Content-Type": "application/json" } }));
26
+ }));
27
+ const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
28
+ client.setQueryData(["namespace-scope"], { ...scope, actives: [] });
29
+ let mutate!: ReturnType<typeof useSetActiveNamespace>["mutateAsync"];
30
+ function Probe() {
31
+ mutate = useSetActiveNamespace().mutateAsync;
32
+ return null;
33
+ }
34
+ const root = createRoot(document.createElement("div"));
35
+ act(() => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
36
+ const outcome = mutate({ namespaces: ["team-a"] }).then(() => "applied", (error: Error) => error.message);
37
+ await vi.advanceTimersByTimeAsync(cacheScoped ? 120_000 : 15_000);
38
+ const result = { outcome: await outcome, calls };
39
+ act(() => root.unmount());
40
+ client.clear();
41
+ return result;
42
+ }
43
+
44
+ it("accepts a timed-out view-filter switch the server applied", async () => {
45
+ const { outcome, calls } = await timedOutSwitch(false);
46
+ expect(outcome).toBe("applied");
47
+ expect(calls).toContain("GET /api/cluster/namespace-scope");
48
+ });
49
+
50
+ it("keeps a timed-out rescope an error even when the scope already names its target", async () => {
51
+ const { outcome, calls } = await timedOutSwitch(true);
52
+ expect(outcome).toMatch(/rescope timed out/);
53
+ expect(calls).not.toContain("GET /api/cluster/namespace-scope");
54
+ });