@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
@@ -3,6 +3,29 @@ import { renderToStaticMarkup } from "react-dom/server";
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import { ApplyDialog } from "./parts";
5
5
  import { AgentSetupNotice } from "./AgentSetupNotice";
6
+ import { evaluateGitOpsWriteGuard } from "@skyhook-io/k8s-ui/utils/gitops-write-guard";
7
+
8
+ const target = { kind: "Deployment", group: "apps", namespace: "dev", name: "api" };
9
+ const APPLY_WRITES = [{ scope: "spec" as const }];
10
+ const unmanaged = evaluateGitOpsWriteGuard({ target, owner: null, writes: APPLY_WRITES });
11
+ const argoManaged = evaluateGitOpsWriteGuard({
12
+ target,
13
+ owner: { tool: "argocd", kind: "applications", namespace: "argocd", name: "api" },
14
+ writes: APPLY_WRITES,
15
+ evidence: {
16
+ uid: "u",
17
+ resourceVersion: "1",
18
+ owner: null,
19
+ policy: { tool: "argocd", auto: true, selfHeal: true, prune: false, suspended: null },
20
+ paths: [],
21
+ },
22
+ });
23
+
24
+ function applyButton(html: string) {
25
+ return html
26
+ .match(/<button\b[^>]*>[\s\S]*?<\/button>/g)
27
+ ?.find((button) => button.includes("Apply fix"));
28
+ }
6
29
 
7
30
  vi.mock("@skyhook-io/k8s-ui/components/ui/DialogPortal", () => ({
8
31
  DialogPortal: ({ open, children }: { open: boolean; children: ReactNode }) =>
@@ -25,6 +48,7 @@ describe("Apply confirmation context", () => {
25
48
  resourceLabel="Deployment dev/api"
26
49
  context={context}
27
50
  fix="Only after verifying credentials, update the Secret reference."
51
+ gitOpsGuard={unmanaged}
28
52
  />,
29
53
  );
30
54
  expect(html).toContain("Cluster:");
@@ -48,23 +72,52 @@ describe("Apply confirmation context", () => {
48
72
  resourceLabel="Deployment dev/api"
49
73
  context="kind-dev"
50
74
  fix="Update its configuration"
51
- managedBy="Argo CD"
75
+ gitOpsGuard={argoManaged}
52
76
  confidence={0.3}
53
77
  />,
54
78
  );
79
+ expect(html).toContain("Argo CD Application argocd/api may revert this change.");
55
80
  expect(html).toContain("I understand Argo CD may revert this");
56
- const applyButton = html
57
- .match(/<button\b[^>]*>[\s\S]*?<\/button>/g)
58
- ?.find((button) => button.includes("Apply fix"));
59
- expect(applyButton).toContain('disabled=""');
81
+ expect(applyButton(html)).toContain('disabled=""');
60
82
  expect(html).toContain("low confidence");
61
83
  expect(html.match(/kubeconfig/g)).toHaveLength(1);
62
84
  });
63
85
  });
64
86
 
87
+ describe("Apply GitOps gate", () => {
88
+ const props = {
89
+ open: true,
90
+ onClose: () => {},
91
+ onConfirm: () => {},
92
+ agentLabel: "Local agent",
93
+ resourceLabel: "Deployment dev/api",
94
+ context: "kind-dev",
95
+ };
96
+
97
+ it("enables Apply for an unmanaged target without acknowledgement", () => {
98
+ const html = renderToStaticMarkup(<ApplyDialog {...props} gitOpsGuard={unmanaged} />);
99
+ expect(applyButton(html)).not.toContain('disabled=""');
100
+ expect(html).not.toContain('type="checkbox"');
101
+ });
102
+
103
+ it("blocks Apply while ownership is being checked or unknown", () => {
104
+ const pending = evaluateGitOpsWriteGuard({ target, owner: null, ownerPending: true, writes: APPLY_WRITES });
105
+ const checking = renderToStaticMarkup(<ApplyDialog {...props} gitOpsGuard={pending} />);
106
+ expect(checking).toContain("Checking GitOps ownership");
107
+ expect(applyButton(checking)).toContain('disabled=""');
108
+ expect(applyButton(renderToStaticMarkup(<ApplyDialog {...props} />))).toContain('disabled=""');
109
+ });
110
+ });
111
+
65
112
  it("distinguishes local execution from sending resource data to the model provider", () => {
66
113
  const html = renderToStaticMarkup(
67
- <AgentSetupNotice setupState="needs-install" />,
114
+ <AgentSetupNotice
115
+ setupState="needs-install"
116
+ cliOverride={false}
117
+ checkingAgents={false}
118
+ agentsCheckFailed={false}
119
+ recheckAgents={async () => {}}
120
+ />,
68
121
  );
69
122
  expect(html).toContain("Your agent runs locally");
70
123
  expect(html).toContain("model provider under your account, not to Radar");
@@ -8,6 +8,12 @@ import {
8
8
  RefreshCw,
9
9
  } from "lucide-react";
10
10
  import { DialogPortal } from "@skyhook-io/k8s-ui/components/ui/DialogPortal";
11
+ import { GitOpsWriteWarning } from "@skyhook-io/k8s-ui";
12
+ import {
13
+ canConfirmGitOpsWrite,
14
+ gitOpsWriteGuardKey,
15
+ type GitOpsWriteGuard,
16
+ } from "@skyhook-io/k8s-ui/utils/gitops-write-guard";
11
17
  import { parseContextName } from "../../utils/context-name";
12
18
  import type { Diagnosis, ApplyMutationOutcome } from "../../api/diagnose";
13
19
  import { AIMarkdown, CopyButton } from "./AIMarkdown";
@@ -24,7 +30,8 @@ export function ApplyDialog({
24
30
  fix,
25
31
  reason,
26
32
  precondition,
27
- managedBy,
33
+ gitOpsGuard,
34
+ onOpenGitOpsOwner,
28
35
  confidence,
29
36
  }: {
30
37
  open: boolean;
@@ -38,7 +45,9 @@ export function ApplyDialog({
38
45
  reason?: string;
39
46
  /** The condition the agent attached to this step; shown before the operator confirms. */
40
47
  precondition?: string;
41
- managedBy?: string; // GitOps/Helm owner of the resource, if any
48
+ /** GitOps/Helm write guard for the target; undefined blocks Apply. */
49
+ gitOpsGuard?: GitOpsWriteGuard;
50
+ onOpenGitOpsOwner?: () => void;
42
51
  confidence?: number;
43
52
  }) {
44
53
  const titleId = useId();
@@ -47,14 +56,19 @@ export function ApplyDialog({
47
56
  // A GitOps/Helm-managed resource needs an explicit acknowledgment before applying
48
57
  // a direct change — it's the canonical footgun (the controller reverts it). Gating
49
58
  // (not just warning) makes the user opt into "yes, I know this may be undone."
59
+ // Apply also waits until ownership has been checked.
50
60
  // TODO(SKY-1075): once Radar can connect the user's SCM (GitHub/GitLab/…), replace
51
61
  // direct apply on managed resources with "open a PR against the Git source"
52
62
  // instead — the durable fix. See Linear SKY-1075.
53
- const [acked, setAcked] = useState(false);
63
+ // The guard key the acknowledgment was given for: a different verdict (the
64
+ // owner resolved, the policy changed) asks again.
65
+ const guardKey = gitOpsWriteGuardKey(gitOpsGuard);
66
+ const [ackedKey, setAckedKey] = useState("");
54
67
  useEffect(() => {
55
- if (open) setAcked(false);
68
+ if (open) setAckedKey("");
56
69
  }, [open]);
57
- const applyBlocked = !!managedBy && !acked;
70
+ const acked = Boolean(guardKey) && ackedKey === guardKey;
71
+ const applyBlocked = !canConfirmGitOpsWrite(gitOpsGuard, acked);
58
72
  return (
59
73
  <DialogPortal
60
74
  ariaLabelledBy={titleId}
@@ -130,29 +144,13 @@ export function ApplyDialog({
130
144
  )}
131
145
  </div>
132
146
  <div className="shrink-0 space-y-2 p-4">
133
- {/* The star warning: when we KNOW a controller owns this resource, a live
134
- change reverts on the next reconcile — say so authoritatively. */}
135
- {managedBy && (
136
- <div className="space-y-2 rounded border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-theme-text-primary">
137
- <div className="flex items-start gap-2">
138
- <RefreshCw className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
139
- <span>
140
- <span className="font-medium">Managed by {managedBy}.</span>{" "}
141
- Unless you turn off auto-sync, a direct change here will be
142
- undone within minutes when {managedBy} re-syncs from Git — the
143
- durable fix is to change it in Git (the {managedBy} source).
144
- </span>
145
- </div>
146
- <label className="flex cursor-pointer items-center gap-2 pl-6 text-xs text-theme-text-secondary">
147
- <input
148
- type="checkbox"
149
- checked={acked}
150
- onChange={(e) => setAcked(e.target.checked)}
151
- className="h-3.5 w-3.5 accent-amber-500"
152
- />
153
- I understand {managedBy} may revert this — apply anyway.
154
- </label>
155
- </div>
147
+ {gitOpsGuard && (
148
+ <GitOpsWriteWarning
149
+ guard={gitOpsGuard}
150
+ acknowledged={acked}
151
+ onAcknowledgedChange={(value) => setAckedKey(value ? guardKey : "")}
152
+ onOpenOwner={onOpenGitOpsOwner}
153
+ />
156
154
  )}
157
155
  {lowConfidence && (
158
156
  <div className="flex items-start gap-2 rounded border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
@@ -8,6 +8,7 @@ import {
8
8
  isInvestigationWorkspacePath,
9
9
  shouldExitUnavailableWorkspace,
10
10
  workspaceRunIDFromPath,
11
+ resolveSetupState,
11
12
  } from "./DiagnoseContext";
12
13
 
13
14
  describe("investigation workspace routes", () => {
@@ -103,3 +104,45 @@ describe("investigationPanelFillsViewport", () => {
103
104
  expect(investigationPanelFillsViewport(1200, 1000)).toBe(true);
104
105
  });
105
106
  });
107
+
108
+ describe("resolveSetupState", () => {
109
+ const base = {
110
+ available: false,
111
+ eligibilityResolved: true,
112
+ eligible: true,
113
+ supportedAgentCount: 0,
114
+ };
115
+
116
+ it("does not call an unanswered probe a deployment restriction", () => {
117
+ // eligible starts false and the probe's catch leaves it false, so both the
118
+ // in-flight and the failed request land here.
119
+ expect(
120
+ resolveSetupState({
121
+ ...base,
122
+ eligibilityResolved: false,
123
+ eligible: false,
124
+ }),
125
+ ).toBe("unknown");
126
+ });
127
+
128
+ it("reports off only once the probe said eligible: false", () => {
129
+ expect(resolveSetupState({ ...base, eligible: false })).toBe("off");
130
+ });
131
+
132
+ it("separates a CLI that appeared after boot from none at all", () => {
133
+ expect(resolveSetupState({ ...base, supportedAgentCount: 1 })).toBe(
134
+ "needs-restart",
135
+ );
136
+ expect(resolveSetupState(base)).toBe("needs-install");
137
+ });
138
+
139
+ it("lets a working engine win over an unresolved probe", () => {
140
+ expect(
141
+ resolveSetupState({
142
+ ...base,
143
+ available: true,
144
+ eligibilityResolved: false,
145
+ }),
146
+ ).toBe("ready");
147
+ });
148
+ });
@@ -60,15 +60,31 @@ export type DiagnoseView = "home" | "investigation";
60
60
  // Setup readiness of local AI investigations, derived from the agents API:
61
61
  // - "ready": an agent is installed and the engine is running (available)
62
62
  // - "needs-install": the feature is supported here but no agent CLI is installed
63
- // - "needs-restart": a supported agent is now on PATH but Radar booted before it
64
- // existed (the engine is decided once, at startup)
63
+ // - "needs-restart": a supported agent is installed but investigations are
64
+ // still off. The server picks up new installs whenever the
65
+ // agent list is fetched, so locally this means RADAR_AI_CLI_BIN
66
+ // pins the engine to a file Radar can't run (cliOverride); an
67
+ // embedding host may report it when its own runner is down
65
68
  // - "off": not available in this deployment (proxy/OIDC auth, --no-mcp,
66
69
  // or an embed host) — no install nudge would help
67
- export type DiagnoseSetup = "ready" | "needs-install" | "needs-restart" | "off";
70
+ // - "unknown": the agents probe hasn't answered (in flight, or it failed
71
+ // and was swallowed). Nothing about the machine or the
72
+ // deployment is established yet, so say nothing about either
73
+ export type DiagnoseSetup =
74
+ "ready" | "needs-install" | "needs-restart" | "off" | "unknown";
68
75
 
69
76
  interface DiagnoseCtx {
70
77
  available: boolean; // an agent CLI is present (button/entry gate)
71
78
  setupState: DiagnoseSetup; // readiness for the setup nudge (see DiagnoseSetup)
79
+ // Re-reads the agent list; the server turns investigations on if a CLI was
80
+ // installed since the last check. Resolves once the new state is applied.
81
+ recheckAgents: () => Promise<void>;
82
+ checkingAgents: boolean;
83
+ // The latest agent check failed to reach the server, so nothing about the
84
+ // machine is known from it.
85
+ agentsCheckFailed: boolean;
86
+ // RADAR_AI_CLI_BIN is set; with "needs-restart" it is what's broken.
87
+ cliOverride: boolean;
72
88
  agentLabel: string; // label of the selected agent, e.g. "Claude Code"
73
89
  hosted: boolean; // selected agent runs on the host's backend, not this machine
74
90
  agents: AgentInfo[]; // supported agents detected on PATH (for the picker)
@@ -172,6 +188,31 @@ const MIN_APP_LEFT_OF_PANEL = 900;
172
188
  // area instead. Radar's own minimum width is about 856px, so this is the
173
189
  // drawer's floor: below it the investigation is the screen.
174
190
  const MIN_APP_PEEK_BESIDE_OVERLAY = 320;
191
+ // resolveSetupState is the ONE mapping from the agents probe to what every
192
+ // surface renders. Exported so it can be pinned by test, since each state drives
193
+ // a different user-facing claim.
194
+ //
195
+ // "unknown" is not the same as "off". `eligible` starts false and the probe's
196
+ // catch leaves it that way, so without a separate state a pending or failed
197
+ // request reads as a confirmed deployment restriction. A non-empty agent list
198
+ // while the engine is off means a drivable CLI appeared after Radar booted.
199
+ export function resolveSetupState({
200
+ available,
201
+ eligibilityResolved,
202
+ eligible,
203
+ supportedAgentCount,
204
+ }: {
205
+ available: boolean;
206
+ eligibilityResolved: boolean;
207
+ eligible: boolean;
208
+ supportedAgentCount: number;
209
+ }): DiagnoseSetup {
210
+ if (available) return "ready";
211
+ if (!eligibilityResolved) return "unknown";
212
+ if (!eligible) return "off";
213
+ return supportedAgentCount > 0 ? "needs-restart" : "needs-install";
214
+ }
215
+
175
216
  export function investigationPanelFillsViewport(
176
217
  viewportWidth: number,
177
218
  panelWidth: number,
@@ -452,52 +493,79 @@ function RoutedDiagnoseProvider({
452
493
  const narrow = viewportW - width < MIN_APP_LEFT_OF_PANEL;
453
494
  const fullWidthForced = investigationPanelFillsViewport(viewportW, width);
454
495
 
496
+ const mountedRef = useRef(true);
455
497
  useEffect(() => {
456
- let live = true;
457
- fetchAgents()
458
- .then((r) => {
459
- if (!live) return;
460
- setConsented(r.consented ?? {});
461
- setEligible(!!r.eligible);
462
- const supported = r.agents.filter(
463
- (a) =>
464
- a.supported &&
465
- (a.hosted ||
466
- (!!a.profiles?.length &&
467
- a.profiles.every((p) => !!a.consentSurfaces?.[p]))),
468
- );
469
- setAvailable(r.enabled && supported.length > 0);
470
- setAgents(supported);
471
- // Keep the stored pick only if it's still installed; else default to the
472
- // first supported agent (matches the server's default selection).
473
- const stored = readStored(AGENT_KEY) || "";
474
- const next =
475
- stored && supported.some((a) => a.name === stored)
476
- ? stored
477
- : (supported[0]?.name ?? "");
478
- setSelectedAgentState(next);
479
- const profiles = supported.find((a) => a.name === next)?.profiles ?? [];
480
- const storedProfile =
481
- (readStored(PROFILE_KEY) as ExecutionProfile) || "safeguarded";
482
- if (!profiles.includes(storedProfile) && profiles[0]) {
483
- setProfileState(profiles[0]);
484
- writeStored(PROFILE_KEY, profiles[0]);
485
- }
486
- // Model/effort are agent-specific; if the stored agent is gone, its values
487
- // don't apply to the fallback agent (e.g. a Codex slug under Claude) — drop them.
488
- if (next !== stored) {
489
- setModelState("");
490
- writeStored(MODEL_KEY, "");
491
- setEffortState("");
492
- writeStored(EFFORT_KEY, "");
493
- }
494
- setAgentEligibilityResolved(true);
495
- })
496
- .catch(() => {});
498
+ mountedRef.current = true;
497
499
  return () => {
498
- live = false;
500
+ mountedRef.current = false;
499
501
  };
500
502
  }, []);
503
+ const [checkingAgents, setCheckingAgents] = useState(false);
504
+ const [agentsCheckFailed, setAgentsCheckFailed] = useState(false);
505
+ const [cliOverride, setCliOverride] = useState(false);
506
+ // A click and a window-focus re-check can overlap; only the newest one may
507
+ // apply its answer, or a stale "nothing installed" could land last.
508
+ const agentCheckSeq = useRef(0);
509
+ // The agent in use now, picked by hand or by default. A check compares
510
+ // against it, not just the stored pick, so seeing the same agent again
511
+ // keeps the model and effort set for it.
512
+ const agentInUseRef = useRef(selectedAgent);
513
+ const recheckAgents = useCallback(async () => {
514
+ const seq = ++agentCheckSeq.current;
515
+ const latest = () => mountedRef.current && seq === agentCheckSeq.current;
516
+ setCheckingAgents(true);
517
+ try {
518
+ const r = await fetchAgents();
519
+ if (!latest()) return;
520
+ setAgentsCheckFailed(false);
521
+ setCliOverride(!!r.cliOverride);
522
+ setConsented(r.consented ?? {});
523
+ setEligible(!!r.eligible);
524
+ const supported = r.agents.filter(
525
+ (a) =>
526
+ a.supported &&
527
+ (a.hosted ||
528
+ (!!a.profiles?.length &&
529
+ a.profiles.every((p) => !!a.consentSurfaces?.[p]))),
530
+ );
531
+ setAvailable(r.enabled && supported.length > 0);
532
+ setAgents(supported);
533
+ // Keep the stored pick only if it's still installed; else default to the
534
+ // first supported agent. Runs always name it, so the server's own default
535
+ // doesn't apply.
536
+ const stored = readStored(AGENT_KEY) || "";
537
+ const next =
538
+ stored && supported.some((a) => a.name === stored)
539
+ ? stored
540
+ : (supported[0]?.name ?? "");
541
+ setSelectedAgentState(next);
542
+ const profiles = supported.find((a) => a.name === next)?.profiles ?? [];
543
+ const storedProfile =
544
+ (readStored(PROFILE_KEY) as ExecutionProfile) || "safeguarded";
545
+ if (!profiles.includes(storedProfile) && profiles[0]) {
546
+ setProfileState(profiles[0]);
547
+ writeStored(PROFILE_KEY, profiles[0]);
548
+ }
549
+ // Model/effort are agent-specific; if the agent changed, its values
550
+ // don't apply to the new one (e.g. a Codex slug under Claude), so drop them.
551
+ if (next !== agentInUseRef.current) {
552
+ setModelState("");
553
+ writeStored(MODEL_KEY, "");
554
+ setEffortState("");
555
+ writeStored(EFFORT_KEY, "");
556
+ }
557
+ agentInUseRef.current = next;
558
+ setAgentEligibilityResolved(true);
559
+ } catch {
560
+ // Leaves the previous state in place; "unknown" until a probe answers.
561
+ if (latest()) setAgentsCheckFailed(true);
562
+ } finally {
563
+ if (latest()) setCheckingAgents(false);
564
+ }
565
+ }, []);
566
+ useEffect(() => {
567
+ void recheckAgents();
568
+ }, [recheckAgents]);
501
569
 
502
570
  const setModel = useCallback((v: string) => {
503
571
  setModelState(v);
@@ -511,6 +579,7 @@ function RoutedDiagnoseProvider({
511
579
  (name: string) => {
512
580
  setSelectedAgentState(name);
513
581
  writeStored(AGENT_KEY, name);
582
+ agentInUseRef.current = name;
514
583
  const nextProfile = agents.find((agent) => agent.name === name)
515
584
  ?.profiles?.[0];
516
585
  if (nextProfile) {
@@ -541,15 +610,21 @@ function RoutedDiagnoseProvider({
541
610
  ? "standard"
542
611
  : (selectedAgentInfo?.consentSurfaces?.[effectiveProfile] ?? "");
543
612
 
544
- // `agents` holds only supported CLIs (filtered on fetch), so a non-empty list
545
- // while the engine is off means a drivable agent appeared on PATH after boot.
546
- const setupState: DiagnoseSetup = available
547
- ? "ready"
548
- : !eligible
549
- ? "off"
550
- : agents.length > 0
551
- ? "needs-restart"
552
- : "needs-install";
613
+ const setupState = resolveSetupState({
614
+ available,
615
+ eligibilityResolved: agentEligibilityResolved,
616
+ eligible,
617
+ supportedAgentCount: agents.length,
618
+ });
619
+
620
+ // Coming back from a terminal after installing a CLI is when to look again,
621
+ // so the setup notice clears without the user hunting for a button.
622
+ useEffect(() => {
623
+ if (setupState === "ready" || setupState === "off") return;
624
+ const onFocus = () => void recheckAgents();
625
+ window.addEventListener("focus", onFocus);
626
+ return () => window.removeEventListener("focus", onFocus);
627
+ }, [setupState, recheckAgents]);
553
628
 
554
629
  useEffect(() => {
555
630
  const onResize = () => setViewportW(window.innerWidth);
@@ -701,6 +776,9 @@ function RoutedDiagnoseProvider({
701
776
  writeFocusedRunID(run.id, true);
702
777
  })
703
778
  .catch((e) => {
779
+ // The picked agent may have been uninstalled since the list was
780
+ // fetched; refresh it so the picker shows what's installed now.
781
+ void recheckAgents();
704
782
  if (seq !== startSeqRef.current) return;
705
783
  setStartFailure({
706
784
  message:
@@ -1021,6 +1099,10 @@ function RoutedDiagnoseProvider({
1021
1099
  const value: DiagnoseCtx = {
1022
1100
  available,
1023
1101
  setupState,
1102
+ recheckAgents,
1103
+ checkingAgents,
1104
+ agentsCheckFailed,
1105
+ cliOverride,
1024
1106
  agentLabel,
1025
1107
  hosted,
1026
1108
  agents,
@@ -502,7 +502,8 @@ export function DiagnoseSurface({
502
502
  };
503
503
 
504
504
  // Feature is eligible here but not runnable yet (no agent installed, or one
505
- // appeared after boot) — Home leads with the setup notice instead of an empty list.
505
+ // found that investigations couldn't start with): Home leads with the setup
506
+ // notice instead of an empty list.
506
507
  const setupPending =
507
508
  d.setupState === "needs-install" || d.setupState === "needs-restart";
508
509
 
@@ -617,7 +618,13 @@ export function DiagnoseSurface({
617
618
  </div>
618
619
  ) : setupPending ? (
619
620
  <div className="flex-1 overflow-y-auto">
620
- <AgentSetupNotice setupState={d.setupState} />
621
+ <AgentSetupNotice
622
+ setupState={d.setupState}
623
+ cliOverride={d.cliOverride}
624
+ checkingAgents={d.checkingAgents}
625
+ agentsCheckFailed={d.agentsCheckFailed}
626
+ recheckAgents={d.recheckAgents}
627
+ />
621
628
  </div>
622
629
  ) : (
623
630
  <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-theme-text-tertiary">
@@ -639,7 +646,13 @@ export function DiagnoseSurface({
639
646
  </div>
640
647
  ) : setupPending ? (
641
648
  <div className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6">
642
- <AgentSetupNotice setupState={d.setupState} />
649
+ <AgentSetupNotice
650
+ setupState={d.setupState}
651
+ cliOverride={d.cliOverride}
652
+ checkingAgents={d.checkingAgents}
653
+ agentsCheckFailed={d.agentsCheckFailed}
654
+ recheckAgents={d.recheckAgents}
655
+ />
643
656
  </div>
644
657
  ) : (
645
658
  <InvestigationHome
@@ -58,7 +58,9 @@ import {
58
58
  type KeyboardEvent,
59
59
  } from "react";
60
60
  import { useQueryClient } from "@tanstack/react-query";
61
- import { Badge } from "@skyhook-io/k8s-ui";
61
+ import { useNavigate } from "react-router-dom";
62
+ import { Badge, gitOpsRouteForOwner, type GitOpsWrite } from "@skyhook-io/k8s-ui";
63
+ import { useGitOpsWriteGuard } from "../../hooks/useGitOpsWriteGuard";
62
64
  import {
63
65
  Send,
64
66
  AlertTriangle,
@@ -264,6 +266,8 @@ function captureEvidenceCardLayout(container: HTMLElement) {
264
266
  );
265
267
  }
266
268
 
269
+ const APPLY_WRITES: GitOpsWrite[] = [{ scope: "spec", description: "The proposed change" }];
270
+
267
271
  export function InvestigationView({
268
272
  run,
269
273
  agentLabel,
@@ -949,6 +953,38 @@ export function InvestigationView({
949
953
  // Apply: a user-confirmed remediation turn. Any step is applyable; the chosen
950
954
  // step's text is sent so the server binds the apply to it.
951
955
  const [confirmApply, setConfirmApply] = useState(false);
956
+ // The agent's change can touch any field of the target, so the guard
957
+ // classifies an unknown spec write.
958
+ const { guard: applyGitOpsGuard, ownership: applyOwnership } =
959
+ useGitOpsWriteGuard({
960
+ target: { kind, group: run.group, namespace, name },
961
+ writes: APPLY_WRITES,
962
+ declaredManager: run.managedBy,
963
+ enabled: confirmApply,
964
+ });
965
+ const navigateRouter = useNavigate();
966
+ const {
967
+ owner: applyOwner,
968
+ ownerVerified: applyOwnerVerified,
969
+ helmOwner: applyHelmOwner,
970
+ } = applyOwnership;
971
+ const openApplyOwner = useMemo(() => {
972
+ // A host without resource navigation (Radar Hub's fleet Diagnose panel)
973
+ // has no route for the owner either.
974
+ if (!onOpenResource) return undefined;
975
+ const owner = applyOwner;
976
+ const helmOwner = applyHelmOwner;
977
+ if (owner && applyOwnerVerified) {
978
+ return () => navigateRouter(gitOpsRouteForOwner(owner));
979
+ }
980
+ if (helmOwner) {
981
+ return () =>
982
+ navigateRouter(
983
+ `/helm?release=${encodeURIComponent(`${helmOwner.namespace}/${helmOwner.name}`)}`,
984
+ );
985
+ }
986
+ return undefined;
987
+ }, [applyOwner, applyOwnerVerified, applyHelmOwner, navigateRouter, onOpenResource]);
952
988
  const [pendingFix, setPendingFix] = useState("");
953
989
  const requestApply = (fix: string) => {
954
990
  if (interactionsBlocked) return;
@@ -2546,7 +2582,8 @@ export function InvestigationView({
2546
2582
  ]?.precondition
2547
2583
  : undefined
2548
2584
  }
2549
- managedBy={run.managedBy}
2585
+ gitOpsGuard={applyGitOpsGuard}
2586
+ onOpenGitOpsOwner={openApplyOwner}
2550
2587
  confidence={turns[lastRemediationIdx]?.diagnosis?.confidence}
2551
2588
  />
2552
2589
  </div>