@oneuptime/common 14.0.8 → 14.0.10

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 (976) hide show
  1. package/Models/DatabaseModels/AutoRemediationSuggestion.ts +60 -0
  2. package/Models/DatabaseModels/CephCluster.ts +213 -0
  3. package/Models/DatabaseModels/DatabaseServer.ts +213 -0
  4. package/Models/DatabaseModels/DockerHost.ts +213 -0
  5. package/Models/DatabaseModels/DockerSwarmCluster.ts +213 -0
  6. package/Models/DatabaseModels/GlobalConfig.ts +1 -1
  7. package/Models/DatabaseModels/Host.ts +213 -0
  8. package/Models/DatabaseModels/Index.ts +4 -0
  9. package/Models/DatabaseModels/KubernetesAiAgent.ts +364 -0
  10. package/Models/DatabaseModels/KubernetesCluster.ts +3 -3
  11. package/Models/DatabaseModels/PodmanHost.ts +213 -0
  12. package/Models/DatabaseModels/Project.ts +55 -5
  13. package/Models/DatabaseModels/ProxmoxCluster.ts +213 -0
  14. package/Models/DatabaseModels/ProxmoxResource.ts +49 -1
  15. package/Models/DatabaseModels/ResourceAiAgent.ts +419 -0
  16. package/Models/DatabaseModels/RunnerJob.ts +219 -0
  17. package/Models/DatabaseModels/TeamComplianceSetting.ts +143 -2
  18. package/Models/DatabaseModels/VMwareVCenter.ts +213 -0
  19. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +17 -0
  20. package/Server/API/AIChatAPI.ts +6 -3
  21. package/Server/API/AIInvestigationAPI.ts +17 -10
  22. package/Server/API/AutoRemediationAPI.ts +598 -25
  23. package/Server/API/KubernetesClusterAiAccessAPI.ts +623 -21
  24. package/Server/API/MicrosoftTeamsAPI.ts +93 -14
  25. package/Server/API/ProxmoxResourceAPI.ts +244 -14
  26. package/Server/API/ResourceAiAccessAPI.ts +1669 -0
  27. package/Server/Infrastructure/GlobalCache.ts +32 -0
  28. package/Server/Infrastructure/Postgres/SchemaMigrations/1796000000000-AddTeamComplianceRuleScope.ts +99 -0
  29. package/Server/Infrastructure/Postgres/SchemaMigrations/1796100000000-AddKubernetesAiAgentAndAiDefaults.ts +137 -0
  30. package/Server/Infrastructure/Postgres/SchemaMigrations/1796200000000-AddProxmoxResourceNativePushColumns.ts +42 -0
  31. package/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.ts +423 -0
  32. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  33. package/Server/Infrastructure/Semaphore.ts +22 -0
  34. package/Server/Middleware/TelemetryIngest.ts +51 -18
  35. package/Server/Services/AIService.ts +70 -1
  36. package/Server/Services/AlertService.ts +352 -11
  37. package/Server/Services/AlertSeverityService.ts +65 -15
  38. package/Server/Services/AnalyticsDatabaseService.ts +45 -1
  39. package/Server/Services/AutoRemediationRuleEngineService.ts +940 -112
  40. package/Server/Services/AutoRemediationRuleService.ts +1 -1
  41. package/Server/Services/CephClusterService.ts +109 -1
  42. package/Server/Services/DatabaseServerService.ts +116 -1
  43. package/Server/Services/DockerHostService.ts +109 -1
  44. package/Server/Services/DockerSwarmClusterService.ts +113 -1
  45. package/Server/Services/HostService.ts +126 -4
  46. package/Server/Services/IncidentService.ts +280 -104
  47. package/Server/Services/IncidentSeverityService.ts +66 -15
  48. package/Server/Services/KubernetesAiAgentJobService.ts +185 -0
  49. package/Server/Services/KubernetesAiAgentService.ts +1634 -0
  50. package/Server/Services/KubernetesClusterAiAccessService.ts +1118 -451
  51. package/Server/Services/KubernetesClusterService.ts +129 -21
  52. package/Server/Services/MetricRecordingRuleService.ts +47 -0
  53. package/Server/Services/PodmanHostService.ts +109 -1
  54. package/Server/Services/ProjectService.ts +32 -0
  55. package/Server/Services/ProxmoxClusterService.ts +110 -1
  56. package/Server/Services/ProxmoxResourceService.ts +322 -5
  57. package/Server/Services/ResourceAiAccessService.ts +1439 -0
  58. package/Server/Services/ResourceAiAgentJobService.ts +202 -0
  59. package/Server/Services/ResourceAiAgentService.ts +2432 -0
  60. package/Server/Services/RunnerJobService.ts +852 -108
  61. package/Server/Services/RunnerService.ts +1 -1
  62. package/Server/Services/ScheduledMaintenanceService.ts +737 -1
  63. package/Server/Services/ScheduledMaintenanceStateTimelineService.ts +414 -56
  64. package/Server/Services/TeamComplianceSettingService.ts +1125 -9
  65. package/Server/Services/TelemetryUsageBillingService.ts +28 -0
  66. package/Server/Services/TraceRecordingRuleService.ts +33 -0
  67. package/Server/Services/VMwareVCenterService.ts +110 -1
  68. package/Server/Utils/AI/ClusterAccess/ClusterAccessContext.ts +67 -9
  69. package/Server/Utils/AI/ClusterAccess/KubectlInvestigationToolkit.ts +39 -10
  70. package/Server/Utils/AI/ClusterAccess/KubectlJobRunner.ts +14 -3
  71. package/Server/Utils/AI/Remediation/RemediationCommandTools.ts +1475 -51
  72. package/Server/Utils/AI/Remediation/RemediationExecutionRunner.ts +1000 -43
  73. package/Server/Utils/AI/Remediation/RemediationPlanRunner.ts +25 -1
  74. package/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.ts +577 -0
  75. package/Server/Utils/AI/ResourceAccess/ResourceAccessContext.ts +271 -0
  76. package/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.ts +45 -0
  77. package/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.ts +964 -0
  78. package/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.ts +333 -0
  79. package/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.ts +873 -0
  80. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +120 -7
  81. package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +72 -5
  82. package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +72 -5
  83. package/Server/Utils/AI/SRE/IncidentPostmortemRunner.ts +40 -8
  84. package/Server/Utils/AI/SRE/InvestigationEligibility.ts +13 -2
  85. package/Server/Utils/AI/SRE/InvestigationGrader.ts +1 -1
  86. package/Server/Utils/AutoRemediation/CommandPlanExecutor.ts +402 -14
  87. package/Server/Utils/AutoRemediation/RemediationVerifier.ts +35 -2
  88. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +9 -1
  89. package/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.ts +920 -0
  90. package/Server/Utils/SessionReplay/SessionReplayUsage.ts +84 -6
  91. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +133 -7
  92. package/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.ts +58 -4
  93. package/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.ts +447 -0
  94. package/Server/Utils/Telemetry/ProxmoxNativePush.ts +903 -0
  95. package/Server/Utils/Telemetry/TelemetryEntity.ts +87 -27
  96. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +35 -15
  97. package/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.ts +33 -15
  98. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +21 -1
  99. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +298 -224
  100. package/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.ts +35 -15
  101. package/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.ts +389 -175
  102. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +1073 -213
  103. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.ts +163 -0
  104. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.ts +396 -0
  105. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.ts +418 -0
  106. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.ts +152 -0
  107. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.ts +251 -0
  108. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.ts +214 -0
  109. package/Tests/App/AdminDashboard/RowActionPlacementOverrides.test.tsx +278 -0
  110. package/Tests/App/Dashboard/AIChatChartWidgetZoom.test.tsx +738 -0
  111. package/Tests/App/Dashboard/AIFeaturesSettingsPointers.test.tsx +147 -0
  112. package/Tests/App/Dashboard/AIInsightsKubernetesClusterPage.test.tsx +1523 -0
  113. package/Tests/App/Dashboard/AdminUserNotificationMethodsPage.test.tsx +463 -3
  114. package/Tests/App/Dashboard/AffectedResourcesPickerDropdownPlacement.test.tsx +958 -0
  115. package/Tests/App/Dashboard/AffectedResourcesPickerFocusLeave.test.tsx +595 -0
  116. package/Tests/App/Dashboard/AskAiAvailability.test.ts +20 -3
  117. package/Tests/App/Dashboard/AskAiDisabled.test.tsx +8 -3
  118. package/Tests/App/Dashboard/CephRateChartReload.test.tsx +1456 -0
  119. package/Tests/App/Dashboard/CephTimeRangeZoom.test.tsx +1229 -0
  120. package/Tests/App/Dashboard/ChartCardTimeRangeZoom.test.tsx +151 -0
  121. package/Tests/App/Dashboard/ChartCardZoomAffordances.test.tsx +257 -0
  122. package/Tests/App/Dashboard/ChartRefetchFrame.test.tsx +141 -0
  123. package/Tests/App/Dashboard/ChartZoomHarness.tsx +249 -0
  124. package/Tests/App/Dashboard/ChartZoomStandIn.tsx +131 -0
  125. package/Tests/App/Dashboard/CloudServerlessSlowLoads.test.tsx +628 -0
  126. package/Tests/App/Dashboard/CloudServerlessTimeRangeZoom.test.tsx +607 -0
  127. package/Tests/App/Dashboard/ClusterAccessNotice.test.tsx +184 -19
  128. package/Tests/App/Dashboard/CompanionMetricsTabZoom.test.tsx +632 -0
  129. package/Tests/App/Dashboard/ContainerChartZoomStandIn.tsx +195 -0
  130. package/Tests/App/Dashboard/ContainerHostOverviewSlowLoads.test.tsx +542 -0
  131. package/Tests/App/Dashboard/ContainerHostOverviewTimeRangeZoom.test.tsx +848 -0
  132. package/Tests/App/Dashboard/ContainerMetricsTimeRangeZoom.test.tsx +561 -0
  133. package/Tests/App/Dashboard/DashboardBoardWideZoomShell.test.tsx +1147 -0
  134. package/Tests/App/Dashboard/DashboardEditPermissions.test.tsx +3 -2
  135. package/Tests/App/Dashboard/DashboardHistogramWidgetDragCursor.test.tsx +399 -0
  136. package/Tests/App/Dashboard/DashboardHistogramWidgetZoom.test.tsx +985 -0
  137. package/Tests/App/Dashboard/DashboardHistogramWidgetZoomHint.test.tsx +803 -0
  138. package/Tests/App/Dashboard/DashboardLazyPages.test.tsx +1 -0
  139. package/Tests/App/Dashboard/DashboardSettingsPreviewZoom.test.tsx +679 -0
  140. package/Tests/App/Dashboard/DashboardSloAndDataSourceChartZoom.test.tsx +881 -0
  141. package/Tests/App/Dashboard/DashboardTraceStatusWidgets.test.tsx +878 -0
  142. package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +18 -6
  143. package/Tests/App/Dashboard/DatabaseMetricChartModalTimeRangeZoom.test.tsx +442 -0
  144. package/Tests/App/Dashboard/DatabaseOverviewTimeRangeZoom.test.tsx +831 -0
  145. package/Tests/App/Dashboard/DatabaseServerComponents.test.tsx +43 -0
  146. package/Tests/App/Dashboard/DockerSwarmInsightsTimeRangeZoom.test.tsx +524 -0
  147. package/Tests/App/Dashboard/DockerSwarmRateChartRefresh.test.tsx +259 -0
  148. package/Tests/App/Dashboard/EdgeDetailPanelHistory.test.tsx +763 -0
  149. package/Tests/App/Dashboard/EdgeDetailPanelTimeRangeZoom.test.tsx +547 -0
  150. package/Tests/App/Dashboard/EdgeHistoryBucketsCases.tsx +565 -0
  151. package/Tests/App/Dashboard/EdgeHistoryBucketsKathmandu.test.tsx +73 -0
  152. package/Tests/App/Dashboard/EdgeHistoryBucketsKolkata.test.tsx +74 -0
  153. package/Tests/App/Dashboard/EdgeHistoryBucketsNewYork.test.tsx +72 -0
  154. package/Tests/App/Dashboard/EdgeHistoryBucketsUtc.test.tsx +72 -0
  155. package/Tests/App/Dashboard/EmbeddedMetricCardRefresh.test.tsx +310 -0
  156. package/Tests/App/Dashboard/EmbeddedMetricCardTimeRangeZoom.test.tsx +536 -0
  157. package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +3 -1
  158. package/Tests/App/Dashboard/EscalationRuleRowActions.test.tsx +660 -0
  159. package/Tests/App/Dashboard/EventNotes.test.tsx +2 -2
  160. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +587 -13
  161. package/Tests/App/Dashboard/EventOverviewSnapshotExplorerZoom.test.tsx +1139 -0
  162. package/Tests/App/Dashboard/EventOverviewSnapshotZoomAcrossTabs.test.tsx +1057 -0
  163. package/Tests/App/Dashboard/EventOverviewTelemetrySnapshotZoom.test.tsx +757 -0
  164. package/Tests/App/Dashboard/ExceptionOccurrenceTrendZoom.test.tsx +675 -0
  165. package/Tests/App/Dashboard/ExceptionOccurrencesZoomSurvivesViewSwitch.test.tsx +405 -0
  166. package/Tests/App/Dashboard/ExceptionSignalPages.test.tsx +67 -3
  167. package/Tests/App/Dashboard/ExceptionTablesSpanStatus.test.tsx +515 -0
  168. package/Tests/App/Dashboard/ExplorerOwnZoomRegression.test.tsx +739 -0
  169. package/Tests/App/Dashboard/FlowBandwidthAxisCases.tsx +461 -0
  170. package/Tests/App/Dashboard/FlowBandwidthAxisKathmandu.test.tsx +25 -0
  171. package/Tests/App/Dashboard/FlowBandwidthAxisKolkata.test.tsx +25 -0
  172. package/Tests/App/Dashboard/FlowBandwidthAxisNewYork.test.tsx +25 -0
  173. package/Tests/App/Dashboard/FlowBandwidthAxisUtc.test.tsx +24 -0
  174. package/Tests/App/Dashboard/FlowTopTalkersZoomRefetch.test.tsx +722 -0
  175. package/Tests/App/Dashboard/FlowViewportGuard.test.tsx +2526 -0
  176. package/Tests/App/Dashboard/HostPagesSlowLoads.test.tsx +872 -0
  177. package/Tests/App/Dashboard/HostPagesTimeRangeZoom.test.tsx +1208 -0
  178. package/Tests/App/Dashboard/IncidentRootCauseTimeRangeZoom.test.tsx +358 -0
  179. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +100 -9
  180. package/Tests/App/Dashboard/InfrastructureMetricsTabTimeRangeZoom.test.tsx +485 -0
  181. package/Tests/App/Dashboard/InvestigationDrawerCompanionZoom.test.tsx +655 -0
  182. package/Tests/App/Dashboard/InvestigationDrawerZoom.test.tsx +830 -0
  183. package/Tests/App/Dashboard/InvestigationInfrastructureTools.test.tsx +506 -0
  184. package/Tests/App/Dashboard/InvestigationNotStarted.test.tsx +338 -2
  185. package/Tests/App/Dashboard/InvoicesPayInvoice.test.tsx +454 -16
  186. package/Tests/App/Dashboard/KubernetesClusterAiAccessSettings.test.ts +799 -819
  187. package/Tests/App/Dashboard/KubernetesClusterAiAgentOverviewCard.test.tsx +367 -0
  188. package/Tests/App/Dashboard/KubernetesClusterAiAgentStatus.test.ts +1202 -0
  189. package/Tests/App/Dashboard/KubernetesClusterAiDocumentationMarkdown.test.ts +106 -0
  190. package/Tests/App/Dashboard/KubernetesClusterAiNavigation.test.tsx +354 -0
  191. package/Tests/App/Dashboard/KubernetesClusterAiPage.test.tsx +1884 -2054
  192. package/Tests/App/Dashboard/KubernetesClusterAiPageServerParity.test.ts +478 -179
  193. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +50 -0
  194. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +2 -1
  195. package/Tests/App/Dashboard/KubernetesClusterOverviewZoom.test.tsx +1217 -0
  196. package/Tests/App/Dashboard/KubernetesClusterPagesZoom.test.tsx +731 -0
  197. package/Tests/App/Dashboard/KubernetesCostsLoadRecovery.test.tsx +869 -0
  198. package/Tests/App/Dashboard/KubernetesCostsZoom.test.tsx +1214 -0
  199. package/Tests/App/Dashboard/KubernetesCostsZoomExactHours.test.tsx +692 -0
  200. package/Tests/App/Dashboard/KubernetesNetworkThroughputReload.test.tsx +902 -0
  201. package/Tests/App/Dashboard/KubernetesNetworkZoomBucketEdges.test.tsx +571 -0
  202. package/Tests/App/Dashboard/KubernetesOverviewZoomBucketEdges.test.tsx +697 -0
  203. package/Tests/App/Dashboard/KubernetesResourceMetricsTabZoom.test.tsx +633 -0
  204. package/Tests/App/Dashboard/LogsExplorerTimeRangeZoom.test.tsx +470 -0
  205. package/Tests/App/Dashboard/LogsInsightsTimeRangeZoom.test.tsx +948 -0
  206. package/Tests/App/Dashboard/LogsViewerPinnedWindowZoom.test.tsx +486 -0
  207. package/Tests/App/Dashboard/MetricChartsBucketInspectorDoubleClick.test.tsx +1001 -0
  208. package/Tests/App/Dashboard/MetricExplorerInvestigationAutoRefresh.test.tsx +514 -0
  209. package/Tests/App/Dashboard/MetricExplorerSavedViewZoomRoundTrip.test.tsx +581 -0
  210. package/Tests/App/Dashboard/MetricExplorerTimeRangeZoom.test.tsx +1162 -0
  211. package/Tests/App/Dashboard/MetricMonitorPreviewZoomLabel.test.ts +72 -0
  212. package/Tests/App/Dashboard/MetricRowSparklineTimeRangeZoom.test.tsx +204 -0
  213. package/Tests/App/Dashboard/MetricViewFirstResultLands.test.tsx +408 -0
  214. package/Tests/App/Dashboard/MetricViewOwnZoomResetPlacement.test.tsx +952 -0
  215. package/Tests/App/Dashboard/MetricViewTimeRange.test.ts +139 -0
  216. package/Tests/App/Dashboard/MetricViewTimeRangeZoom.test.tsx +556 -0
  217. package/Tests/App/Dashboard/MonitorMetricsChartWrappersZoom.test.tsx +552 -0
  218. package/Tests/App/Dashboard/MonitorMetricsTabsTimeRangeZoom.test.tsx +740 -0
  219. package/Tests/App/Dashboard/MonitorOverviewChartsTimeRangeZoom.test.tsx +735 -0
  220. package/Tests/App/Dashboard/MonitorStepPreviewsTimeRangeZoom.test.tsx +835 -0
  221. package/Tests/App/Dashboard/NetworkDeviceTimeRangeZoom.test.tsx +1115 -0
  222. package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +186 -17
  223. package/Tests/App/Dashboard/OnCallRulesDeleteGuard.test.tsx +155 -46
  224. package/Tests/App/Dashboard/PasskeySettings.test.tsx +87 -2
  225. package/Tests/App/Dashboard/ProjectAISettingsPages.test.tsx +884 -0
  226. package/Tests/App/Dashboard/ProxmoxNodeRemove.test.tsx +621 -0
  227. package/Tests/App/Dashboard/ProxmoxOverviewSlowLoads.test.tsx +584 -0
  228. package/Tests/App/Dashboard/ProxmoxPagesTimeRangeZoom.test.tsx +823 -0
  229. package/Tests/App/Dashboard/ProxmoxRateChartReload.test.tsx +1253 -0
  230. package/Tests/App/Dashboard/PublicDashboardBoardWideZoom.test.tsx +605 -0
  231. package/Tests/App/Dashboard/RealRowsLineChart.tsx +250 -0
  232. package/Tests/App/Dashboard/RechartsHostsPlotCursor.test.tsx +513 -0
  233. package/Tests/App/Dashboard/RemediationSuggestionCardDescription.test.tsx +209 -0
  234. package/Tests/App/Dashboard/RemediationSuggestionCardResource.test.tsx +317 -0
  235. package/Tests/App/Dashboard/ResourceAiAccessSettingsUtil.test.ts +921 -0
  236. package/Tests/App/Dashboard/ResourceAiAgentInstall.test.ts +860 -0
  237. package/Tests/App/Dashboard/ResourceAiAgentPage.test.tsx +1721 -0
  238. package/Tests/App/Dashboard/ResourceAiAgentStatus.test.ts +1002 -0
  239. package/Tests/App/Dashboard/ResourceAiInsightsPage.test.tsx +931 -0
  240. package/Tests/App/Dashboard/ResourceAiNavigation.test.tsx +575 -0
  241. package/Tests/App/Dashboard/RowActionPlacementOverrides.test.tsx +865 -0
  242. package/Tests/App/Dashboard/RumOverviewTimeRangeZoom.test.tsx +877 -0
  243. package/Tests/App/Dashboard/RunbookStepTypeMaps.test.ts +38 -7
  244. package/Tests/App/Dashboard/RunnerKubernetesAgentForm.test.tsx +20 -4
  245. package/Tests/App/Dashboard/RunnerSupersededByAiAgentNote.test.tsx +296 -0
  246. package/Tests/App/Dashboard/SecurityEventConnectionsTable.test.tsx +146 -36
  247. package/Tests/App/Dashboard/ServiceDatabaseSlowLoads.test.tsx +1166 -0
  248. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +1243 -104
  249. package/Tests/App/Dashboard/ServiceOverviewTimeRangeZoom.test.tsx +792 -0
  250. package/Tests/App/Dashboard/SloBudgetBurnDownZoom.test.tsx +771 -0
  251. package/Tests/App/Dashboard/SloBudgetBurnDownZoomAffordances.test.tsx +380 -0
  252. package/Tests/App/Dashboard/SloEventMetricsBarChartZoom.test.tsx +369 -0
  253. package/Tests/App/Dashboard/SloHistoryChartsTimeRangeZoom.test.tsx +694 -0
  254. package/Tests/App/Dashboard/SloHistoryChartsZoomAffordances.test.tsx +497 -0
  255. package/Tests/App/Dashboard/SloMetricsTimeRangeZoom.test.tsx +541 -0
  256. package/Tests/App/Dashboard/SlowLoadHarness.ts +123 -0
  257. package/Tests/App/Dashboard/SnapshotExplorerPrimaryZoom.test.tsx +1194 -0
  258. package/Tests/App/Dashboard/SnapshotViewerPinnedWindow.test.tsx +620 -0
  259. package/Tests/App/Dashboard/TelemetrySnapshotPanelTimeRangeZoom.test.tsx +765 -0
  260. package/Tests/App/Dashboard/TelemetrySnapshotZoom.test.tsx +516 -0
  261. package/Tests/App/Dashboard/TelemetrySnapshotZoomAcrossTabs.test.tsx +827 -0
  262. package/Tests/App/Dashboard/TimeRangeZoomPageHarness.tsx +463 -0
  263. package/Tests/App/Dashboard/TraceChartEditorStatusSplit.test.tsx +543 -0
  264. package/Tests/App/Dashboard/TraceStatusPresentation.test.tsx +967 -0
  265. package/Tests/App/Dashboard/TraceTimelineMinimapZoom.test.tsx +315 -0
  266. package/Tests/App/Dashboard/TracesAnalyticsStatusSplit.test.tsx +778 -0
  267. package/Tests/App/Dashboard/TracesAnalyticsViewZoom.test.tsx +349 -0
  268. package/Tests/App/Dashboard/TracesExplorerTimeRangeZoom.test.tsx +551 -0
  269. package/Tests/App/Dashboard/TracesStatusChartAndFacet.test.tsx +687 -0
  270. package/Tests/App/Dashboard/TracesViewerStaleResponses.test.tsx +458 -0
  271. package/Tests/App/Dashboard/UptimeStripsExcludedFromZoom.test.tsx +406 -0
  272. package/Tests/App/Dashboard/VMwareOverviewSlowLoads.test.tsx +535 -0
  273. package/Tests/App/Dashboard/VMwarePagesTimeRangeZoom.test.tsx +606 -0
  274. package/Tests/App/Dashboard/ValueWidgetSparklineStaleHover.test.tsx +1292 -0
  275. package/Tests/App/Dashboard/ValueWidgetSparklineZoom.test.tsx +765 -0
  276. package/Tests/App/StatusPage/StatusPageResourceExplorer.test.tsx +98 -0
  277. package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +33 -1
  278. package/Tests/Models/DatabaseModels/KubernetesAiAgentModel.test.ts +295 -0
  279. package/Tests/Models/DatabaseModels/ResourceAiAccessColumns.test.ts +738 -0
  280. package/Tests/Models/DatabaseModels/ResourceAiAgentModel.test.ts +842 -0
  281. package/Tests/Models/DatabaseModels/TeamComplianceSettingModel.test.ts +347 -0
  282. package/Tests/Server/API/AIChatRouteAuthorization.test.ts +58 -0
  283. package/Tests/Server/API/AIGenerationProjectAIToggle.test.ts +1 -1
  284. package/Tests/Server/API/AIInvestigationAPI.test.ts +123 -45
  285. package/Tests/Server/API/AutoRemediationApproveClusterRound.test.ts +1329 -0
  286. package/Tests/Server/API/AutoRemediationApproveKubectlRecheck.test.ts +25 -2
  287. package/Tests/Server/API/AutoRemediationApproveResourceRound.test.ts +835 -0
  288. package/Tests/Server/API/KubernetesClusterAiAccessAPI.test.ts +2 -0
  289. package/Tests/Server/API/KubernetesClusterAiAccessAPIAgent.test.ts +903 -0
  290. package/Tests/Server/API/MicrosoftTeamsChatsRefreshRoute.test.ts +667 -0
  291. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +294 -6
  292. package/Tests/Server/API/ProxmoxResourceAPIRemoveNode.test.ts +1932 -0
  293. package/Tests/Server/API/ResourceAiAccessAPI.test.ts +2730 -0
  294. package/Tests/Server/Infrastructure/GlobalCache.test.ts +195 -0
  295. package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +60 -0
  296. package/Tests/Server/Infrastructure/Postgres/AddKubernetesAiAgentAndAiDefaultsMigration.test.ts +623 -0
  297. package/Tests/Server/Infrastructure/Postgres/AddKubernetesClusterAiAccessMigration.test.ts +87 -10
  298. package/Tests/Server/Infrastructure/Postgres/AddResourceAiAgentsMigration.test.ts +659 -0
  299. package/Tests/Server/Infrastructure/Postgres/AddTeamComplianceRuleScopeMigration.test.ts +622 -0
  300. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +31 -0
  301. package/Tests/Server/Middleware/TelemetryIngestBrowserKey.test.ts +119 -4
  302. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerPinnedKey.test.ts +196 -2
  303. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerRateLimit.test.ts +123 -2
  304. package/Tests/Server/Services/AIServiceBalanceBlocker.test.ts +282 -0
  305. package/Tests/Server/Services/AlertMonitorClearWritesNull.test.ts +409 -0
  306. package/Tests/Server/Services/AlertMonitorEditFeed.test.ts +474 -0
  307. package/Tests/Server/Services/AlertMonitorEditGuard.test.ts +610 -0
  308. package/Tests/Server/Services/AutoRemediationClusterRuleEngine.test.ts +2 -0
  309. package/Tests/Server/Services/AutoRemediationResourceRuleEngine.test.ts +1279 -0
  310. package/Tests/Server/Services/AutoRemediationRuleCommandRunners.test.ts +2 -1
  311. package/Tests/Server/Services/DatabaseServerService.test.ts +55 -0
  312. package/Tests/Server/Services/GroupTelemetryUsageExcludeNames.test.ts +299 -0
  313. package/Tests/Server/Services/HostServiceFindOrCreateMemo.test.ts +44 -0
  314. package/Tests/Server/Services/IncidentAffectedMonitorsEdit.test.ts +1360 -0
  315. package/Tests/Server/Services/IncidentAlertSloLinkSurvivesEdit.test.ts +3 -1
  316. package/Tests/Server/Services/IncidentSeverityDeletePausesComplianceRules.test.ts +488 -0
  317. package/Tests/Server/Services/KubernetesAiAgentJobService.test.ts +329 -0
  318. package/Tests/Server/Services/KubernetesAiAgentServiceHelpers.test.ts +618 -0
  319. package/Tests/Server/Services/KubernetesAiAgentServiceLifecycle.test.ts +1444 -0
  320. package/Tests/Server/Services/KubernetesAiAgentServiceRegister.test.ts +1666 -0
  321. package/Tests/Server/Services/KubernetesClusterAiAccessDeletedRunnerRemedy.test.ts +140 -111
  322. package/Tests/Server/Services/KubernetesClusterAiAccessGapNextSteps.test.ts +267 -105
  323. package/Tests/Server/Services/KubernetesClusterAiAccessLegacySuperseded.test.ts +440 -0
  324. package/Tests/Server/Services/KubernetesClusterAiAccessService.test.ts +567 -411
  325. package/Tests/Server/Services/KubernetesClusterAiAccessTarget.test.ts +1168 -0
  326. package/Tests/Server/Services/KubernetesClusterAiSettingsPermission.test.ts +410 -12
  327. package/Tests/Server/Services/KubernetesClusterServiceAiAccessFeed.test.ts +55 -0
  328. package/Tests/Server/Services/MonitorProbeServiceIntervalScheduling.test.ts +45 -6
  329. package/Tests/Server/Services/ProjectServiceNewProjectAiDefaults.test.ts +288 -0
  330. package/Tests/Server/Services/ProxmoxResourcePostgres.test.ts +5633 -0
  331. package/Tests/Server/Services/ProxmoxResourceService.test.ts +6458 -5
  332. package/Tests/Server/Services/RecordingRuleReservedMetricName.test.ts +171 -0
  333. package/Tests/Server/Services/ResourceAiAccessChangeAuthorization.test.ts +256 -0
  334. package/Tests/Server/Services/ResourceAiAccessService.test.ts +1373 -0
  335. package/Tests/Server/Services/ResourceAiAgentJobService.test.ts +375 -0
  336. package/Tests/Server/Services/ResourceAiAgentServiceHelpers.test.ts +1114 -0
  337. package/Tests/Server/Services/ResourceAiAgentServiceLifecycle.test.ts +1050 -0
  338. package/Tests/Server/Services/ResourceAiAgentServiceRegister.test.ts +2251 -0
  339. package/Tests/Server/Services/ResourceAiSettingsCreate.test.ts +373 -0
  340. package/Tests/Server/Services/ResourceAiSettingsPermission.test.ts +1042 -0
  341. package/Tests/Server/Services/ResourceServiceDeleteCleansUpAi.test.ts +319 -0
  342. package/Tests/Server/Services/RunnerJobEnqueueKubectl.test.ts +153 -15
  343. package/Tests/Server/Services/RunnerJobEnqueueKubectlAiAgent.test.ts +620 -0
  344. package/Tests/Server/Services/RunnerJobEnqueueResourceCommand.test.ts +986 -0
  345. package/Tests/Server/Services/RunnerJobKubectlWriteScope.test.ts +170 -3
  346. package/Tests/Server/Services/RunnerJobResourceCommandLane.test.ts +211 -0
  347. package/Tests/Server/Services/RunnerJobResourceCommandTimeoutAndRedaction.test.ts +352 -0
  348. package/Tests/Server/Services/RunnerJobTimeoutReason.test.ts +72 -0
  349. package/Tests/Server/Services/RunnerServiceAgentName.test.ts +6 -4
  350. package/Tests/Server/Services/ScheduledMaintenanceAttachmentEdit.test.ts +2113 -0
  351. package/Tests/Server/Services/ScheduledMaintenanceMonitorRelease.test.ts +972 -0
  352. package/Tests/Server/Services/TeamComplianceSettingPostgres.test.ts +2237 -0
  353. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +3988 -138
  354. package/Tests/Server/Services/TelemetryUsageBillingSloExclusion.test.ts +218 -0
  355. package/Tests/Server/TestingUtils/Services/FakeRunnerJobCount.ts +145 -0
  356. package/Tests/Server/Utils/AI/ClusterAccessContext.test.ts +107 -1
  357. package/Tests/Server/Utils/AI/IncidentPostmortemRunner.test.ts +351 -0
  358. package/Tests/Server/Utils/AI/InvestigationClusterAccessWiring.test.ts +2 -0
  359. package/Tests/Server/Utils/AI/InvestigationEligibility.test.ts +310 -0
  360. package/Tests/Server/Utils/AI/InvestigationInfrastructureAccessWiring.test.ts +453 -0
  361. package/Tests/Server/Utils/AI/InvestigationInfrastructureReport.test.ts +721 -0
  362. package/Tests/Server/Utils/AI/KubectlInvestigationToolkit.test.ts +70 -0
  363. package/Tests/Server/Utils/AI/KubectlJobRunnerFinishedJob.test.ts +58 -0
  364. package/Tests/Server/Utils/AI/RemediationCommandTools.test.ts +94 -1
  365. package/Tests/Server/Utils/AI/RemediationCommandToolsKubectl.test.ts +2 -0
  366. package/Tests/Server/Utils/AI/RemediationCommandToolsKubectlOutcome.test.ts +2 -0
  367. package/Tests/Server/Utils/AI/RemediationCommandToolsLiveCluster.test.ts +2 -0
  368. package/Tests/Server/Utils/AI/RemediationCommandToolsResource.test.ts +1530 -0
  369. package/Tests/Server/Utils/AI/RemediationCommandToolsRunnerScope.test.ts +125 -1
  370. package/Tests/Server/Utils/AI/RemediationExecutionRunner.test.ts +252 -3
  371. package/Tests/Server/Utils/AI/RemediationExecutionRunnerClusterMode.test.ts +2 -0
  372. package/Tests/Server/Utils/AI/RemediationExecutionRunnerProjectGates.test.ts +240 -0
  373. package/Tests/Server/Utils/AI/RemediationExecutionRunnerResourceMode.test.ts +1249 -0
  374. package/Tests/Server/Utils/AI/RemediationKubectlCopyParity.test.ts +2 -0
  375. package/Tests/Server/Utils/AI/RemediationPlanRunner.test.ts +117 -0
  376. package/Tests/Server/Utils/AI/RemediationResourceCopyParity.test.ts +463 -0
  377. package/Tests/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.test.ts +722 -0
  378. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAccessContext.test.ts +266 -0
  379. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.test.ts +1220 -0
  380. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.test.ts +569 -0
  381. package/Tests/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.test.ts +830 -0
  382. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutor.test.ts +2 -0
  383. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorResource.test.ts +717 -0
  384. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorUnknownRun.test.ts +2 -0
  385. package/Tests/Server/Utils/AutoRemediation/CommandPlanVerification.test.ts +2 -0
  386. package/Tests/Server/Utils/AutoRemediation/RemediationVerifierResourceFollowUp.test.ts +338 -0
  387. package/Tests/Server/Utils/Monitor/Criteria/SessionReplayBudgetTemplateCriteria.test.ts +422 -0
  388. package/Tests/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.test.ts +1647 -0
  389. package/Tests/Server/Utils/SessionReplay/SessionReplayUsage.test.ts +143 -1
  390. package/Tests/Server/Utils/Telemetry/HostAssetAttributesFromOtlp.test.ts +68 -0
  391. package/Tests/Server/Utils/Telemetry/InventoryMirrorPostgres.test.ts +370 -0
  392. package/Tests/Server/Utils/Telemetry/NetworkDeviceInventoryAttributes.test.ts +522 -0
  393. package/Tests/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.test.ts +155 -0
  394. package/Tests/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.test.ts +8631 -0
  395. package/Tests/Server/Utils/Telemetry/ProxmoxNativePush.test.ts +2852 -0
  396. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +367 -1
  397. package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.test.ts +75 -3
  398. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsAccountNotLinked.test.ts +1093 -0
  399. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.test.ts +1049 -0
  400. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.test.ts +1676 -0
  401. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCards.test.ts +2884 -0
  402. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.test.ts +2490 -0
  403. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmit.test.ts +2293 -0
  404. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmitServerTimezone.test.ts +386 -0
  405. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.test.ts +1477 -0
  406. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.test.ts +1967 -0
  407. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsStripHtmlTags.test.ts +92 -0
  408. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsSubmittedFormRemoval.test.ts +1819 -0
  409. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.test.ts +1033 -0
  410. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezoneServerZone.test.ts +610 -0
  411. package/Tests/Server/Utils/Workspace/MicrosoftTeamsBotMessageHandling.test.ts +2610 -0
  412. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChats.test.ts +25 -0
  413. package/Tests/Server/Utils/Workspace/MicrosoftTeamsCreateCommandsEndToEnd.test.ts +4672 -0
  414. package/Tests/Server/Utils/Workspace/MicrosoftTeamsGroupChatNames.test.ts +3119 -0
  415. package/Tests/Server/Utils/Workspace/MicrosoftTeamsGroupChatNamesPostgres.test.ts +1721 -0
  416. package/Tests/Server/Utils/Workspace/WorkspaceCreateProjectReferences.test.ts +190 -65
  417. package/Tests/Types/AutoRemediation/AiRemediationCommandPlan.test.ts +10 -4
  418. package/Tests/Types/AutoRemediation/AiRemediationCommandPlanResourceCommand.test.ts +305 -0
  419. package/Tests/Types/Kubernetes/KubernetesAgentChartAiAccess.test.ts +581 -113
  420. package/Tests/Types/Kubernetes/KubernetesAiAgentAccessContract.test.ts +510 -0
  421. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +277 -13
  422. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +113 -0
  423. package/Tests/Types/Monitor/RumAlertTemplates.test.ts +773 -2
  424. package/Tests/Types/ResourceAiAgent/AiResourceType.test.ts +419 -0
  425. package/Tests/Types/ResourceAiAgent/ResourceAiAccess.test.ts +517 -0
  426. package/Tests/Types/Runbook/RunbookStepType.test.ts +147 -8
  427. package/Tests/Types/Team/ComplianceRule.test.ts +1029 -0
  428. package/Tests/Types/Team/TeamComplianceStatus.test.ts +176 -0
  429. package/Tests/UI/Components/ActionButton/RowActions.test.tsx +641 -0
  430. package/Tests/UI/Components/ActionButton/RowActionsAccessibility.test.tsx +414 -0
  431. package/Tests/UI/Components/ActionButton/SplitActionButtons.test.ts +565 -0
  432. package/Tests/UI/Components/Charts/BarChartDragToSelect.test.tsx +386 -0
  433. package/Tests/UI/Components/Charts/ChartDoubleClickResetWithoutDblclick.test.tsx +1025 -0
  434. package/Tests/UI/Components/Charts/ChartGroupPageZoomHint.test.tsx +196 -0
  435. package/Tests/UI/Components/Charts/ChartPlaceholdersTakeDoubleClick.test.tsx +1861 -0
  436. package/Tests/UI/Components/Charts/ChartPlotClicksWhileResetArmed.test.tsx +382 -0
  437. package/Tests/UI/Components/Charts/ChartRangeSelection.test.tsx +704 -0
  438. package/Tests/UI/Components/Charts/ChartRangeSelectionDoubleClickReset.test.tsx +1481 -0
  439. package/Tests/UI/Components/Charts/ChartRangeSelectionRealRecharts.test.tsx +918 -0
  440. package/Tests/UI/Components/Charts/ChartRowBucketStart.test.ts +350 -0
  441. package/Tests/UI/Components/Charts/DoubleClickReset.test.tsx +996 -0
  442. package/Tests/UI/Components/Charts/HistogramDoubleClickResetRealRecharts.test.tsx +965 -0
  443. package/Tests/UI/Components/Charts/HistogramDriftThenRefetch.test.tsx +277 -0
  444. package/Tests/UI/Components/Charts/HistogramRangeSelectionDataChange.test.tsx +1757 -0
  445. package/Tests/UI/Components/Charts/HistogramRangeSelectionRealRecharts.test.tsx +560 -0
  446. package/Tests/UI/Components/Charts/TimeRangeZoom/ChartPageTimeRangeZoom.test.tsx +513 -0
  447. package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.test.tsx +845 -0
  448. package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.test.tsx +119 -0
  449. package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHintTailwind.test.tsx +274 -0
  450. package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.test.ts +433 -0
  451. package/Tests/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.test.tsx +863 -0
  452. package/Tests/UI/Components/Charts/TimeRangeZoom/ZoomDocsPromises.test.tsx +1147 -0
  453. package/Tests/UI/Components/DisabledButtonTooltip.test.tsx +14 -3
  454. package/Tests/UI/Components/EntityDropdownEscape.test.tsx +454 -0
  455. package/Tests/UI/Components/EntityDropdownFocusLeave.test.tsx +420 -0
  456. package/Tests/UI/Components/Forms/FormFieldDropdownDisabled.test.tsx +233 -0
  457. package/Tests/UI/Components/Input.test.tsx +80 -1
  458. package/Tests/UI/Components/LogsAnalyticsViewZoom.test.tsx +947 -0
  459. package/Tests/UI/Components/LogsHistogramDragTooltip.test.tsx +17 -5
  460. package/Tests/UI/Components/LogsViewerAnalyticsZoom.test.tsx +501 -0
  461. package/Tests/UI/Components/LogsViewerHistogramZoom.test.tsx +1 -1
  462. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +6 -4
  463. package/Tests/UI/Components/ModelTable/BaseModelTablePermissionGating.test.tsx +60 -8
  464. package/Tests/UI/Components/ModelTable/BaseModelTableRowActions.test.tsx +476 -0
  465. package/Tests/UI/Components/ModelTable/ModelTableWrapContent.test.tsx +7 -5
  466. package/Tests/UI/Components/MoreMenuPortal.test.tsx +623 -0
  467. package/Tests/UI/Components/StatusPage/ResourceGridActions.test.tsx +538 -0
  468. package/Tests/UI/Components/StatusPage/ResourceGridSelection.test.tsx +14 -4
  469. package/Tests/UI/Components/TableCellWrapping.test.tsx +3 -3
  470. package/Tests/UI/Components/TableRowActions.test.tsx +472 -0
  471. package/Tests/UI/Components/TelemetryHistogramDragTooltip.test.tsx +1 -1
  472. package/Tests/UI/Components/TelemetryViewer/TelemetryViewerHistogramClickZoom.test.tsx +1 -1
  473. package/Tests/UI/Components/TelemetryViewer/TelemetryViewerZoomScope.test.tsx +470 -0
  474. package/Tests/UI/Components/TelemetryViewer/UseViewerTimeRangeZoom.test.tsx +571 -0
  475. package/Tests/UI/Components/TelemetryViewer/ViewerDoubleClickResetUnderDataChange.test.tsx +928 -0
  476. package/Tests/UI/Components/TelemetryViewer/ViewerFollowsEnclosingZoom.test.tsx +1569 -0
  477. package/Tests/UI/Components/Toggle.test.tsx +66 -0
  478. package/Tests/UI/MicrosoftTeams/MicrosoftTeamsChatsCardRefreshNames.test.tsx +1023 -0
  479. package/Tests/UI/Rum/ChunkLoader.test.ts +201 -0
  480. package/Tests/UI/Rum/FidelityNotices.test.ts +54 -0
  481. package/Tests/UI/Rum/RecordingHealthDashboard.test.tsx +769 -1
  482. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +72 -0
  483. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +1009 -9
  484. package/Tests/UI/Rum/ReplayRecordedAssets.test.ts +1069 -0
  485. package/Tests/UI/Rum/ReplayStage.test.tsx +913 -2
  486. package/Tests/UI/Rum/SessionReplayTable.test.tsx +443 -8
  487. package/Tests/UI/Rum/SessionReplayUsersTable.test.tsx +439 -9
  488. package/Tests/UI/Rum/UserFlowTablesRowActions.test.tsx +501 -0
  489. package/Tests/Utils/AiRemediation/KubectlPolicyChartRbacParity.test.ts +47 -19
  490. package/Tests/Utils/AiRemediation/KubectlWriteScopeCallerParity.test.ts +2 -0
  491. package/Tests/Utils/AiRemediation/KubernetesAiAgentPolicyCopyParity.test.ts +396 -0
  492. package/Tests/Utils/AiRemediation/Resource/CephCommandPolicy.test.ts +1653 -0
  493. package/Tests/Utils/AiRemediation/Resource/CephOutputRedaction.test.ts +204 -0
  494. package/Tests/Utils/AiRemediation/Resource/DatabaseCommandPolicy.test.ts +1077 -0
  495. package/Tests/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.test.ts +558 -0
  496. package/Tests/Utils/AiRemediation/Resource/DatabaseQueryRedactor.test.ts +834 -0
  497. package/Tests/Utils/AiRemediation/Resource/DockerCliGrammar.test.ts +747 -0
  498. package/Tests/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.test.ts +1259 -0
  499. package/Tests/Utils/AiRemediation/Resource/DockerOutputRedaction.test.ts +386 -0
  500. package/Tests/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.test.ts +782 -0
  501. package/Tests/Utils/AiRemediation/Resource/GovcCommandPolicy.test.ts +2259 -0
  502. package/Tests/Utils/AiRemediation/Resource/HostCommandPolicy.test.ts +2019 -0
  503. package/Tests/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.test.ts +2497 -0
  504. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicy.test.ts +1305 -0
  505. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.test.ts +585 -0
  506. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactor.test.ts +334 -0
  507. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactorCopyParity.test.ts +98 -0
  508. package/Tests/Utils/AiRemediation/Resource/ResourcePolicyImportClosure.test.ts +391 -0
  509. package/Tests/Utils/AiRemediation/ResourceAiAgentPolicyCopyParity.test.ts +497 -0
  510. package/Tests/Utils/Dashboard/DashboardTimeRangeZoom.test.ts +104 -1
  511. package/Tests/Utils/SessionReplay/SessionReplayBudgetMetricType.test.ts +383 -0
  512. package/Types/AI/AIRunType.ts +9 -4
  513. package/Types/AI/InvestigationNotStartedReason.ts +6 -0
  514. package/Types/AI/ResourceAiAccessApi.ts +140 -0
  515. package/Types/AI/ResourceAiAccessPermissions.ts +126 -0
  516. package/Types/AutoRemediation/AiRemediationCommandPlan.ts +197 -18
  517. package/Types/AutoRemediation/AiRemediationCommandPolicyVerdict.ts +28 -0
  518. package/Types/Dashboard/DashboardComponents/DashboardTraceChartComponent.ts +4 -2
  519. package/Types/Kubernetes/KubernetesClusterAiAccess.ts +263 -32
  520. package/Types/Kubernetes/KubernetesClusterAiAccessPermissions.ts +20 -16
  521. package/Types/Kubernetes/KubernetesClusterAiInsights.ts +81 -0
  522. package/Types/Monitor/ProxmoxAlertTemplates.ts +4 -4
  523. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +55 -22
  524. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +35 -3
  525. package/Types/Monitor/RumAlertTemplates.ts +351 -4
  526. package/Types/ResourceAiAgent/AiResourceType.ts +310 -0
  527. package/Types/ResourceAiAgent/ResourceAiAccess.ts +574 -0
  528. package/Types/Rum/SessionReplayBudgetMetricType.ts +47 -0
  529. package/Types/Runbook/RunbookStepType.ts +29 -0
  530. package/Types/Team/ComplianceNotificationChannel.ts +22 -0
  531. package/Types/Team/ComplianceRule.ts +434 -0
  532. package/Types/Team/ComplianceRuleType.ts +21 -0
  533. package/Types/Team/TeamComplianceStatus.ts +81 -0
  534. package/Types/Telemetry/TelemetryIngestSurface.ts +40 -0
  535. package/UI/Components/ActionButton/ActionButtonSchema.ts +21 -0
  536. package/UI/Components/ActionButton/RowActions.tsx +238 -0
  537. package/UI/Components/ActionButton/SplitActionButtons.ts +173 -0
  538. package/UI/Components/Charts/Area/AreaChart.tsx +32 -2
  539. package/UI/Components/Charts/Bar/BarChart.tsx +40 -3
  540. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +37 -11
  541. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +80 -243
  542. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +122 -31
  543. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +95 -244
  544. package/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.ts +214 -0
  545. package/UI/Components/Charts/ChartLibrary/Utils/UseChartBucketsChange.ts +32 -0
  546. package/UI/Components/Charts/ChartLibrary/Utils/UseChartRangeSelection.ts +599 -0
  547. package/UI/Components/Charts/Line/LineChart.tsx +32 -2
  548. package/UI/Components/Charts/TimeRangeZoom/ResetTimeRangeZoomButton.tsx +70 -0
  549. package/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.tsx +226 -0
  550. package/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.tsx +73 -0
  551. package/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.ts +163 -0
  552. package/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.ts +215 -0
  553. package/UI/Components/Charts/Utils/DataPoint.ts +20 -8
  554. package/UI/Components/Charts/Utils/XAxis.ts +75 -0
  555. package/UI/Components/Charts/Utils/useHistogramRangeSelection.ts +493 -59
  556. package/UI/Components/EntityDropdown/EntityDropdown.tsx +107 -11
  557. package/UI/Components/Forms/Fields/FormField.tsx +10 -0
  558. package/UI/Components/Input/Input.tsx +90 -57
  559. package/UI/Components/List/ListRow.tsx +8 -74
  560. package/UI/Components/LogsViewer/LogsViewer.tsx +40 -11
  561. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +22 -6
  562. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +209 -6
  563. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +127 -84
  564. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +3 -0
  565. package/UI/Components/ModelTable/BaseModelTable.tsx +12 -1
  566. package/UI/Components/MoreMenu/MoreMenu.tsx +365 -30
  567. package/UI/Components/MoreMenu/MoreMenuItem.tsx +70 -17
  568. package/UI/Components/OrderedStatesList/Item.tsx +7 -72
  569. package/UI/Components/StatusPage/ResourceGrid.tsx +126 -77
  570. package/UI/Components/Table/TableBody.tsx +8 -2
  571. package/UI/Components/Table/TableRow.tsx +20 -140
  572. package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +41 -10
  573. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +47 -9
  574. package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +25 -6
  575. package/UI/Components/TelemetryViewer/types.ts +2 -0
  576. package/UI/Components/TelemetryViewer/useViewerTimeRangeZoom.ts +448 -0
  577. package/UI/Components/Toggle/Toggle.tsx +26 -0
  578. package/UI/Components/Tooltip/Tooltip.tsx +8 -1
  579. package/UI/Styles/Theme.css +14 -2
  580. package/Utils/AI/InvestigationReport.ts +121 -15
  581. package/Utils/AiRemediation/CommandPolicy.ts +1 -1
  582. package/Utils/AiRemediation/KubectlPolicy.ts +1 -1
  583. package/Utils/AiRemediation/Resource/CephCommandPolicy.ts +1936 -0
  584. package/Utils/AiRemediation/Resource/DatabaseCommandPolicy.ts +166 -0
  585. package/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.ts +1532 -0
  586. package/Utils/AiRemediation/Resource/DatabaseQueryRedactor.ts +1394 -0
  587. package/Utils/AiRemediation/Resource/DockerCliGrammar.ts +1839 -0
  588. package/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.ts +490 -0
  589. package/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.ts +687 -0
  590. package/Utils/AiRemediation/Resource/GovcCommandPolicy.ts +2096 -0
  591. package/Utils/AiRemediation/Resource/HostCommandPolicy.ts +2982 -0
  592. package/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.ts +1679 -0
  593. package/Utils/AiRemediation/Resource/ResourceCommandPolicy.ts +851 -0
  594. package/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.ts +593 -0
  595. package/Utils/AiRemediation/Resource/ResourceOutputRedactor.ts +2812 -0
  596. package/Utils/Dashboard/DashboardTimeRangeZoom.ts +46 -2
  597. package/Utils/SessionReplay/SessionReplayBudgetMetricType.ts +184 -0
  598. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js +62 -0
  599. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js.map +1 -1
  600. package/build/dist/Models/DatabaseModels/CephCluster.js +219 -0
  601. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  602. package/build/dist/Models/DatabaseModels/DatabaseServer.js +219 -0
  603. package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -1
  604. package/build/dist/Models/DatabaseModels/DockerHost.js +219 -0
  605. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  606. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js +219 -0
  607. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js.map +1 -1
  608. package/build/dist/Models/DatabaseModels/GlobalConfig.js +1 -1
  609. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  610. package/build/dist/Models/DatabaseModels/Host.js +219 -0
  611. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  612. package/build/dist/Models/DatabaseModels/Index.js +4 -0
  613. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  614. package/build/dist/Models/DatabaseModels/KubernetesAiAgent.js +385 -0
  615. package/build/dist/Models/DatabaseModels/KubernetesAiAgent.js.map +1 -0
  616. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +3 -3
  617. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  618. package/build/dist/Models/DatabaseModels/PodmanHost.js +219 -0
  619. package/build/dist/Models/DatabaseModels/PodmanHost.js.map +1 -1
  620. package/build/dist/Models/DatabaseModels/Project.js +56 -5
  621. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  622. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +219 -0
  623. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  624. package/build/dist/Models/DatabaseModels/ProxmoxResource.js +51 -1
  625. package/build/dist/Models/DatabaseModels/ProxmoxResource.js.map +1 -1
  626. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js +439 -0
  627. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js.map +1 -0
  628. package/build/dist/Models/DatabaseModels/RunnerJob.js +219 -0
  629. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  630. package/build/dist/Models/DatabaseModels/TeamComplianceSetting.js +134 -3
  631. package/build/dist/Models/DatabaseModels/TeamComplianceSetting.js.map +1 -1
  632. package/build/dist/Models/DatabaseModels/VMwareVCenter.js +219 -0
  633. package/build/dist/Models/DatabaseModels/VMwareVCenter.js.map +1 -1
  634. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  635. package/build/dist/Server/API/AIChatAPI.js +6 -1
  636. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  637. package/build/dist/Server/API/AIInvestigationAPI.js +17 -10
  638. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
  639. package/build/dist/Server/API/AutoRemediationAPI.js +374 -24
  640. package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
  641. package/build/dist/Server/API/KubernetesClusterAiAccessAPI.js +436 -22
  642. package/build/dist/Server/API/KubernetesClusterAiAccessAPI.js.map +1 -1
  643. package/build/dist/Server/API/MicrosoftTeamsAPI.js +69 -13
  644. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  645. package/build/dist/Server/API/ProxmoxResourceAPI.js +187 -14
  646. package/build/dist/Server/API/ProxmoxResourceAPI.js.map +1 -1
  647. package/build/dist/Server/API/ResourceAiAccessAPI.js +1119 -0
  648. package/build/dist/Server/API/ResourceAiAccessAPI.js.map +1 -0
  649. package/build/dist/Server/Infrastructure/GlobalCache.js +27 -0
  650. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  651. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796000000000-AddTeamComplianceRuleScope.js +50 -0
  652. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796000000000-AddTeamComplianceRuleScope.js.map +1 -0
  653. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796100000000-AddKubernetesAiAgentAndAiDefaults.js +84 -0
  654. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796100000000-AddKubernetesAiAgentAndAiDefaults.js.map +1 -0
  655. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796200000000-AddProxmoxResourceNativePushColumns.js +31 -0
  656. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796200000000-AddProxmoxResourceNativePushColumns.js.map +1 -0
  657. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js +168 -0
  658. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js.map +1 -0
  659. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  660. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  661. package/build/dist/Server/Infrastructure/Semaphore.js +6 -0
  662. package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
  663. package/build/dist/Server/Middleware/TelemetryIngest.js +41 -19
  664. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  665. package/build/dist/Server/Services/AIService.js +59 -1
  666. package/build/dist/Server/Services/AIService.js.map +1 -1
  667. package/build/dist/Server/Services/AlertService.js +248 -13
  668. package/build/dist/Server/Services/AlertService.js.map +1 -1
  669. package/build/dist/Server/Services/AlertSeverityService.js +51 -15
  670. package/build/dist/Server/Services/AlertSeverityService.js.map +1 -1
  671. package/build/dist/Server/Services/AnalyticsDatabaseService.js +26 -1
  672. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  673. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +601 -3
  674. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
  675. package/build/dist/Server/Services/AutoRemediationRuleService.js +1 -1
  676. package/build/dist/Server/Services/AutoRemediationRuleService.js.map +1 -1
  677. package/build/dist/Server/Services/CephClusterService.js +104 -0
  678. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  679. package/build/dist/Server/Services/DatabaseServerService.js +99 -0
  680. package/build/dist/Server/Services/DatabaseServerService.js.map +1 -1
  681. package/build/dist/Server/Services/DockerHostService.js +104 -0
  682. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  683. package/build/dist/Server/Services/DockerSwarmClusterService.js +104 -0
  684. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  685. package/build/dist/Server/Services/HostService.js +114 -2
  686. package/build/dist/Server/Services/HostService.js.map +1 -1
  687. package/build/dist/Server/Services/IncidentService.js +199 -72
  688. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  689. package/build/dist/Server/Services/IncidentSeverityService.js +51 -15
  690. package/build/dist/Server/Services/IncidentSeverityService.js.map +1 -1
  691. package/build/dist/Server/Services/KubernetesAiAgentJobService.js +158 -0
  692. package/build/dist/Server/Services/KubernetesAiAgentJobService.js.map +1 -0
  693. package/build/dist/Server/Services/KubernetesAiAgentService.js +1145 -0
  694. package/build/dist/Server/Services/KubernetesAiAgentService.js.map +1 -0
  695. package/build/dist/Server/Services/KubernetesClusterAiAccessService.js +842 -371
  696. package/build/dist/Server/Services/KubernetesClusterAiAccessService.js.map +1 -1
  697. package/build/dist/Server/Services/KubernetesClusterService.js +86 -17
  698. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  699. package/build/dist/Server/Services/MetricRecordingRuleService.js +46 -0
  700. package/build/dist/Server/Services/MetricRecordingRuleService.js.map +1 -1
  701. package/build/dist/Server/Services/PodmanHostService.js +104 -0
  702. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  703. package/build/dist/Server/Services/ProjectService.js +22 -0
  704. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  705. package/build/dist/Server/Services/ProxmoxClusterService.js +104 -0
  706. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  707. package/build/dist/Server/Services/ProxmoxResourceService.js +258 -3
  708. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  709. package/build/dist/Server/Services/ResourceAiAccessService.js +1028 -0
  710. package/build/dist/Server/Services/ResourceAiAccessService.js.map +1 -0
  711. package/build/dist/Server/Services/ResourceAiAgentJobService.js +173 -0
  712. package/build/dist/Server/Services/ResourceAiAgentJobService.js.map +1 -0
  713. package/build/dist/Server/Services/ResourceAiAgentService.js +1687 -0
  714. package/build/dist/Server/Services/ResourceAiAgentService.js.map +1 -0
  715. package/build/dist/Server/Services/RunnerJobService.js +522 -97
  716. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  717. package/build/dist/Server/Services/RunnerService.js +1 -1
  718. package/build/dist/Server/Services/RunnerService.js.map +1 -1
  719. package/build/dist/Server/Services/ScheduledMaintenanceService.js +535 -3
  720. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  721. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js +316 -46
  722. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js.map +1 -1
  723. package/build/dist/Server/Services/TeamComplianceSettingService.js +833 -7
  724. package/build/dist/Server/Services/TeamComplianceSettingService.js.map +1 -1
  725. package/build/dist/Server/Services/TelemetryUsageBillingService.js +26 -0
  726. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  727. package/build/dist/Server/Services/TraceRecordingRuleService.js +37 -0
  728. package/build/dist/Server/Services/TraceRecordingRuleService.js.map +1 -1
  729. package/build/dist/Server/Services/VMwareVCenterService.js +104 -0
  730. package/build/dist/Server/Services/VMwareVCenterService.js.map +1 -1
  731. package/build/dist/Server/Utils/AI/ClusterAccess/ClusterAccessContext.js +52 -9
  732. package/build/dist/Server/Utils/AI/ClusterAccess/ClusterAccessContext.js.map +1 -1
  733. package/build/dist/Server/Utils/AI/ClusterAccess/KubectlInvestigationToolkit.js +13 -9
  734. package/build/dist/Server/Utils/AI/ClusterAccess/KubectlInvestigationToolkit.js.map +1 -1
  735. package/build/dist/Server/Utils/AI/ClusterAccess/KubectlJobRunner.js +7 -3
  736. package/build/dist/Server/Utils/AI/ClusterAccess/KubectlJobRunner.js.map +1 -1
  737. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js +1056 -43
  738. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js.map +1 -1
  739. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js +683 -42
  740. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js.map +1 -1
  741. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js +21 -1
  742. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js.map +1 -1
  743. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js +330 -0
  744. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js.map +1 -0
  745. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js +160 -0
  746. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js.map +1 -0
  747. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js +33 -0
  748. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js.map +1 -0
  749. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js +640 -0
  750. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js.map +1 -0
  751. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js +226 -0
  752. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js.map +1 -0
  753. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js +600 -0
  754. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js.map +1 -0
  755. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +89 -9
  756. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  757. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +55 -4
  758. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
  759. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +55 -4
  760. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js.map +1 -1
  761. package/build/dist/Server/Utils/AI/SRE/IncidentPostmortemRunner.js +30 -3
  762. package/build/dist/Server/Utils/AI/SRE/IncidentPostmortemRunner.js.map +1 -1
  763. package/build/dist/Server/Utils/AI/SRE/InvestigationEligibility.js +13 -2
  764. package/build/dist/Server/Utils/AI/SRE/InvestigationEligibility.js.map +1 -1
  765. package/build/dist/Server/Utils/AI/SRE/InvestigationGrader.js +1 -1
  766. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js +294 -15
  767. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js.map +1 -1
  768. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js +29 -1
  769. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js.map +1 -1
  770. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +9 -1
  771. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  772. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js +627 -0
  773. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js.map +1 -0
  774. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js +60 -5
  775. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js.map +1 -1
  776. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +111 -6
  777. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  778. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js +40 -4
  779. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js.map +1 -1
  780. package/build/dist/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.js +296 -0
  781. package/build/dist/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.js.map +1 -0
  782. package/build/dist/Server/Utils/Telemetry/ProxmoxNativePush.js +699 -0
  783. package/build/dist/Server/Utils/Telemetry/ProxmoxNativePush.js.map +1 -0
  784. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +77 -19
  785. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  786. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +19 -15
  787. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  788. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js +19 -15
  789. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js.map +1 -1
  790. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +17 -1
  791. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  792. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +183 -209
  793. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  794. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js +19 -15
  795. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js.map +1 -1
  796. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js +233 -167
  797. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js.map +1 -1
  798. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +768 -152
  799. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  800. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js +129 -0
  801. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js.map +1 -0
  802. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js +266 -0
  803. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js.map +1 -0
  804. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js +258 -0
  805. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js.map +1 -0
  806. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js +104 -0
  807. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js.map +1 -0
  808. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js +183 -0
  809. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js.map +1 -0
  810. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js +123 -0
  811. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js.map +1 -0
  812. package/build/dist/Types/AI/AIRunType.js +9 -4
  813. package/build/dist/Types/AI/AIRunType.js.map +1 -1
  814. package/build/dist/Types/AI/ResourceAiAccessApi.js +30 -0
  815. package/build/dist/Types/AI/ResourceAiAccessApi.js.map +1 -0
  816. package/build/dist/Types/AI/ResourceAiAccessPermissions.js +98 -0
  817. package/build/dist/Types/AI/ResourceAiAccessPermissions.js.map +1 -0
  818. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js +126 -45
  819. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js.map +1 -1
  820. package/build/dist/Types/AutoRemediation/AiRemediationCommandPolicyVerdict.js +28 -0
  821. package/build/dist/Types/AutoRemediation/AiRemediationCommandPolicyVerdict.js.map +1 -0
  822. package/build/dist/Types/Kubernetes/KubernetesClusterAiAccess.js +72 -16
  823. package/build/dist/Types/Kubernetes/KubernetesClusterAiAccess.js.map +1 -1
  824. package/build/dist/Types/Kubernetes/KubernetesClusterAiAccessPermissions.js +20 -16
  825. package/build/dist/Types/Kubernetes/KubernetesClusterAiAccessPermissions.js.map +1 -1
  826. package/build/dist/Types/Kubernetes/KubernetesClusterAiInsights.js +17 -0
  827. package/build/dist/Types/Kubernetes/KubernetesClusterAiInsights.js.map +1 -0
  828. package/build/dist/Types/Monitor/ProxmoxAlertTemplates.js +4 -4
  829. package/build/dist/Types/Monitor/ProxmoxAlertTemplates.js.map +1 -1
  830. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +47 -21
  831. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  832. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +5 -0
  833. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  834. package/build/dist/Types/Monitor/RumAlertTemplates.js +199 -3
  835. package/build/dist/Types/Monitor/RumAlertTemplates.js.map +1 -1
  836. package/build/dist/Types/ResourceAiAgent/AiResourceType.js +242 -0
  837. package/build/dist/Types/ResourceAiAgent/AiResourceType.js.map +1 -0
  838. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js +275 -0
  839. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js.map +1 -0
  840. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js +45 -0
  841. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js.map +1 -0
  842. package/build/dist/Types/Runbook/RunbookStepType.js +27 -0
  843. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  844. package/build/dist/Types/Team/ComplianceNotificationChannel.js +23 -0
  845. package/build/dist/Types/Team/ComplianceNotificationChannel.js.map +1 -0
  846. package/build/dist/Types/Team/ComplianceRule.js +304 -0
  847. package/build/dist/Types/Team/ComplianceRule.js.map +1 -0
  848. package/build/dist/Types/Team/ComplianceRuleType.js +21 -0
  849. package/build/dist/Types/Team/ComplianceRuleType.js.map +1 -1
  850. package/build/dist/Types/Team/TeamComplianceStatus.js +2 -0
  851. package/build/dist/Types/Team/TeamComplianceStatus.js.map +1 -0
  852. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js +36 -0
  853. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js.map +1 -1
  854. package/build/dist/UI/Components/ActionButton/ActionButtonSchema.js +20 -1
  855. package/build/dist/UI/Components/ActionButton/ActionButtonSchema.js.map +1 -1
  856. package/build/dist/UI/Components/ActionButton/RowActions.js +113 -0
  857. package/build/dist/UI/Components/ActionButton/RowActions.js.map +1 -0
  858. package/build/dist/UI/Components/ActionButton/SplitActionButtons.js +71 -0
  859. package/build/dist/UI/Components/ActionButton/SplitActionButtons.js.map +1 -0
  860. package/build/dist/UI/Components/Charts/Area/AreaChart.js +16 -1
  861. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  862. package/build/dist/UI/Components/Charts/Bar/BarChart.js +16 -1
  863. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  864. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +27 -5
  865. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  866. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +61 -194
  867. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  868. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +84 -26
  869. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  870. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +75 -196
  871. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  872. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js +133 -0
  873. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js.map +1 -1
  874. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartBucketsChange.js +26 -0
  875. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartBucketsChange.js.map +1 -0
  876. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartRangeSelection.js +371 -0
  877. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartRangeSelection.js.map +1 -0
  878. package/build/dist/UI/Components/Charts/Line/LineChart.js +16 -1
  879. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  880. package/build/dist/UI/Components/Charts/TimeRangeZoom/ResetTimeRangeZoomButton.js +35 -0
  881. package/build/dist/UI/Components/Charts/TimeRangeZoom/ResetTimeRangeZoomButton.js.map +1 -0
  882. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.js +107 -0
  883. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.js.map +1 -0
  884. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.js +41 -0
  885. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.js.map +1 -0
  886. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.js +117 -0
  887. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.js.map +1 -0
  888. package/build/dist/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.js +121 -0
  889. package/build/dist/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.js.map +1 -0
  890. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +15 -7
  891. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  892. package/build/dist/UI/Components/Charts/Utils/XAxis.js +70 -0
  893. package/build/dist/UI/Components/Charts/Utils/XAxis.js.map +1 -1
  894. package/build/dist/UI/Components/Charts/Utils/useHistogramRangeSelection.js +305 -38
  895. package/build/dist/UI/Components/Charts/Utils/useHistogramRangeSelection.js.map +1 -1
  896. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js +89 -14
  897. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js.map +1 -1
  898. package/build/dist/UI/Components/Forms/Fields/FormField.js +11 -2
  899. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  900. package/build/dist/UI/Components/Input/Input.js +69 -54
  901. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  902. package/build/dist/UI/Components/List/ListRow.js +4 -39
  903. package/build/dist/UI/Components/List/ListRow.js.map +1 -1
  904. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +31 -8
  905. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  906. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +10 -1
  907. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
  908. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +139 -21
  909. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  910. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +30 -8
  911. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
  912. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +1 -1
  913. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  914. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +10 -0
  915. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  916. package/build/dist/UI/Components/MoreMenu/MoreMenu.js +253 -14
  917. package/build/dist/UI/Components/MoreMenu/MoreMenu.js.map +1 -1
  918. package/build/dist/UI/Components/MoreMenu/MoreMenuItem.js +43 -13
  919. package/build/dist/UI/Components/MoreMenu/MoreMenuItem.js.map +1 -1
  920. package/build/dist/UI/Components/OrderedStatesList/Item.js +2 -38
  921. package/build/dist/UI/Components/OrderedStatesList/Item.js.map +1 -1
  922. package/build/dist/UI/Components/StatusPage/ResourceGrid.js +53 -15
  923. package/build/dist/UI/Components/StatusPage/ResourceGrid.js.map +1 -1
  924. package/build/dist/UI/Components/Table/TableBody.js +6 -2
  925. package/build/dist/UI/Components/Table/TableBody.js.map +1 -1
  926. package/build/dist/UI/Components/Table/TableRow.js +12 -75
  927. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  928. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +32 -7
  929. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
  930. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +27 -9
  931. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  932. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +11 -1
  933. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
  934. package/build/dist/UI/Components/TelemetryViewer/useViewerTimeRangeZoom.js +272 -0
  935. package/build/dist/UI/Components/TelemetryViewer/useViewerTimeRangeZoom.js.map +1 -0
  936. package/build/dist/UI/Components/Toggle/Toggle.js +14 -1
  937. package/build/dist/UI/Components/Toggle/Toggle.js.map +1 -1
  938. package/build/dist/UI/Components/Tooltip/Tooltip.js +1 -1
  939. package/build/dist/UI/Components/Tooltip/Tooltip.js.map +1 -1
  940. package/build/dist/Utils/AI/InvestigationReport.js +71 -11
  941. package/build/dist/Utils/AI/InvestigationReport.js.map +1 -1
  942. package/build/dist/Utils/AiRemediation/CommandPolicy.js +1 -1
  943. package/build/dist/Utils/AiRemediation/CommandPolicy.js.map +1 -1
  944. package/build/dist/Utils/AiRemediation/KubectlPolicy.js +1 -1
  945. package/build/dist/Utils/AiRemediation/KubectlPolicy.js.map +1 -1
  946. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js +1462 -0
  947. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js.map +1 -0
  948. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js +122 -0
  949. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js.map +1 -0
  950. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js +1139 -0
  951. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js.map +1 -0
  952. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js +1060 -0
  953. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js.map +1 -0
  954. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js +1301 -0
  955. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js.map +1 -0
  956. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js +370 -0
  957. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js.map +1 -0
  958. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js +498 -0
  959. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js.map +1 -0
  960. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js +1565 -0
  961. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js.map +1 -0
  962. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js +2206 -0
  963. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js.map +1 -0
  964. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js +1129 -0
  965. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js.map +1 -0
  966. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js +565 -0
  967. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js.map +1 -0
  968. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js +408 -0
  969. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js.map +1 -0
  970. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js +1910 -0
  971. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js.map +1 -0
  972. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js +27 -1
  973. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js.map +1 -1
  974. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js +160 -0
  975. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js.map +1 -0
  976. package/package.json +1 -1
@@ -17,27 +17,38 @@ import {
17
17
  within,
18
18
  } from "@testing-library/react";
19
19
  import React from "react";
20
- import { MemoryRouter, Route as PageRoute, Routes } from "react-router-dom";
21
- import KubernetesClusterAI, {
22
- AI_ACCESS_STATUS_POLL_INTERVAL_MS,
23
- KUBERNETES_AGENT_HELM_NAMESPACE,
24
- KUBERNETES_AGENT_HELM_RELEASE,
20
+ import {
21
+ MemoryRouter,
22
+ Route as PageRoute,
23
+ Routes,
24
+ useLocation,
25
+ } from "react-router-dom";
26
+ import KubernetesClusterAiAgent, {
27
+ KubernetesClusterViewAiRedirect,
28
+ } from "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/AI/Agent";
29
+ import {
30
+ AI_AGENT_GONE_TEXT,
31
+ AI_AGENT_PAGE_SUBTITLE,
32
+ AI_AGENT_PAGE_TITLE,
33
+ AI_AGENT_READY_TEXT,
34
+ AI_AGENT_SIGNED_OFF_TEXT,
35
+ AI_AGENT_SILENT_TEXT,
36
+ AI_AGENT_STATUS_POLL_INTERVAL_MS,
37
+ } from "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesAiAgentStatus";
38
+ import {
39
+ getAiAgentHelmCommands,
40
+ getAiAgentWriteDisclosure,
41
+ } from "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesAiAccessSetup";
42
+ import {
25
43
  REMEDIATION_MODE_LABELS,
26
- canPickKubernetesCredential,
27
- getAiAccessClusterWideCommandNote,
28
- getAiAccessHelmCommands,
29
- getAiAccessScopedCommandNote,
30
- getKubernetesCredentialPermissionTitles,
31
- getRemediationModeFieldDescription,
32
- parseStatus,
33
- } from "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/AI";
34
- import { getKubernetesInstallationMarkdown } from "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/DocumentationMarkdown";
44
+ REMEDIATION_MODE_SUMMARIES,
45
+ } from "../../../../App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/KubernetesAiAccessSettings";
35
46
  import PageMap from "../../../../App/FeatureSet/Dashboard/src/Utils/PageMap";
36
47
  import RouteMap from "../../../../App/FeatureSet/Dashboard/src/Utils/RouteMap";
37
48
  import KubernetesCluster from "../../../Models/DatabaseModels/KubernetesCluster";
49
+ import Project from "../../../Models/DatabaseModels/Project";
38
50
  import RunbookCredential from "../../../Models/DatabaseModels/RunbookCredential";
39
51
  import Runner from "../../../Models/DatabaseModels/Runner";
40
- import RunnerJob from "../../../Models/DatabaseModels/RunnerJob";
41
52
  import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
42
53
  import HTTPResponse from "../../../Types/API/HTTPResponse";
43
54
  import Route from "../../../Types/API/Route";
@@ -45,15 +56,15 @@ import ListResult from "../../../Types/BaseDatabase/ListResult";
45
56
  import { JSONObject } from "../../../Types/JSON";
46
57
  import {
47
58
  KubernetesAiAccessGap,
59
+ KubernetesAiAccessGapCode,
60
+ KubernetesAiAgentSummary,
48
61
  KubernetesAiRemediationMode,
49
62
  KubernetesClusterAiAccessStatus,
63
+ KUBERNETES_AI_AGENT_DISPLAY_NAME,
50
64
  } from "../../../Types/Kubernetes/KubernetesClusterAiAccess";
51
65
  import ObjectID from "../../../Types/ObjectID";
52
66
  import Permission from "../../../Types/Permission";
53
67
  import RunbookCredentialType from "../../../Types/Runbook/RunbookCredentialType";
54
- import RunbookStepType from "../../../Types/Runbook/RunbookStepType";
55
- import RunnerJobOrigin from "../../../Types/Runbook/RunnerJobOrigin";
56
- import RunnerJobStatus from "../../../Types/Runbook/RunnerJobStatus";
57
68
  import API from "../../../UI/Utils/API/API";
58
69
  import ModelAPI from "../../../UI/Utils/ModelAPI/ModelAPI";
59
70
  import PermissionUtil from "../../../UI/Utils/Permission";
@@ -73,16 +84,15 @@ jest.mock("react-i18next", () => {
73
84
  });
74
85
 
75
86
  /*
76
- * The cluster's AI page renders for real on its real route, with the
77
- * status endpoint, the model API and the permission snapshot stubbed. The
78
- * page is one screen made of pieces that each failed in review in a
79
- * different way: the summary tile read a status field that does not exist,
80
- * a background poll wiped the whole page, the commands table asked for the
81
- * wrong columns (and lacked the ones ModelTable requires), the edit modal
82
- * loaded Runner and credential lists its users were not allowed to read
83
- * and let any cluster editor switch on unattended fixes, the helm command
84
- * named another release, and the test button and the command history
85
- * failed for roles that may open the page.
87
+ * The cluster's AI agent page (AI → Agent) renders for real on its real
88
+ * route, with the status, test and reset-agent routes, the model API and
89
+ * the permission snapshot stubbed. It is three cards at most: the
90
+ * "Kubernetes AI agent" card in each of its states (connected, offline,
91
+ * not installed, the previous in-cluster Runner, an advanced Runner),
92
+ * "Needs attention" only when the server has gaps, and "What AI may do"
93
+ * with its Change modal — whose loosening rules, confirmations and
94
+ * advanced-binding pickers carry over from the old AI page, with Off ->
95
+ * enabled now needing the admin set.
86
96
  */
87
97
 
88
98
  // Real components fetch; give the waits room on a loaded CI box.
@@ -91,17 +101,22 @@ const WAIT_TIMEOUT: number = 20000;
91
101
  const CLUSTER_ID: ObjectID = new ObjectID(
92
102
  "44444444-0000-4000-8000-000000000004",
93
103
  );
94
- const RUNNER_ID: string = "55555555-0000-4000-8000-000000000005";
95
- const RUN_ID: string = "66666666-0000-4000-8000-000000000006";
104
+ const AGENT_ID: string = "99999999-0000-4000-8000-000000000009";
105
+ const LEGACY_RUNNER_ID: string = "55555555-0000-4000-8000-000000000005";
106
+ const HOST_RUNNER_ID: string = "55555555-0000-4000-8000-00000000000f";
107
+ const DISABLED_RUNNER_ID: string = "55555555-0000-4000-8000-000000000010";
96
108
  const CREDENTIAL_ID: string = "77777777-0000-4000-8000-000000000007";
97
- const AI_PAGE_PATH: string = `/dashboard/${PROJECT_ID}/kubernetes/${CLUSTER_ID.toString()}/ai`;
109
+ const STAGING_CREDENTIAL_ID: string = "77777777-0000-4000-8000-000000000009";
110
+ const AGENT_PAGE_PATH: string = `/dashboard/${PROJECT_ID}/kubernetes/${CLUSTER_ID.toString()}/ai/agent`;
111
+ const OLD_AI_PAGE_PATH: string = `/dashboard/${PROJECT_ID}/kubernetes/${CLUSTER_ID.toString()}/ai`;
112
+
113
+ const AGENT_CARD_TITLE: string = "Kubernetes AI agent";
114
+ const SETTINGS_CARD_TITLE: string = "What AI may do";
115
+ const GAPS_CARD_TITLE: string = "Needs attention";
98
116
 
99
- const INVESTIGATION_ON_TEXT: string =
100
- "AI runs read-only kubectl (get, describe, logs, events, top) while investigating.";
101
- const INVESTIGATION_OFF_TEXT: string =
102
- "Off — AI investigates with OneUptime data only.";
103
- const STATUS_CARD_TITLE: string = "OneUptime AI access to this cluster";
104
- const SETTINGS_CARD_TITLE: string = "AI access settings";
117
+ const STATUS_ROUTE: string = "/kubernetes-cluster/ai-access/status";
118
+ const TEST_ROUTE: string = "/kubernetes-cluster/ai-access/test";
119
+ const RESET_ROUTE: string = "/kubernetes-cluster/ai-access/reset-agent";
105
120
 
106
121
  const BASE_PERMISSIONS: Array<Permission> = [
107
122
  Permission.Public,
@@ -114,55 +129,169 @@ const MEMBER_PERMISSIONS: Array<Permission> = [
114
129
  Permission.ProjectMember,
115
130
  ];
116
131
 
117
- // May loosen AI access (unattended modes, allowlist, bindings) and read everything.
132
+ // May loosen AI access, reset the agent and change project AI settings.
118
133
  const ADMIN_PERMISSIONS: Array<Permission> = [
119
134
  ...BASE_PERMISSIONS,
120
135
  Permission.ProjectAdmin,
121
136
  ];
122
137
 
138
+ const READER_PERMISSIONS: Array<Permission> = [
139
+ ...BASE_PERMISSIONS,
140
+ Permission.ReadKubernetesCluster,
141
+ ];
142
+
123
143
  const SET_IMAGE_PATTERN: string = "kubectl set image deployment/web * -n web";
124
144
  const PATCH_PATTERN: string = "kubectl patch deployment/web -n web -p *";
145
+ const BROAD_PATTERN: string = "kubectl delete deployment * -n *";
146
+
147
+ function makeAgent(
148
+ overrides: Partial<KubernetesAiAgentSummary> = {},
149
+ ): KubernetesAiAgentSummary {
150
+ return {
151
+ id: AGENT_ID,
152
+ isOnline: true,
153
+ connectionStatus: "connected",
154
+ lastAliveAt: new Date().toISOString(),
155
+ agentVersion: "14.1.0",
156
+ posture: {
157
+ clusterIdentifier: "prod-east",
158
+ inCluster: true,
159
+ allowWrites: false,
160
+ writeNamespaces: [],
161
+ podNamespace: "monitoring",
162
+ kubectlVersion: "v1.31.2",
163
+ },
164
+ ...overrides,
165
+ };
166
+ }
167
+
168
+ function minutesAgo(minutes: number): string {
169
+ return new Date(Date.now() - minutes * 60 * 1000).toISOString();
170
+ }
171
+
125
172
  /*
126
- * A broad but valid allowlist entry: deleting any Deployment in any
127
- * namespace. These tests used "kubectl * * * -n *" until KubectlPolicy made
128
- * a wildcard verb invalid; the form now refuses that entry outright.
173
+ * The agent offline the three ways the server's isOnline rule allows:
174
+ * heartbeats stopped (still "connected", last seen past the alive window),
175
+ * signed off or reset moments ago (a helm upgrade, "Reset agent"), and
176
+ * signed off long ago without coming back.
129
177
  */
130
- const BROAD_PATTERN: string = "kubectl delete deployment * -n *";
131
- const OTHER_CLUSTER_RUNNER_ID: string = "55555555-0000-4000-8000-00000000000e";
132
- const HOST_RUNNER_ID: string = "55555555-0000-4000-8000-00000000000f";
133
- const DISABLED_RUNNER_ID: string = "55555555-0000-4000-8000-000000000010";
134
- const RENAMED_AGENT_RUNNER_ID: string = "55555555-0000-4000-8000-000000000011";
135
- const SSH_CREDENTIAL_ID: string = "77777777-0000-4000-8000-000000000008";
136
- const STAGING_CREDENTIAL_ID: string = "77777777-0000-4000-8000-000000000009";
178
+ function silentAgent(): KubernetesAiAgentSummary {
179
+ return makeAgent({ isOnline: false, lastAliveAt: minutesAgo(12) });
180
+ }
181
+
182
+ function signedOffAgent(): KubernetesAiAgentSummary {
183
+ return makeAgent({
184
+ isOnline: false,
185
+ connectionStatus: "disconnected",
186
+ lastAliveAt: new Date().toISOString(),
187
+ });
188
+ }
189
+
190
+ function goneAgent(): KubernetesAiAgentSummary {
191
+ return makeAgent({
192
+ isOnline: false,
193
+ connectionStatus: "disconnected",
194
+ lastAliveAt: minutesAgo(45),
195
+ });
196
+ }
137
197
 
198
+ // The resolved target is the cluster's Kubernetes AI agent.
138
199
  function makeStatus(
139
200
  overrides: Partial<KubernetesClusterAiAccessStatus> = {},
201
+ agent: KubernetesAiAgentSummary = makeAgent(),
140
202
  ): KubernetesClusterAiAccessStatus {
141
203
  return {
142
204
  clusterId: CLUSTER_ID.toString(),
143
205
  clusterName: "prod-east",
144
206
  clusterIdentifier: "prod-east",
145
207
  runner: {
146
- id: RUNNER_ID,
208
+ id: agent.id,
209
+ name: KUBERNETES_AI_AGENT_DISPLAY_NAME,
210
+ kind: "ai_agent",
211
+ isOnline: agent.isOnline,
212
+ lastAliveAt: agent.lastAliveAt,
213
+ canRunAiCommands: true,
214
+ posture: agent.posture,
215
+ },
216
+ accessMethod: "in_cluster",
217
+ aiAgent: agent,
218
+ automaticInvestigation: { incidents: true, alerts: true },
219
+ kubectlAllowlist: [],
220
+ isInvestigationEnabled: true,
221
+ isInvestigationReady: true,
222
+ remediationMode: KubernetesAiRemediationMode.Disabled,
223
+ isRemediationReady: false,
224
+ gaps: [gap("remediation_disabled", "remediation")],
225
+ evaluatedAt: new Date().toISOString(),
226
+ ...overrides,
227
+ };
228
+ }
229
+
230
+ function notInstalledStatus(
231
+ overrides: Partial<KubernetesClusterAiAccessStatus> = {},
232
+ ): KubernetesClusterAiAccessStatus {
233
+ return makeStatus({
234
+ runner: null,
235
+ aiAgent: null,
236
+ accessMethod: "none",
237
+ isInvestigationReady: false,
238
+ gaps: [gap("ai_agent_not_connected")],
239
+ ...overrides,
240
+ });
241
+ }
242
+
243
+ function legacyStatus(
244
+ overrides: Partial<KubernetesClusterAiAccessStatus> = {},
245
+ ): KubernetesClusterAiAccessStatus {
246
+ return makeStatus({
247
+ runner: {
248
+ id: LEGACY_RUNNER_ID,
147
249
  name: "kubernetes-agent/prod-east",
148
250
  isOnline: true,
149
- lastAliveAt: "2026-09-22T10:00:00.000Z",
251
+ lastAliveAt: new Date().toISOString(),
150
252
  canRunAiCommands: true,
151
253
  posture: {
254
+ clusterIdentifier: "prod-east",
152
255
  inCluster: true,
153
256
  allowWrites: false,
154
- kubectlVersion: "v1.31.0",
257
+ kubectlVersion: "v1.30.0",
155
258
  },
156
259
  },
157
- accessMethod: "in_cluster",
158
- kubectlAllowlist: [],
159
- isInvestigationEnabled: true,
160
- isInvestigationReady: true,
161
- remediationMode: KubernetesAiRemediationMode.RequireApproval,
162
- isRemediationReady: true,
163
- gaps: [],
164
- evaluatedAt: "2026-09-22T10:00:30.000Z",
260
+ aiAgent: null,
261
+ ...overrides,
262
+ });
263
+ }
264
+
265
+ function advancedStatus(
266
+ overrides: Partial<KubernetesClusterAiAccessStatus> = {},
267
+ ): KubernetesClusterAiAccessStatus {
268
+ return makeStatus({
269
+ runner: {
270
+ id: HOST_RUNNER_ID,
271
+ name: "bash-runner",
272
+ kind: "runner",
273
+ isOnline: true,
274
+ lastAliveAt: new Date().toISOString(),
275
+ canRunAiCommands: true,
276
+ posture: { inCluster: false, kubectlVersion: "v1.29.1" },
277
+ },
278
+ accessMethod: "credential",
279
+ credentialId: CREDENTIAL_ID,
280
+ credentialName: "prod-east token",
165
281
  ...overrides,
282
+ });
283
+ }
284
+
285
+ function gap(
286
+ code: KubernetesAiAccessGapCode,
287
+ blocks: KubernetesAiAccessGap["blocks"] = "both",
288
+ ): KubernetesAiAccessGap {
289
+ return {
290
+ code,
291
+ title: `Title of ${code}`,
292
+ description: `Description of ${code}`,
293
+ nextStep: `Next step for ${code}`,
294
+ blocks,
166
295
  };
167
296
  }
168
297
 
@@ -176,11 +305,8 @@ type ClusterOverrides = {
176
305
  [Key in keyof KubernetesCluster]?: KubernetesCluster[Key] | unknown;
177
306
  };
178
307
 
308
+ // The saved settings the Change modal reads: reached through the agent.
179
309
  function makeCluster(overrides: ClusterOverrides = {}): KubernetesCluster {
180
- const runner: Runner = Object.assign(new Runner(), {
181
- _id: RUNNER_ID,
182
- name: "kubernetes-agent/prod-east",
183
- });
184
310
  return Object.assign(
185
311
  new KubernetesCluster(),
186
312
  {
@@ -188,13 +314,29 @@ function makeCluster(overrides: ClusterOverrides = {}): KubernetesCluster {
188
314
  isAiInvestigationEnabled: true,
189
315
  aiRemediationMode: KubernetesAiRemediationMode.RequireApproval,
190
316
  aiKubectlCommandAllowlist: [],
191
- aiAccessRunner: runner,
192
317
  },
193
318
  overrides,
194
319
  );
195
320
  }
196
321
 
197
- // The posture an in-cluster agent Runner reports for the cluster it runs in.
322
+ // The cluster bound to the host Runner (outside the cluster), with a credential.
323
+ function hostRunnerBinding(withCredential: boolean = true): ClusterOverrides {
324
+ return {
325
+ aiAccessRunner: Object.assign(new Runner(), {
326
+ _id: HOST_RUNNER_ID,
327
+ name: "bash-runner",
328
+ }),
329
+ ...(withCredential
330
+ ? {
331
+ aiAccessCredential: Object.assign(new RunbookCredential(), {
332
+ _id: CREDENTIAL_ID,
333
+ name: "prod-east token",
334
+ }),
335
+ }
336
+ : {}),
337
+ };
338
+ }
339
+
198
340
  function agentHostInfo(clusterIdentifier: string): JSONObject {
199
341
  return {
200
342
  kubernetes: { inCluster: true, allowWrites: false, clusterIdentifier },
@@ -202,24 +344,21 @@ function agentHostInfo(clusterIdentifier: string): JSONObject {
202
344
  }
203
345
 
204
346
  /*
205
- * Every kind of Runner a project holds: this cluster's agent, another
206
- * cluster's agent, a host Runner that runs AI commands, one that does not,
207
- * and another cluster's agent whose name lost the "kubernetes-agent/"
208
- * marker (a rename from before the server refused one) — an agent by its
209
- * posture, the server's rule. Only the first and third can serve this
210
- * cluster. The agents report the cluster they run in, which is how the
211
- * picker tells them apart.
347
+ * Every kind of Runner a project holds: this cluster's previous agent
348
+ * Runner, another cluster's, a host Runner that runs AI commands and one
349
+ * that does not. Only the host Runner can be picked for an advanced
350
+ * binding.
212
351
  */
213
352
  function makeProjectRunners(): Array<Runner> {
214
353
  return [
215
354
  Object.assign(new Runner(), {
216
- _id: RUNNER_ID,
355
+ _id: LEGACY_RUNNER_ID,
217
356
  name: "kubernetes-agent/prod-east",
218
357
  canRunAiCommands: true,
219
358
  hostInfo: agentHostInfo("prod-east"),
220
359
  }),
221
360
  Object.assign(new Runner(), {
222
- _id: OTHER_CLUSTER_RUNNER_ID,
361
+ _id: "55555555-0000-4000-8000-00000000000e",
223
362
  name: "kubernetes-agent/prod-eu",
224
363
  canRunAiCommands: true,
225
364
  hostInfo: agentHostInfo("prod-eu"),
@@ -234,78 +373,26 @@ function makeProjectRunners(): Array<Runner> {
234
373
  name: "ops-runner",
235
374
  canRunAiCommands: false,
236
375
  }),
237
- Object.assign(new Runner(), {
238
- _id: RENAMED_AGENT_RUNNER_ID,
239
- name: "prod-eu-kubectl",
240
- canRunAiCommands: true,
241
- hostInfo: agentHostInfo("prod-eu"),
242
- }),
243
376
  ];
244
377
  }
245
378
 
246
- function assignedTo(...runnerIds: Array<string>): Array<Runner> {
247
- return runnerIds.map((runnerId: string): Runner => {
248
- return Object.assign(new Runner(), { _id: runnerId });
249
- });
250
- }
251
-
252
- /*
253
- * A Kubernetes credential assigned to the host Runner, one assigned to a
254
- * Runner that is not listed, and an SSH key.
255
- */
256
379
  function makeProjectCredentials(): Array<RunbookCredential> {
257
380
  return [
258
381
  Object.assign(new RunbookCredential(), {
259
382
  _id: CREDENTIAL_ID,
260
383
  name: "prod-east token",
261
384
  credentialType: RunbookCredentialType.Kubernetes,
262
- runners: assignedTo(HOST_RUNNER_ID),
385
+ runners: [Object.assign(new Runner(), { _id: HOST_RUNNER_ID })],
263
386
  }),
264
387
  Object.assign(new RunbookCredential(), {
265
388
  _id: STAGING_CREDENTIAL_ID,
266
389
  name: "staging token",
267
390
  credentialType: RunbookCredentialType.Kubernetes,
268
- runners: assignedTo(DISABLED_RUNNER_ID),
269
- }),
270
- Object.assign(new RunbookCredential(), {
271
- _id: SSH_CREDENTIAL_ID,
272
- name: "bastion ssh key",
273
- credentialType: RunbookCredentialType.SSH,
274
- runners: assignedTo(HOST_RUNNER_ID),
391
+ runners: [Object.assign(new Runner(), { _id: DISABLED_RUNNER_ID })],
275
392
  }),
276
393
  ];
277
394
  }
278
395
 
279
- // The cluster bound to the host Runner (outside the cluster).
280
- function hostRunnerBinding(): ClusterOverrides {
281
- return {
282
- aiAccessRunner: Object.assign(new Runner(), {
283
- _id: HOST_RUNNER_ID,
284
- name: "bash-runner",
285
- }),
286
- };
287
- }
288
-
289
- type JobOverrides = {
290
- [Key in keyof RunnerJob]?: RunnerJob[Key] | undefined;
291
- };
292
-
293
- function makeJob(id: string, overrides: JobOverrides = {}): RunnerJob {
294
- return Object.assign(
295
- new RunnerJob(),
296
- {
297
- _id: id,
298
- createdAt: new Date("2026-09-22T09:59:00Z"),
299
- origin: RunnerJobOrigin.AiInvestigation,
300
- stepType: RunbookStepType.Kubectl,
301
- status: RunnerJobStatus.Succeeded,
302
- payload: { displayCommand: `kubectl get pods -n web-${id}` },
303
- exitCode: 0,
304
- },
305
- overrides,
306
- );
307
- }
308
-
309
396
  interface ListRequest {
310
397
  modelType?: unknown;
311
398
  query?: Record<string, unknown> | undefined;
@@ -316,45 +403,55 @@ let postSpy: ReturnType<typeof jest.spyOn>;
316
403
  let getListSpy: ReturnType<typeof jest.spyOn>;
317
404
  let getItemSpy: ReturnType<typeof jest.spyOn>;
318
405
  let updateByIdSpy: ReturnType<typeof jest.spyOn>;
319
- let jobs: Array<RunnerJob> = [];
320
406
 
321
- function serveStatus(status: KubernetesClusterAiAccessStatus): void {
322
- postSpy.mockImplementation(async (): Promise<HTTPResponse<JSONObject>> => {
323
- return statusResponse(status);
324
- });
407
+ type RouteAnswer = () => Promise<HTTPResponse<JSONObject> | HTTPErrorResponse>;
408
+
409
+ /*
410
+ * Answers the status route with `status`, and the test and reset routes
411
+ * with their handlers (a plain 200 by default).
412
+ */
413
+ function serve(
414
+ status: KubernetesClusterAiAccessStatus,
415
+ handlers: { test?: RouteAnswer; reset?: RouteAnswer } = {},
416
+ ): void {
417
+ postSpy.mockImplementation(
418
+ async (
419
+ request: unknown,
420
+ ): Promise<HTTPResponse<JSONObject> | HTTPErrorResponse> => {
421
+ const url: string = String((request as JSONObject)["url"]);
422
+ if (url.endsWith(TEST_ROUTE) && handlers.test) {
423
+ return await handlers.test();
424
+ }
425
+ if (url.endsWith(RESET_ROUTE)) {
426
+ return handlers.reset
427
+ ? await handlers.reset()
428
+ : new HTTPResponse<JSONObject>(200, {}, {});
429
+ }
430
+ return statusResponse(status);
431
+ },
432
+ );
325
433
  }
326
434
 
327
435
  /*
328
436
  * A status request that fails the way a transient 502 or a dropped
329
- * connection does. Thrown from inside the async body rather than returned
330
- * as a pre-built rejected promise: zone.js (pulled in by the telemetry util
331
- * the page imports) reports the latter as an unhandled rejection in the
332
- * microtask between construction and the await that catches it, which
333
- * fills a passing run with alarming console noise.
437
+ * connection does — thrown from inside the async body, so zone.js does not
438
+ * report a pre-built rejected promise as unhandled.
334
439
  */
335
440
  async function failStatusRequest(): Promise<HTTPResponse<JSONObject>> {
336
441
  throw new Error("Network Error");
337
442
  }
338
443
 
339
- function listRequests(): Array<ListRequest> {
340
- return getListSpy.mock.calls.map((call: Array<unknown>): ListRequest => {
341
- return (call[0] || {}) as ListRequest;
342
- });
343
- }
344
-
345
- function jobListRequest(): ListRequest | undefined {
346
- return listRequests().find((request: ListRequest): boolean => {
347
- return request.modelType === RunnerJob;
348
- });
349
- }
350
-
351
444
  function listRequestsFor(modelType: unknown): Array<ListRequest> {
352
- return listRequests().filter((request: ListRequest): boolean => {
353
- return request.modelType === modelType;
354
- });
445
+ return getListSpy.mock.calls
446
+ .map((call: Array<unknown>): ListRequest => {
447
+ return (call[0] || {}) as ListRequest;
448
+ })
449
+ .filter((request: ListRequest): boolean => {
450
+ return request.modelType === modelType;
451
+ });
355
452
  }
356
453
 
357
- // The requests the page sent to one of its two custom routes.
454
+ // The requests the page sent to one of its custom routes.
358
455
  function postsTo(route: string): Array<JSONObject> {
359
456
  return postSpy.mock.calls
360
457
  .map((call: Array<unknown>): JSONObject => {
@@ -365,34 +462,12 @@ function postsTo(route: string): Array<JSONObject> {
365
462
  });
366
463
  }
367
464
 
368
- // Answers the status route with `status` and the test route with `test`.
369
- function serveStatusAndTest(
370
- status: KubernetesClusterAiAccessStatus,
371
- test: () => Promise<HTTPResponse<JSONObject> | HTTPErrorResponse>,
372
- ): void {
373
- postSpy.mockImplementation(
374
- async (
375
- request: unknown,
376
- ): Promise<HTTPResponse<JSONObject> | HTTPErrorResponse> => {
377
- const url: string = String((request as JSONObject)["url"]);
378
- if (url.endsWith("/kubernetes-cluster/ai-access/test")) {
379
- return await test();
380
- }
381
- return statusResponse(status);
382
- },
383
- );
384
- }
385
-
386
465
  function updateRequests(): Array<JSONObject> {
387
466
  return updateByIdSpy.mock.calls.map((call: Array<unknown>): JSONObject => {
388
467
  return (call[0] || {}) as JSONObject;
389
468
  });
390
469
  }
391
470
 
392
- function textOf(testId: string): string {
393
- return screen.getByTestId(testId).textContent || "";
394
- }
395
-
396
471
  function grant(permissions: Array<Permission>): void {
397
472
  jest.spyOn(User, "isMasterAdmin").mockReturnValue(false);
398
473
  jest.spyOn(PermissionUtil, "getAllPermissions").mockReturnValue(permissions);
@@ -411,18 +486,20 @@ function grant(permissions: Array<Permission>): void {
411
486
  } as unknown as ReturnType<typeof PermissionUtil.getProjectPermissions>);
412
487
  }
413
488
 
414
- function openAiPage(): void {
415
- goTo(AI_PAGE_PATH);
489
+ function openAgentPage(): void {
490
+ goTo(AGENT_PAGE_PATH);
416
491
 
417
492
  render(
418
- <MemoryRouter initialEntries={[AI_PAGE_PATH]}>
493
+ <MemoryRouter initialEntries={[AGENT_PAGE_PATH]}>
419
494
  <Routes>
420
495
  <PageRoute
421
- path={String(RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_AI])}
496
+ path={String(RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_AI_AGENT])}
422
497
  element={
423
- <KubernetesClusterAI
424
- pageRoute={RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_AI] as Route}
425
- currentProject={null}
498
+ <KubernetesClusterAiAgent
499
+ pageRoute={
500
+ RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_AI_AGENT] as Route
501
+ }
502
+ currentProject={Object.assign(new Project(), { name: "Acme" })}
426
503
  hasPaymentMethod={true}
427
504
  />
428
505
  }
@@ -436,9 +513,17 @@ async function findText(text: string | RegExp): Promise<HTMLElement> {
436
513
  return await screen.findByText(text, {}, { timeout: WAIT_TIMEOUT });
437
514
  }
438
515
 
439
- async function openEditModal(): Promise<HTMLElement> {
440
- await findText(SETTINGS_CARD_TITLE);
441
- fireEvent.click(await findText("Edit AI access"));
516
+ async function findTestId(testId: string): Promise<HTMLElement> {
517
+ return await screen.findByTestId(testId, {}, { timeout: WAIT_TIMEOUT });
518
+ }
519
+
520
+ // The command a CodeBlock inside the element shows, exactly.
521
+ function codeIn(element: HTMLElement): string {
522
+ return element.querySelector("code")?.textContent || "";
523
+ }
524
+
525
+ async function openChangeModal(): Promise<HTMLElement> {
526
+ fireEvent.click(await findTestId("ai-access-change-button"));
442
527
  const dialog: HTMLElement = await screen.findByRole(
443
528
  "dialog",
444
529
  {},
@@ -446,24 +531,20 @@ async function openEditModal(): Promise<HTMLElement> {
446
531
  );
447
532
  // The form is on screen once its first field label is.
448
533
  await within(dialog).findByText(
449
- "Let AI investigate with kubectl",
534
+ "Investigate with kubectl",
450
535
  {},
451
536
  { timeout: WAIT_TIMEOUT },
452
537
  );
453
538
  return dialog;
454
539
  }
455
540
 
456
- // Serves the settings the edit modal reads when it opens.
457
541
  function serveCluster(overrides: ClusterOverrides = {}): void {
458
542
  getItemSpy.mockImplementation(async (): Promise<KubernetesCluster> => {
459
543
  return makeCluster(overrides);
460
544
  });
461
545
  }
462
546
 
463
- /*
464
- * The open react-select menu's options. Native <option>s — the commands
465
- * table's page-size picker — also have the option role and are left out.
466
- */
547
+ // The open react-select menu's options (not native <option>s).
467
548
  function menuOptions(): Array<HTMLElement> {
468
549
  return screen
469
550
  .queryAllByRole("option")
@@ -472,7 +553,6 @@ function menuOptions(): Array<HTMLElement> {
472
553
  });
473
554
  }
474
555
 
475
- // Opens a react-select dropdown in the dialog and returns its option texts.
476
556
  async function openDropdown(
477
557
  dialog: HTMLElement,
478
558
  name: RegExp,
@@ -510,9 +590,9 @@ async function pickOption(
510
590
  }
511
591
 
512
592
  /*
513
- * Clicks the investigation switch once the form has been seeded with the
514
- * saved value. The Toggle picks up its value in an effect after its first
515
- * paint, so a click in that instant would toggle the unseeded default.
593
+ * Clicks a switch once the form has been seeded with the saved value. The
594
+ * Toggle picks up its value in an effect after its first paint, so a click
595
+ * in that instant would toggle the unseeded default.
516
596
  */
517
597
  async function toggleSwitch(
518
598
  dialog: HTMLElement,
@@ -530,17 +610,6 @@ async function toggleSwitch(
530
610
  expect(toggle).toHaveAttribute("aria-checked", String(!from));
531
611
  }
532
612
 
533
- /*
534
- * By its test id: the modal can also hold "Unbind the Runner" and "Unbind
535
- * the Kubernetes credential" switches.
536
- */
537
- async function toggleInvestigation(
538
- dialog: HTMLElement,
539
- from: boolean,
540
- ): Promise<void> {
541
- await toggleSwitch(dialog, "ai-investigation-field", from);
542
- }
543
-
544
613
  async function setAllowlistText(
545
614
  dialog: HTMLElement,
546
615
  value: string,
@@ -563,7 +632,7 @@ async function waitForOneUpdate(): Promise<JSONObject> {
563
632
  return updateRequests()[0]!["data"] as JSONObject;
564
633
  }
565
634
 
566
- function saveEditModal(dialog: HTMLElement): void {
635
+ function saveChangeModal(dialog: HTMLElement): void {
567
636
  fireEvent.click(within(dialog).getByTestId("modal-footer-submit-button"));
568
637
  }
569
638
 
@@ -582,35 +651,24 @@ beforeEach(() => {
582
651
  grant(MEMBER_PERMISSIONS);
583
652
 
584
653
  postSpy = jest.spyOn(API, "post");
585
- serveStatus(makeStatus());
654
+ serve(makeStatus());
586
655
 
587
- jobs = [];
588
656
  getListSpy = jest.spyOn(ModelAPI, "getList");
589
657
  getListSpy.mockImplementation(
590
- async (args: unknown): Promise<ListResult<RunnerJob>> => {
658
+ async (args: unknown): Promise<ListResult<Runner>> => {
591
659
  const request: ListRequest = args as ListRequest;
592
- if (request.modelType === RunnerJob) {
593
- return { data: jobs, count: jobs.length, skip: 0, limit: 10 };
594
- }
595
- if (request.modelType === RunbookCredential) {
596
- const credentials: Array<RunbookCredential> = makeProjectCredentials();
597
- return {
598
- data: credentials as unknown as Array<RunnerJob>,
599
- count: credentials.length,
600
- skip: 0,
601
- limit: 10,
602
- };
603
- }
604
- if (request.modelType === Runner) {
605
- const runners: Array<Runner> = makeProjectRunners();
606
- return {
607
- data: runners as unknown as Array<RunnerJob>,
608
- count: runners.length,
609
- skip: 0,
610
- limit: 10,
611
- };
612
- }
613
- return { data: [], count: 0, skip: 0, limit: 10 };
660
+ const data: Array<unknown> =
661
+ request.modelType === Runner
662
+ ? makeProjectRunners()
663
+ : request.modelType === RunbookCredential
664
+ ? makeProjectCredentials()
665
+ : [];
666
+ return {
667
+ data: data as Array<Runner>,
668
+ count: data.length,
669
+ skip: 0,
670
+ limit: 10,
671
+ };
614
672
  },
615
673
  );
616
674
 
@@ -631,1956 +689,1784 @@ afterEach(() => {
631
689
  jest.restoreAllMocks();
632
690
  });
633
691
 
634
- describe("parseStatus", () => {
635
- test("accepts the status shape the API returns and nothing looser", () => {
636
- expect(parseStatus(makeStatus())).toEqual(makeStatus());
637
- expect(parseStatus(null)).toBeNull();
638
- expect(parseStatus("ready")).toBeNull();
639
- expect(parseStatus([makeStatus()])).toBeNull();
640
- expect(parseStatus({ ...makeStatus(), clusterId: 7 })).toBeNull();
641
- expect(parseStatus({ ...makeStatus(), gaps: null })).toBeNull();
642
- });
643
- });
692
+ describe("the Kubernetes AI agent card", () => {
693
+ test("a connected agent: pill, sentence, meta line and the ready line", async () => {
694
+ openAgentPage();
644
695
 
645
- describe("remediation mode labels", () => {
646
- test("give every mode its own plain-words label", () => {
647
- const modes: Array<KubernetesAiRemediationMode> = Object.values(
648
- KubernetesAiRemediationMode,
696
+ expect(await findText(AGENT_CARD_TITLE)).toBeInTheDocument();
697
+ expect(await findTestId("ai-agent-status")).toHaveTextContent("Connected");
698
+ expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
699
+ "The AI agent is running in this cluster.",
649
700
  );
650
- const labels: Array<string> = modes.map(
651
- (mode: KubernetesAiRemediationMode): string => {
652
- return REMEDIATION_MODE_LABELS[mode];
653
- },
701
+ expect(screen.getByTestId("ai-agent-meta")).toHaveTextContent(
702
+ "agent v14.1.0 · kubectl v1.31.2 · Read-only",
703
+ );
704
+ expect(screen.getByTestId("ai-agent-meta")).toHaveTextContent(
705
+ /^last seen /,
706
+ );
707
+ expect(screen.getByTestId("ai-agent-ready")).toHaveTextContent(
708
+ AI_AGENT_READY_TEXT,
654
709
  );
710
+ // Nothing to install, no logs to read.
711
+ expect(
712
+ screen.queryByTestId("ai-agent-install-command"),
713
+ ).not.toBeInTheDocument();
714
+ expect(
715
+ screen.queryByTestId("ai-agent-logs-command"),
716
+ ).not.toBeInTheDocument();
717
+ });
655
718
 
656
- expect(modes.length).toBeGreaterThanOrEqual(4);
657
- for (const label of labels) {
658
- expect(typeof label).toBe("string");
659
- expect(label.trim().length).toBeGreaterThan(0);
660
- }
661
- expect(new Set(labels).size).toBe(labels.length);
662
- // Both unattended modes say fixes run on their own.
719
+ /*
720
+ * The whole body of a not-installed card is one sentence and ONE
721
+ * command, always with aiAgent.enabled=true — no decision in front of a
722
+ * first-time user, and nothing to test yet.
723
+ */
724
+ test("not installed: the one install command and nothing to test", async () => {
725
+ serve(notInstalledStatus());
726
+ openAgentPage();
727
+
728
+ expect(await findTestId("ai-agent-status")).toHaveTextContent(
729
+ "Not installed",
730
+ );
731
+ expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
732
+ "Install the AI agent — it runs in your cluster, read-only, using your Kubernetes agent's key. This page updates within a minute.",
733
+ );
734
+ expect(codeIn(screen.getByTestId("ai-agent-install-command"))).toBe(
735
+ getAiAgentHelmCommands().install,
736
+ );
663
737
  expect(
664
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Automatic],
665
- ).toMatch(/run on their own/);
738
+ screen.getByText(
739
+ "Installed under another release or namespace? Use yours.",
740
+ ),
741
+ ).toBeInTheDocument();
666
742
  expect(
667
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.BypassApproval],
668
- ).toMatch(/runs on its own/);
743
+ screen.queryByTestId("ai-agent-test-button"),
744
+ ).not.toBeInTheDocument();
745
+ expect(screen.queryByTestId("ai-agent-ready")).not.toBeInTheDocument();
746
+ expect(screen.queryByTestId("ai-agent-meta")).not.toBeInTheDocument();
747
+ // No agent row: nothing to reset, even for an admin.
669
748
  expect(
670
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Disabled],
671
- ).toMatch(/Off/);
749
+ screen.queryByTestId("ai-agent-reset-button"),
750
+ ).not.toBeInTheDocument();
751
+ });
752
+
753
+ test("offline: the logs command for the namespace the agent reported", async () => {
754
+ serve(makeStatus({}, silentAgent()));
755
+ openAgentPage();
756
+
757
+ expect(await findTestId("ai-agent-status")).toHaveTextContent("Offline");
758
+ expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
759
+ "The AI agent has not checked in for over 5 minutes. Check its pod:",
760
+ );
761
+ expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
762
+ AI_AGENT_SILENT_TEXT,
763
+ );
764
+ expect(codeIn(screen.getByTestId("ai-agent-logs-command"))).toBe(
765
+ "kubectl logs -n monitoring -l component=ai-agent --tail=100",
766
+ );
672
767
  });
673
768
 
674
769
  /*
675
- * The copy must match the canonical description on
676
- * KubernetesAiRemediationMode (and what RemediationExecutionRunner does):
677
- * an Automatic round that could only find riskier fixes ends by proposing
678
- * them for one-click approval. The page said they were "left for you"
679
- * and that "only a follow-up round" proposed them. This pin replaces the
680
- * old one, which required "left for you".
770
+ * A pod stopped by a helm upgrade signs off, and it is back within a few
771
+ * minutes. The card used to say "has not checked in for over 5 minutes"
772
+ * above a meta line reading "last seen a few seconds ago".
681
773
  */
682
- test("Automatic says a riskier fix is proposed for one-click approval", () => {
683
- const label: string =
684
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Automatic];
685
- expect(label).toMatch(/one-click approval/);
686
- expect(label).not.toMatch(/left for you/);
774
+ test("offline right after a sign-off: says it reconnects, never five silent minutes", async () => {
775
+ serve(makeStatus({}, signedOffAgent()));
776
+ openAgentPage();
777
+
778
+ expect(await findTestId("ai-agent-status")).toHaveTextContent("Offline");
779
+ const sentence: HTMLElement = screen.getByTestId("ai-agent-sentence");
780
+ expect(sentence).toHaveTextContent(
781
+ "The AI agent signed off or was reset. It reconnects on its own within a few minutes. If it does not, check its pod:",
782
+ );
783
+ expect(sentence).toHaveTextContent(AI_AGENT_SIGNED_OFF_TEXT);
784
+ expect(sentence).not.toHaveTextContent("has not checked in");
785
+ expect(screen.getByTestId("ai-agent-meta")).toHaveTextContent(
786
+ /^last seen /,
787
+ );
788
+ // Still the pod to look at if it does not come back.
789
+ expect(codeIn(screen.getByTestId("ai-agent-logs-command"))).toBe(
790
+ "kubectl logs -n monitoring -l component=ai-agent --tail=100",
791
+ );
792
+ });
793
+
794
+ test("offline long after a sign-off: says it has not come back", async () => {
795
+ serve(makeStatus({}, goneAgent()));
796
+ openAgentPage();
687
797
 
688
- const description: string = getRemediationModeFieldDescription();
689
- expect(description).toMatch(
690
- /when the round could only find riskier fixes, it ends by proposing exactly those for one-click approval/,
798
+ expect(await findTestId("ai-agent-status")).toHaveTextContent("Offline");
799
+ expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
800
+ AI_AGENT_GONE_TEXT,
691
801
  );
692
- expect(description).not.toMatch(/only a follow-up round/);
693
- expect(description).not.toMatch(
694
- /leaves the exact command in its recommendations/,
802
+ expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
803
+ "The AI agent disconnected and has not come back. Check its pod:",
695
804
  );
696
- // A named Job is not recreated by anything: deleting one is riskier.
697
- expect(description).not.toMatch(/delete a named pod or job/);
698
- expect(description).toContain("delete a named pod");
805
+ expect(screen.getByTestId("ai-agent-logs-command")).toBeInTheDocument();
699
806
  });
700
807
 
701
808
  /*
702
- * Bypass approval does ask in a few places: protected namespaces, node
703
- * drains and taints, and a tripped circuit breaker. "Nobody is asked"
704
- * was false.
809
+ * Every 14.0.x install sits here between the server upgrade and the
810
+ * chart upgrade: it works today, and the same single command moves it
811
+ * to the AI agent.
705
812
  */
706
- test("Bypass approval names what still asks a human", () => {
707
- const label: string =
708
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.BypassApproval];
709
- expect(label).not.toMatch(/nobody is asked/);
710
- for (const exception of [
711
- "protected namespaces",
712
- "drain",
713
- "circuit breaker",
714
- ]) {
715
- expect(label).toContain(exception);
716
- }
813
+ test("the previous in-cluster Runner: works today, one command to upgrade", async () => {
814
+ serve(legacyStatus());
815
+ openAgentPage();
717
816
 
718
- const description: string = getRemediationModeFieldDescription();
719
- expect(description).not.toMatch(/nobody is ever asked/);
720
- for (const namespace of ["kube-system", "kube-public", "kube-node-lease"]) {
721
- expect(description).toContain(namespace);
722
- }
723
- /*
724
- * The canonical comment's words ("a node drain and a node taint"),
725
- * plus a patch of a node, which round four holds to the same rule:
726
- * a taint written as `kubectl patch node` is the same change.
727
- */
728
- expect(description).toMatch(
729
- /a node drain, a node taint and a patch of a node always need a human/,
730
- );
731
- expect(description).toMatch(/circuit breaker/);
732
- expect(description).toMatch(/never changes its own namespace/);
733
- // Bypass approval's other exception: another unattended round.
734
- expect(description).toMatch(
735
- /another unattended round already holds the cluster/,
736
- );
737
- expect(label).toContain("another unattended round");
817
+ expect(await findTestId("ai-agent-status")).toHaveTextContent(
818
+ "Connected through the previous in-cluster Runner",
819
+ );
820
+ expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
821
+ "Works today. Upgrade the Kubernetes agent chart to switch to the new AI agent — your settings carry over.",
822
+ );
823
+ expect(codeIn(screen.getByTestId("ai-agent-install-command"))).toBe(
824
+ getAiAgentHelmCommands().install,
825
+ );
826
+ // The connection can still be tested through it.
827
+ expect(screen.getByTestId("ai-agent-test-button")).not.toBeDisabled();
828
+ expect(
829
+ screen.queryByTestId("ai-agent-switch-button"),
830
+ ).not.toBeInTheDocument();
738
831
  });
739
- });
740
-
741
- describe("credential picker permission", () => {
742
- test("is offered only to users who may read Runner credentials", () => {
743
- expect(canPickKubernetesCredential()).toBe(false);
744
832
 
745
- grant([...MEMBER_PERMISSIONS, Permission.ReadRunbookCredential]);
746
- expect(canPickKubernetesCredential()).toBe(true);
833
+ test("the previous in-cluster Runner offline: not said to work today, same command", async () => {
834
+ serve(
835
+ legacyStatus({
836
+ runner: {
837
+ ...legacyStatus().runner!,
838
+ isOnline: false,
839
+ lastAliveAt: minutesAgo(20),
840
+ },
841
+ }),
842
+ );
843
+ openAgentPage();
747
844
 
748
- grant([Permission.Public, Permission.User, Permission.ProjectAdmin]);
749
- expect(canPickKubernetesCredential()).toBe(true);
845
+ expect(await findTestId("ai-agent-status")).toHaveTextContent("Offline");
846
+ const sentence: HTMLElement = screen.getByTestId("ai-agent-sentence");
847
+ expect(sentence).toHaveTextContent(
848
+ "The previous in-cluster Runner is offline. Upgrade the Kubernetes agent chart to switch to the new AI agent — your settings carry over.",
849
+ );
850
+ expect(sentence).not.toHaveTextContent("Works today");
851
+ expect(codeIn(screen.getByTestId("ai-agent-install-command"))).toBe(
852
+ getAiAgentHelmCommands().install,
853
+ );
750
854
  });
751
855
 
752
- test("is always offered to a master admin", () => {
753
- jest.spyOn(User, "isMasterAdmin").mockReturnValue(true);
754
- expect(canPickKubernetesCredential()).toBe(true);
856
+ test("an advanced Runner is display-only for a member", async () => {
857
+ serve(advancedStatus());
858
+ openAgentPage();
859
+
860
+ expect(await findTestId("ai-agent-status")).toHaveTextContent(
861
+ "Connected through Runner bash-runner (advanced)",
862
+ );
863
+ expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
864
+ 'Reached through Runner "bash-runner" with credential "prod-east token".',
865
+ );
866
+ expect(
867
+ screen.queryByTestId("ai-agent-switch-button"),
868
+ ).not.toBeInTheDocument();
869
+ expect(
870
+ screen.queryByTestId("ai-agent-install-command"),
871
+ ).not.toBeInTheDocument();
755
872
  });
756
873
 
757
- test("is withheld while the permission snapshot has not landed", () => {
758
- grant([]);
759
- expect(canPickKubernetesCredential()).toBe(false);
874
+ test("warns when another agent tried to register for the cluster", async () => {
875
+ serve(
876
+ makeStatus(
877
+ {},
878
+ makeAgent({
879
+ lastRefusedRegistrationAt: new Date().toISOString(),
880
+ lastRefusedRegistrationReason: "previous_instance_online",
881
+ }),
882
+ ),
883
+ );
884
+ openAgentPage();
885
+
886
+ expect(await findTestId("ai-agent-refused-registration")).toHaveTextContent(
887
+ "Another agent tried to register for this cluster at",
888
+ );
760
889
  });
761
890
 
762
- test("names the permissions that would unlock it", () => {
763
- const titles: Array<string> = getKubernetesCredentialPermissionTitles();
764
- expect(titles.length).toBeGreaterThan(0);
765
- expect(titles.join(", ")).toMatch(/Runbook Credential/);
891
+ test("an older server's status without the agent field still renders", async () => {
892
+ const older: Partial<KubernetesClusterAiAccessStatus> = legacyStatus();
893
+ delete older.aiAgent;
894
+ delete older.automaticInvestigation;
895
+ serve(older as KubernetesClusterAiAccessStatus);
896
+ openAgentPage();
897
+
898
+ expect(await findTestId("ai-agent-status")).toHaveTextContent(
899
+ "Connected through the previous in-cluster Runner",
900
+ );
901
+ expect(
902
+ screen.queryByTestId("ai-access-automatic-investigation"),
903
+ ).not.toBeInTheDocument();
766
904
  });
767
905
  });
768
906
 
769
- describe("Kubernetes cluster AI page", () => {
770
- describe("investigation tile", () => {
771
- test("says investigation is on when the status says it is", async () => {
772
- serveStatus(makeStatus({ isInvestigationEnabled: true }));
773
- openAiPage();
774
-
775
- expect(await findText(INVESTIGATION_ON_TEXT)).toBeInTheDocument();
776
- expect(
777
- screen.queryByText(INVESTIGATION_OFF_TEXT),
778
- ).not.toBeInTheDocument();
779
- expect(screen.getByText("Investigation: ready")).toBeInTheDocument();
780
- });
907
+ /*
908
+ * Both AI pages open with the same heading: a title and one line saying
909
+ * what the page is for (the AI Insights page's is "AI Insights").
910
+ */
911
+ describe("the page heading", () => {
912
+ test("names the page and what it is for", async () => {
913
+ openAgentPage();
914
+
915
+ const heading: HTMLElement = await screen.findByRole(
916
+ "heading",
917
+ { level: 2, name: AI_AGENT_PAGE_TITLE },
918
+ { timeout: WAIT_TIMEOUT },
919
+ );
920
+ expect(heading).toHaveTextContent("AI agent");
921
+ expect(screen.getByTestId("ai-agent-page-heading")).toHaveTextContent(
922
+ "Whether OneUptime AI can reach this cluster, and what it may do there.",
923
+ );
924
+ expect(screen.getByTestId("ai-agent-page-heading")).toHaveTextContent(
925
+ AI_AGENT_PAGE_SUBTITLE,
926
+ );
927
+ // Above the first card.
928
+ await findText(AGENT_CARD_TITLE);
929
+ expect(
930
+ screen
931
+ .getByTestId("ai-agent-page-heading")
932
+ .compareDocumentPosition(screen.getByText(AGENT_CARD_TITLE)) &
933
+ Node.DOCUMENT_POSITION_FOLLOWING,
934
+ ).toBeTruthy();
935
+ });
781
936
 
782
- test("says investigation is off when the status says it is", async () => {
783
- serveStatus(
784
- makeStatus({
785
- isInvestigationEnabled: false,
786
- isInvestigationReady: false,
787
- }),
937
+ test("is shown while the status loads", async () => {
938
+ let answer: (value: HTTPResponse<JSONObject>) => void = () => {
939
+ return undefined;
940
+ };
941
+ postSpy.mockImplementation((): Promise<HTTPResponse<JSONObject>> => {
942
+ return new Promise<HTTPResponse<JSONObject>>(
943
+ (resolve: (value: HTTPResponse<JSONObject>) => void) => {
944
+ answer = resolve;
945
+ },
788
946
  );
789
- openAiPage();
947
+ });
948
+ openAgentPage();
949
+
950
+ expect(screen.getByTestId("ai-agent-page-heading")).toHaveTextContent(
951
+ AI_AGENT_PAGE_TITLE,
952
+ );
953
+ expect(screen.queryByText(AGENT_CARD_TITLE)).not.toBeInTheDocument();
790
954
 
791
- expect(await findText(INVESTIGATION_OFF_TEXT)).toBeInTheDocument();
792
- expect(screen.queryByText(INVESTIGATION_ON_TEXT)).not.toBeInTheDocument();
793
- expect(screen.getByText("Investigation: off")).toBeInTheDocument();
955
+ await act(async () => {
956
+ answer(statusResponse(makeStatus()));
794
957
  });
958
+ expect(await findText(AGENT_CARD_TITLE)).toBeInTheDocument();
959
+ expect(screen.getAllByTestId("ai-agent-page-heading")).toHaveLength(1);
960
+ });
795
961
 
796
- test("shows the remediation mode in the same words as the settings dropdown", async () => {
797
- serveStatus(
798
- makeStatus({
799
- remediationMode: KubernetesAiRemediationMode.BypassApproval,
800
- }),
801
- );
802
- openAiPage();
803
-
804
- expect(
805
- await findText("Remediation: bypass approval"),
806
- ).toBeInTheDocument();
807
- expect(
808
- screen.getAllByText(
809
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.BypassApproval],
810
- ).length,
811
- ).toBeGreaterThan(0);
962
+ test("stays above the error when the first load fails", async () => {
963
+ postSpy.mockImplementation(failStatusRequest);
964
+ openAgentPage();
965
+
966
+ expect(await findText("Network Error")).toBeInTheDocument();
967
+ expect(screen.getByTestId("ai-agent-page-heading")).toHaveTextContent(
968
+ AI_AGENT_PAGE_TITLE,
969
+ );
970
+ });
971
+ });
972
+
973
+ describe("status polling", () => {
974
+ /*
975
+ * The page read its cluster id as a fresh ObjectID on every render, so
976
+ * the memoized fetch was recreated each render and the load effect re-ran
977
+ * after every answer: an unbounded loop of status requests.
978
+ */
979
+ test("requests the status once on load, not once per render", async () => {
980
+ openAgentPage();
981
+
982
+ expect(await findText(AGENT_CARD_TITLE)).toBeInTheDocument();
983
+ expect(await findText(SETTINGS_CARD_TITLE)).toBeInTheDocument();
984
+
985
+ expect(postsTo(STATUS_ROUTE)).toHaveLength(1);
986
+ expect(postsTo(STATUS_ROUTE)[0]!["data"]).toEqual({
987
+ clusterId: CLUSTER_ID.toString(),
812
988
  });
813
989
  });
814
990
 
815
- describe("status polling", () => {
816
- /*
817
- * The page read its cluster id as a fresh ObjectID on every render, so
818
- * the memoized fetch was recreated each render and the load effect
819
- * re-ran after every answer: an unbounded loop of status requests.
820
- */
821
- test("requests the status once on load, not once per render", async () => {
822
- openAiPage();
991
+ test("shows the error page only when the first load fails", async () => {
992
+ postSpy.mockImplementation(failStatusRequest);
993
+ openAgentPage();
823
994
 
824
- expect(await findText(STATUS_CARD_TITLE)).toBeInTheDocument();
825
- expect(await findText(SETTINGS_CARD_TITLE)).toBeInTheDocument();
826
- await findText(
827
- "OneUptime AI has not run any kubectl commands on this cluster yet.",
828
- );
995
+ expect(await findText("Network Error")).toBeInTheDocument();
996
+ expect(screen.queryByText(AGENT_CARD_TITLE)).not.toBeInTheDocument();
997
+ expect(
998
+ screen.queryByTestId("ai-access-refresh-warning"),
999
+ ).not.toBeInTheDocument();
1000
+ });
1001
+
1002
+ test("keeps the last good status and warns inline when a background poll fails", async () => {
1003
+ jest.useFakeTimers();
1004
+ postSpy
1005
+ .mockResolvedValueOnce(statusResponse(makeStatus()))
1006
+ .mockImplementationOnce(failStatusRequest)
1007
+ .mockResolvedValue(statusResponse(makeStatus()));
829
1008
 
830
- expect(postSpy).toHaveBeenCalledTimes(1);
1009
+ openAgentPage();
1010
+ expect(await findText(AGENT_CARD_TITLE)).toBeInTheDocument();
1011
+
1012
+ await act(async () => {
1013
+ jest.advanceTimersByTime(AI_AGENT_STATUS_POLL_INTERVAL_MS);
831
1014
  });
832
1015
 
833
- test("shows the error page only when the first load fails", async () => {
834
- postSpy.mockImplementation(failStatusRequest);
835
- openAiPage();
1016
+ const warning: HTMLElement = await findTestId("ai-access-refresh-warning");
1017
+ expect(warning).toHaveTextContent("Could not refresh the AI agent status");
1018
+ expect(warning).toHaveTextContent("Network Error");
1019
+ expect(warning).toHaveTextContent("Showing the last status from");
1020
+ expect(screen.getByText(AGENT_CARD_TITLE)).toBeInTheDocument();
1021
+ expect(screen.getByTestId("ai-agent-status")).toHaveTextContent(
1022
+ "Connected",
1023
+ );
1024
+ expect(postSpy).toHaveBeenCalledTimes(2);
836
1025
 
837
- expect(await findText("Network Error")).toBeInTheDocument();
838
- expect(screen.queryByText(STATUS_CARD_TITLE)).not.toBeInTheDocument();
839
- expect(
840
- screen.queryByTestId("ai-access-refresh-warning"),
841
- ).not.toBeInTheDocument();
1026
+ await act(async () => {
1027
+ jest.advanceTimersByTime(AI_AGENT_STATUS_POLL_INTERVAL_MS);
842
1028
  });
843
1029
 
844
- /*
845
- * The finding: an operator mid-edit lost the modal and the whole page to
846
- * a transient 502 on the 30 s poll. The last good status must stay on
847
- * screen, with a warning that says so, until a later poll succeeds.
848
- */
849
- test("keeps the last good status and warns inline when a background poll fails", async () => {
850
- jest.useFakeTimers();
851
- postSpy
852
- .mockResolvedValueOnce(statusResponse(makeStatus()))
853
- .mockImplementationOnce(failStatusRequest)
854
- .mockResolvedValue(statusResponse(makeStatus()));
855
-
856
- openAiPage();
857
- expect(await findText(STATUS_CARD_TITLE)).toBeInTheDocument();
858
- expect(await findText(SETTINGS_CARD_TITLE)).toBeInTheDocument();
859
-
860
- await act(async () => {
861
- jest.advanceTimersByTime(AI_ACCESS_STATUS_POLL_INTERVAL_MS);
862
- });
863
-
864
- const warning: HTMLElement = await screen.findByTestId(
865
- "ai-access-refresh-warning",
866
- {},
867
- { timeout: WAIT_TIMEOUT },
868
- );
869
- expect(warning).toHaveTextContent(
870
- "Could not refresh the AI access status",
871
- );
872
- expect(warning).toHaveTextContent("Network Error");
873
- expect(warning).toHaveTextContent("Showing the last status from");
874
- expect(screen.getByText(STATUS_CARD_TITLE)).toBeInTheDocument();
875
- expect(screen.getByText(SETTINGS_CARD_TITLE)).toBeInTheDocument();
876
- expect(screen.getByText(INVESTIGATION_ON_TEXT)).toBeInTheDocument();
877
- expect(postSpy).toHaveBeenCalledTimes(2);
878
-
879
- await act(async () => {
880
- jest.advanceTimersByTime(AI_ACCESS_STATUS_POLL_INTERVAL_MS);
881
- });
882
-
883
- await waitFor(
884
- () => {
885
- expect(
886
- screen.queryByTestId("ai-access-refresh-warning"),
887
- ).not.toBeInTheDocument();
888
- },
889
- { timeout: WAIT_TIMEOUT },
890
- );
891
- expect(screen.getByText(STATUS_CARD_TITLE)).toBeInTheDocument();
892
- expect(postSpy).toHaveBeenCalledTimes(3);
893
- });
1030
+ await waitFor(
1031
+ () => {
1032
+ expect(
1033
+ screen.queryByTestId("ai-access-refresh-warning"),
1034
+ ).not.toBeInTheDocument();
1035
+ },
1036
+ { timeout: WAIT_TIMEOUT },
1037
+ );
1038
+ expect(postSpy).toHaveBeenCalledTimes(3);
1039
+ });
894
1040
 
895
- /*
896
- * The exact scene from the review: the operator is inside "Edit AI
897
- * access" when a poll fails. The modal must stay open through the
898
- * failure and through the recovery that follows.
899
- */
900
- test("keeps an open edit modal through a failed poll and its recovery", async () => {
901
- jest.useFakeTimers();
902
- // Every poll after the first load fails until the test says otherwise.
903
- postSpy
904
- .mockResolvedValueOnce(statusResponse(makeStatus()))
905
- .mockImplementation(failStatusRequest);
906
-
907
- openAiPage();
908
- const dialog: HTMLElement = await openEditModal();
909
-
910
- await act(async () => {
911
- jest.advanceTimersByTime(AI_ACCESS_STATUS_POLL_INTERVAL_MS);
912
- });
913
-
914
- const warning: HTMLElement = await screen.findByTestId(
915
- "ai-access-refresh-warning",
916
- {},
917
- { timeout: WAIT_TIMEOUT },
918
- );
919
- expect(warning).toHaveTextContent("Network Error");
920
- expect(screen.getByRole("dialog")).toBe(dialog);
921
- expect(
922
- within(dialog).getByText("Let AI investigate with kubectl"),
923
- ).toBeInTheDocument();
924
- expect(screen.getByText(SETTINGS_CARD_TITLE)).toBeInTheDocument();
925
-
926
- serveStatus(makeStatus());
927
- await act(async () => {
928
- jest.advanceTimersByTime(AI_ACCESS_STATUS_POLL_INTERVAL_MS);
929
- });
930
-
931
- await waitFor(
932
- () => {
933
- expect(
934
- screen.queryByTestId("ai-access-refresh-warning"),
935
- ).not.toBeInTheDocument();
936
- },
937
- { timeout: WAIT_TIMEOUT },
938
- );
939
- expect(screen.getByRole("dialog")).toBe(dialog);
940
- expect(
941
- within(dialog).getByText("Let AI investigate with kubectl"),
942
- ).toBeInTheDocument();
1041
+ test("picks up an agent that connects while the page is open", async () => {
1042
+ jest.useFakeTimers();
1043
+ postSpy
1044
+ .mockResolvedValueOnce(statusResponse(notInstalledStatus()))
1045
+ .mockResolvedValue(statusResponse(makeStatus()));
1046
+
1047
+ openAgentPage();
1048
+ expect(await findTestId("ai-agent-status")).toHaveTextContent(
1049
+ "Not installed",
1050
+ );
1051
+
1052
+ await act(async () => {
1053
+ jest.advanceTimersByTime(AI_AGENT_STATUS_POLL_INTERVAL_MS);
943
1054
  });
944
1055
 
945
- test("keeps the page when a poll returns a status it cannot read", async () => {
946
- jest.useFakeTimers();
947
- postSpy
948
- .mockResolvedValueOnce(statusResponse(makeStatus()))
949
- .mockResolvedValue(
950
- new HTTPResponse<JSONObject>(200, { unexpected: true }, {}),
1056
+ await waitFor(
1057
+ () => {
1058
+ expect(screen.getByTestId("ai-agent-status")).toHaveTextContent(
1059
+ "Connected",
951
1060
  );
1061
+ },
1062
+ { timeout: WAIT_TIMEOUT },
1063
+ );
1064
+ });
952
1065
 
953
- openAiPage();
954
- expect(await findText(STATUS_CARD_TITLE)).toBeInTheDocument();
1066
+ test("keeps an open Change modal through a failed poll and its recovery", async () => {
1067
+ jest.useFakeTimers();
1068
+ postSpy
1069
+ .mockResolvedValueOnce(statusResponse(makeStatus()))
1070
+ .mockImplementation(failStatusRequest);
955
1071
 
956
- await act(async () => {
957
- jest.advanceTimersByTime(AI_ACCESS_STATUS_POLL_INTERVAL_MS);
958
- });
1072
+ openAgentPage();
1073
+ const dialog: HTMLElement = await openChangeModal();
959
1074
 
960
- expect(
961
- await screen.findByTestId(
962
- "ai-access-refresh-warning",
963
- {},
964
- { timeout: WAIT_TIMEOUT },
965
- ),
966
- ).toBeInTheDocument();
967
- expect(screen.getByText(STATUS_CARD_TITLE)).toBeInTheDocument();
1075
+ await act(async () => {
1076
+ jest.advanceTimersByTime(AI_AGENT_STATUS_POLL_INTERVAL_MS);
968
1077
  });
969
- });
970
1078
 
971
- describe("commands table", () => {
972
- test("asks for every column its cells read and labels rows from them", async () => {
973
- jobs = [
974
- makeJob("investigation", { aiRunId: new ObjectID(RUN_ID) }),
975
- makeJob("access-test"),
976
- makeJob("failed", {
977
- aiRunId: new ObjectID(RUN_ID),
978
- status: RunnerJobStatus.Failed,
979
- exitCode: 1,
980
- errorMessage: "pods is forbidden: User cannot list resource",
981
- }),
982
- ];
983
- openAiPage();
984
-
985
- expect(
986
- await findText("kubectl get pods -n web-investigation"),
987
- ).toBeInTheDocument();
988
- expect(
989
- screen.getByText("kubectl get pods -n web-access-test"),
990
- ).toBeInTheDocument();
991
-
992
- // Whole-cell matches only: the card description also says "(read-only)".
993
- const whyCells: Array<string> = screen
994
- .getAllByText(/^(Investigation|Access test) \(read-only\)$/)
995
- .map((cell: HTMLElement): string => {
996
- return cell.textContent || "";
997
- });
998
- expect(whyCells).toEqual([
999
- "Investigation (read-only)",
1000
- "Access test (read-only)",
1001
- "Investigation (read-only)",
1002
- ]);
1003
-
1004
- expect(screen.getByText("Failed (exit 1)")).toBeInTheDocument();
1005
- expect(screen.getAllByText("Succeeded (exit 0)").length).toBe(2);
1006
- expect(
1007
- screen.getByText("pods is forbidden: User cannot list resource"),
1008
- ).toBeInTheDocument();
1009
-
1010
- const request: ListRequest | undefined = jobListRequest();
1011
- expect(request?.select).toEqual(
1012
- expect.objectContaining({
1013
- createdAt: true,
1014
- payload: true,
1015
- origin: true,
1016
- status: true,
1017
- aiRunId: true,
1018
- exitCode: true,
1019
- errorMessage: true,
1020
- }),
1021
- );
1022
- expect(request?.select).not.toHaveProperty("output");
1023
- expect(request?.query).toEqual(
1024
- expect.objectContaining({
1025
- stepType: RunbookStepType.Kubectl,
1026
- }),
1027
- );
1028
- expect(String(request?.query?.["kubernetesClusterId"])).toBe(
1029
- CLUSTER_ID.toString(),
1030
- );
1079
+ expect(await findTestId("ai-access-refresh-warning")).toHaveTextContent(
1080
+ "Network Error",
1081
+ );
1082
+ expect(screen.getByRole("dialog")).toBe(dialog);
1083
+
1084
+ serve(makeStatus());
1085
+ await act(async () => {
1086
+ jest.advanceTimersByTime(AI_AGENT_STATUS_POLL_INTERVAL_MS);
1031
1087
  });
1032
1088
 
1033
- test("labels a remediation command and tells the empty state apart", async () => {
1034
- jobs = [
1035
- makeJob("fix", {
1036
- origin: RunnerJobOrigin.AiRemediation,
1037
- aiRunId: new ObjectID(RUN_ID),
1038
- payload: {
1039
- displayCommand: "kubectl rollout restart deployment/web -n web",
1040
- },
1041
- }),
1042
- ];
1043
- openAiPage();
1089
+ await waitFor(
1090
+ () => {
1091
+ expect(
1092
+ screen.queryByTestId("ai-access-refresh-warning"),
1093
+ ).not.toBeInTheDocument();
1094
+ },
1095
+ { timeout: WAIT_TIMEOUT },
1096
+ );
1097
+ expect(screen.getByRole("dialog")).toBe(dialog);
1098
+ expect(
1099
+ within(dialog).getByText("Investigate with kubectl"),
1100
+ ).toBeInTheDocument();
1101
+ });
1044
1102
 
1045
- const commandCell: HTMLElement = await findText(
1046
- "kubectl rollout restart deployment/web -n web",
1103
+ test("keeps the page when a poll returns a status it cannot read", async () => {
1104
+ jest.useFakeTimers();
1105
+ postSpy
1106
+ .mockResolvedValueOnce(statusResponse(makeStatus()))
1107
+ .mockResolvedValue(
1108
+ new HTTPResponse<JSONObject>(200, { unexpected: true }, {}),
1047
1109
  );
1048
- // Scoped to the row: the summary tile above is also headed "Remediation".
1049
- const row: HTMLElement | null = commandCell.closest("tr");
1050
- expect(row).not.toBeNull();
1051
- expect(
1052
- within(row as HTMLElement).getByText("Remediation"),
1053
- ).toBeInTheDocument();
1054
- expect(
1055
- within(row as HTMLElement).queryByText(
1056
- /^(Investigation|Access test) \(read-only\)$/,
1057
- ),
1058
- ).not.toBeInTheDocument();
1059
- });
1060
1110
 
1061
- test("renders the empty state without crashing when nothing ran yet", async () => {
1062
- openAiPage();
1111
+ openAgentPage();
1112
+ expect(await findText(AGENT_CARD_TITLE)).toBeInTheDocument();
1063
1113
 
1064
- expect(
1065
- await findText(
1066
- "OneUptime AI has not run any kubectl commands on this cluster yet.",
1067
- ),
1068
- ).toBeInTheDocument();
1114
+ await act(async () => {
1115
+ jest.advanceTimersByTime(AI_AGENT_STATUS_POLL_INTERVAL_MS);
1069
1116
  });
1117
+
1118
+ expect(await findTestId("ai-access-refresh-warning")).toHaveTextContent(
1119
+ "cannot read",
1120
+ );
1121
+ expect(screen.getByText(AGENT_CARD_TITLE)).toBeInTheDocument();
1070
1122
  });
1123
+ });
1071
1124
 
1125
+ describe("Needs attention", () => {
1072
1126
  /*
1073
- * Binding a Runner or a credential, the higher unattended modes and new
1074
- * allowlist patterns need KUBERNETES_AI_ACCESS_ADMIN_PERMISSIONS (the
1075
- * server refuses them otherwise). The pickers are exercised with a user
1076
- * who holds the admin set; the tightening a member may do is below.
1127
+ * A default install — agent connected, Fixes off — has one server gap,
1128
+ * remediation_disabled. That is a choice, shown in "What AI may do", so
1129
+ * the page has no "Needs attention" card and reads Ready.
1077
1130
  */
1078
- describe("edit modal pickers", () => {
1079
- test("omits the credential picker for an admin-set holder who may not read Runner credentials", async () => {
1080
- grant([...MEMBER_PERMISSIONS, Permission.EditAutoRemediationRule]);
1081
- openAiPage();
1082
-
1083
- expect(
1084
- await screen.findByTestId(
1085
- "kubernetes-credential-permission-note",
1086
- {},
1087
- { timeout: WAIT_TIMEOUT },
1088
- ),
1089
- ).toHaveTextContent("Runbook Credential");
1131
+ test("is not shown when the only gap is the fixes-off choice", async () => {
1132
+ openAgentPage();
1090
1133
 
1091
- const dialog: HTMLElement = await openEditModal();
1134
+ expect(await findText(SETTINGS_CARD_TITLE)).toBeInTheDocument();
1135
+ expect(screen.queryByText(GAPS_CARD_TITLE)).not.toBeInTheDocument();
1136
+ expect(screen.getByTestId("ai-agent-ready")).toBeInTheDocument();
1137
+ });
1092
1138
 
1093
- expect(
1094
- await within(dialog).findByText(
1095
- "Runner",
1096
- {},
1097
- { timeout: WAIT_TIMEOUT },
1098
- ),
1099
- ).toBeInTheDocument();
1100
- expect(
1101
- within(dialog).queryByText("Kubernetes credential"),
1102
- ).not.toBeInTheDocument();
1103
- expect(
1104
- within(dialog).getByTestId(
1105
- "kubernetes-credential-picker-permission-note",
1106
- ),
1107
- ).toHaveTextContent("Read Runbook Credential");
1108
- expect(
1109
- within(dialog).queryByText(/do not have permission/i),
1110
- ).not.toBeInTheDocument();
1139
+ test("lists one row per server gap with its title and next step", async () => {
1140
+ serve(
1141
+ makeStatus({
1142
+ isInvestigationReady: false,
1143
+ gaps: [
1144
+ gap("ai_balance_insufficient"),
1145
+ gap("remediation_disabled", "remediation"),
1146
+ gap("llm_provider_missing"),
1147
+ ],
1148
+ }),
1149
+ );
1150
+ openAgentPage();
1151
+
1152
+ const list: HTMLElement = await findTestId("ai-agent-gaps");
1153
+ expect(screen.getByText(GAPS_CARD_TITLE)).toBeInTheDocument();
1154
+ const rows: Array<HTMLElement> = within(list).getAllByRole("listitem");
1155
+ expect(rows).toHaveLength(2);
1156
+ expect(rows[0]).toHaveTextContent("Title of ai_balance_insufficient");
1157
+ expect(rows[0]).toHaveTextContent("Next step for ai_balance_insufficient");
1158
+ expect(rows[1]).toHaveTextContent("Title of llm_provider_missing");
1159
+ // Not ready: no ready line either.
1160
+ expect(screen.queryByTestId("ai-agent-ready")).not.toBeInTheDocument();
1161
+ });
1111
1162
 
1112
- expect(listRequestsFor(Runner).length).toBeGreaterThan(0);
1113
- expect(listRequestsFor(RunbookCredential)).toHaveLength(0);
1114
- });
1163
+ test("a cluster editor turns investigation on from its row", async () => {
1164
+ serve(
1165
+ makeStatus({
1166
+ isInvestigationEnabled: false,
1167
+ isInvestigationReady: false,
1168
+ gaps: [gap("investigation_disabled", "investigation")],
1169
+ }),
1170
+ );
1171
+ openAgentPage();
1115
1172
 
1116
- /*
1117
- * The finding (dashboard-ai-page-7): the picker offered every
1118
- * Kubernetes credential, whatever Runner it was assigned to.
1119
- */
1120
- test("offers an admin only the Kubernetes credentials assigned to the chosen Runner", async () => {
1121
- grant(ADMIN_PERMISSIONS);
1122
- serveCluster(hostRunnerBinding());
1123
- openAiPage();
1124
-
1125
- await findText(SETTINGS_CARD_TITLE);
1126
- expect(
1127
- screen.queryByTestId("kubernetes-credential-permission-note"),
1128
- ).not.toBeInTheDocument();
1129
-
1130
- const dialog: HTMLElement = await openEditModal();
1131
-
1132
- expect(
1133
- await within(dialog).findByText(
1134
- "Kubernetes credential",
1135
- {},
1136
- { timeout: WAIT_TIMEOUT },
1137
- ),
1138
- ).toBeInTheDocument();
1139
-
1140
- const requests: Array<ListRequest> = listRequestsFor(RunbookCredential);
1141
- expect(requests).toHaveLength(1);
1142
- expect(requests[0]!.query).toEqual({
1143
- credentialType: RunbookCredentialType.Kubernetes,
1144
- });
1145
- // The assignments are read so the options can follow the Runner.
1146
- expect(requests[0]!.select).toEqual(
1147
- expect.objectContaining({ runners: { _id: true } }),
1148
- );
1173
+ fireEvent.click(await findTestId("ai-agent-gap-turn-on-investigation"));
1149
1174
 
1150
- const options: Array<string> = await openDropdown(
1151
- dialog,
1152
- /^Kubernetes credential/,
1153
- );
1154
- expect(options).toEqual(["prod-east token"]);
1155
- expect(
1156
- within(dialog).getByText(/assigned to "bash-runner" are listed/),
1157
- ).toBeInTheDocument();
1175
+ expect(await waitForOneUpdate()).toEqual({
1176
+ isAiInvestigationEnabled: true,
1158
1177
  });
1178
+ expect(String(updateRequests()[0]!["id"])).toBe(CLUSTER_ID.toString());
1179
+ expect(updateRequests()[0]!["modelType"]).toBe(KubernetesCluster);
1180
+ expect(await findTestId("ai-agent-action-notice")).toHaveTextContent(
1181
+ "AI may now investigate this cluster with kubectl.",
1182
+ );
1183
+ await waitFor(
1184
+ () => {
1185
+ expect(postsTo(STATUS_ROUTE).length).toBeGreaterThanOrEqual(2);
1186
+ },
1187
+ { timeout: WAIT_TIMEOUT },
1188
+ );
1189
+ });
1159
1190
 
1160
- test("offers no credential for this cluster's in-cluster Runner, and says why", async () => {
1161
- grant(ADMIN_PERMISSIONS);
1162
- openAiPage();
1163
- const dialog: HTMLElement = await openEditModal();
1191
+ test("a failed one-click fix is shown on the page", async () => {
1192
+ serve(
1193
+ makeStatus({
1194
+ isInvestigationEnabled: false,
1195
+ isInvestigationReady: false,
1196
+ gaps: [gap("investigation_disabled", "investigation")],
1197
+ }),
1198
+ );
1199
+ updateByIdSpy.mockImplementation(async (): Promise<never> => {
1200
+ throw new HTTPErrorResponse(422, { error: "Not allowed." }, {});
1201
+ });
1202
+ openAgentPage();
1164
1203
 
1165
- expect(
1166
- await within(dialog).findByText(
1167
- /No credential can be chosen: "kubernetes-agent\/prod-east" is an in-cluster Runner/,
1168
- {},
1169
- { timeout: WAIT_TIMEOUT },
1170
- ),
1171
- ).toBeInTheDocument();
1204
+ fireEvent.click(await findTestId("ai-agent-gap-turn-on-investigation"));
1172
1205
 
1173
- // Choosing the host Runner brings its credentials back.
1174
- await pickOption(dialog, /^Runner/, "bash-runner");
1175
- expect(
1176
- await within(dialog).findByText(
1177
- /assigned to "bash-runner" are listed/,
1178
- {},
1179
- { timeout: WAIT_TIMEOUT },
1180
- ),
1181
- ).toBeInTheDocument();
1182
- const options: Array<string> = await openDropdown(
1183
- dialog,
1184
- /^Kubernetes credential/,
1185
- );
1186
- expect(options).toEqual(["prod-east token"]);
1187
- });
1206
+ expect(await findTestId("ai-agent-action-error")).toHaveTextContent(
1207
+ "Not allowed.",
1208
+ );
1209
+ });
1188
1210
 
1189
- test("refuses to save this cluster's in-cluster Runner together with a credential", async () => {
1190
- grant(ADMIN_PERMISSIONS);
1191
- serveCluster({
1192
- ...hostRunnerBinding(),
1193
- aiAccessCredential: Object.assign(new RunbookCredential(), {
1194
- _id: CREDENTIAL_ID,
1195
- name: "prod-east token",
1196
- }),
1197
- });
1198
- openAiPage();
1199
- const dialog: HTMLElement = await openEditModal();
1200
- await within(dialog).findByText("Runner", {}, { timeout: WAIT_TIMEOUT });
1201
-
1202
- await pickOption(
1203
- dialog,
1204
- /^Runner/,
1205
- "kubernetes-agent/prod-east (in-cluster Runner for this cluster)",
1206
- );
1207
- saveEditModal(dialog);
1211
+ test("a reader is told who to ask instead of getting a button", async () => {
1212
+ grant(READER_PERMISSIONS);
1213
+ serve(
1214
+ makeStatus({
1215
+ isInvestigationEnabled: false,
1216
+ isInvestigationReady: false,
1217
+ gaps: [gap("investigation_disabled", "investigation")],
1218
+ }),
1219
+ );
1220
+ openAgentPage();
1208
1221
 
1209
- const error: HTMLElement = await screen.findByTestId(
1210
- "ai-access-save-error",
1211
- {},
1212
- { timeout: WAIT_TIMEOUT },
1213
- );
1214
- expect(error).toHaveTextContent("is an in-cluster Runner");
1215
- expect(error).toHaveTextContent("Clear the Kubernetes credential");
1216
- expect(updateByIdSpy).not.toHaveBeenCalled();
1217
- });
1222
+ const row: HTMLElement = await findTestId(
1223
+ "ai-agent-gap-investigation_disabled",
1224
+ );
1225
+ expect(within(row).getByTestId("ai-agent-gap-ask")).toHaveTextContent(
1226
+ "Ask a project owner or admin.",
1227
+ );
1228
+ expect(
1229
+ screen.queryByTestId("ai-agent-gap-turn-on-investigation"),
1230
+ ).not.toBeInTheDocument();
1231
+ });
1218
1232
 
1219
- /*
1220
- * The finding: the Runner picker listed every project Runner — another
1221
- * cluster's in-cluster agent (which can only reach its own cluster, and
1222
- * which this cluster's own agent then never takes the binding back
1223
- * from) and Runners with AI commands off.
1224
- */
1225
- test("offers only Runners that can serve this cluster", async () => {
1226
- grant(ADMIN_PERMISSIONS);
1227
- openAiPage();
1228
- const dialog: HTMLElement = await openEditModal();
1229
- await within(dialog).findByText("Runner", {}, { timeout: WAIT_TIMEOUT });
1230
-
1231
- const request: ListRequest = listRequestsFor(Runner)[0]!;
1232
- expect(request.select).toEqual(
1233
- expect.objectContaining({
1234
- name: true,
1235
- canRunAiCommands: true,
1236
- // The posture tells this cluster's agent from another's.
1237
- hostInfo: true,
1238
- }),
1233
+ /*
1234
+ * Project switches live on their settings pages — never an inline
1235
+ * kill-switch here. An owner or admin gets a link; everyone else is told
1236
+ * who to ask.
1237
+ */
1238
+ test("project-level gaps link an admin to the page that fixes them", async () => {
1239
+ grant(ADMIN_PERMISSIONS);
1240
+ serve(
1241
+ makeStatus({
1242
+ isInvestigationReady: false,
1243
+ gaps: [
1244
+ gap("project_ai_disabled"),
1245
+ gap("llm_provider_missing"),
1246
+ gap("ai_balance_insufficient"),
1247
+ ],
1248
+ }),
1249
+ );
1250
+ openAgentPage();
1251
+
1252
+ const expected: Array<[string, string, string]> = [
1253
+ ["project_ai_disabled", "Open AI Features", "settings/ai-features"],
1254
+ ["llm_provider_missing", "Open LLM Providers", "settings/llm-providers"],
1255
+ ["ai_balance_insufficient", "Open AI Credits", "settings/ai-credits"],
1256
+ ];
1257
+ for (const [code, linkText, path] of expected) {
1258
+ const row: HTMLElement = await findTestId(`ai-agent-gap-${code}`);
1259
+ const link: HTMLElement = within(row).getByText(linkText);
1260
+ expect(link.closest("a")?.getAttribute("href")).toBe(
1261
+ `/dashboard/${PROJECT_ID}/${path}`,
1239
1262
  );
1263
+ }
1264
+ // No switch on this page flips a project setting.
1265
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument();
1266
+ });
1240
1267
 
1241
- const options: Array<string> = await openDropdown(dialog, /^Runner/);
1242
- expect(options).toEqual([
1243
- "kubernetes-agent/prod-east (in-cluster Runner for this cluster)",
1244
- "bash-runner",
1245
- ]);
1246
- });
1268
+ test("project-level gaps tell a member who to ask", async () => {
1269
+ serve(
1270
+ makeStatus({
1271
+ isInvestigationReady: false,
1272
+ gaps: [gap("project_auto_remediation_disabled", "remediation")],
1273
+ }),
1274
+ );
1275
+ openAgentPage();
1247
1276
 
1248
- /*
1249
- * The finding (dashboard-ai-page-2): for a cluster identifier long
1250
- * enough that the server shortens the Runner name with a hash, the
1251
- * picker hid this cluster's own Runner.
1252
- */
1253
- test("offers this cluster's in-cluster Runner when the server shortened its name", async () => {
1254
- const longIdentifier: string =
1255
- "arn:aws:eks:ap-southeast-2:123456789012:cluster/payments-platform-production-blue-green";
1256
- const shortenedName: string = `kubernetes-agent/${longIdentifier.slice(0, 74)}-fe7fc1b3`;
1257
- grant(ADMIN_PERMISSIONS);
1258
- serveStatus(makeStatus({ clusterIdentifier: longIdentifier }));
1259
- getListSpy.mockImplementation(async (args: unknown): Promise<unknown> => {
1260
- const request: ListRequest = args as ListRequest;
1261
- const data: Array<unknown> =
1262
- request.modelType === Runner
1263
- ? [
1264
- Object.assign(new Runner(), {
1265
- _id: RUNNER_ID,
1266
- name: shortenedName,
1267
- canRunAiCommands: true,
1268
- hostInfo: agentHostInfo(longIdentifier),
1269
- }),
1270
- ]
1271
- : [];
1272
- return { data, count: data.length, skip: 0, limit: 10 };
1273
- });
1274
- serveCluster({
1275
- aiAccessRunner: Object.assign(new Runner(), {
1276
- _id: RUNNER_ID,
1277
- name: shortenedName,
1278
- }),
1279
- });
1280
- openAiPage();
1281
- const dialog: HTMLElement = await openEditModal();
1282
- await within(dialog).findByText("Runner", {}, { timeout: WAIT_TIMEOUT });
1283
-
1284
- const options: Array<string> = await openDropdown(dialog, /^Runner/);
1285
- expect(options).toEqual([
1286
- `${shortenedName} (in-cluster Runner for this cluster)`,
1287
- ]);
1288
- });
1277
+ const row: HTMLElement = await findTestId(
1278
+ "ai-agent-gap-project_auto_remediation_disabled",
1279
+ );
1280
+ expect(row).toHaveTextContent("Ask a project owner or admin.");
1281
+ expect(within(row).queryByText("Open AI Features")).not.toBeInTheDocument();
1282
+ });
1289
1283
 
1290
- test("keeps a Runner bound by mistake visible, saying why it cannot serve the cluster", async () => {
1291
- grant(ADMIN_PERMISSIONS);
1292
- serveCluster({
1293
- aiAccessRunner: Object.assign(new Runner(), {
1294
- _id: OTHER_CLUSTER_RUNNER_ID,
1295
- name: "kubernetes-agent/prod-eu",
1296
- }),
1297
- });
1298
- openAiPage();
1299
- const dialog: HTMLElement = await openEditModal();
1300
- await within(dialog).findByText("Runner", {}, { timeout: WAIT_TIMEOUT });
1301
-
1302
- const options: Array<string> = await openDropdown(dialog, /^Runner/);
1303
- expect(options).toContain(
1304
- "kubernetes-agent/prod-eu (currently bound — runs inside another cluster and can only reach that cluster)",
1305
- );
1306
- expect(options).not.toContain("ops-runner");
1307
- });
1284
+ test("a failed access check offers the connection test", async () => {
1285
+ serve(
1286
+ makeStatus({
1287
+ gaps: [gap("last_access_check_failed", "both")],
1288
+ isInvestigationReady: false,
1289
+ }),
1290
+ {
1291
+ test: async (): Promise<HTTPResponse<JSONObject>> => {
1292
+ return new HTTPResponse<JSONObject>(
1293
+ 200,
1294
+ { ok: true, message: "It works.", results: [] },
1295
+ {},
1296
+ );
1297
+ },
1298
+ },
1299
+ );
1300
+ openAgentPage();
1308
1301
 
1309
- /*
1310
- * The finding: SettingsAdmin, SettingsMember and EditKubernetesCluster
1311
- * may edit the cluster but not read Runners, and the modal's Runner
1312
- * list request failed the whole form with a permission error.
1313
- */
1314
- test("never requests Runners or credentials for a Settings admin, and explains why", async () => {
1315
- grant([...BASE_PERMISSIONS, Permission.SettingsAdmin]);
1316
- openAiPage();
1317
-
1318
- expect(
1319
- await screen.findByTestId(
1320
- "kubernetes-runner-permission-note",
1321
- {},
1322
- { timeout: WAIT_TIMEOUT },
1323
- ),
1324
- ).toHaveTextContent("Read Runbook Agent");
1325
-
1326
- const dialog: HTMLElement = await openEditModal();
1327
-
1328
- expect(within(dialog).queryByText("Runner")).not.toBeInTheDocument();
1329
- expect(
1330
- within(dialog).queryByText("Kubernetes credential"),
1331
- ).not.toBeInTheDocument();
1332
- expect(
1333
- within(dialog).queryByText(/do not have permission/i),
1334
- ).not.toBeInTheDocument();
1335
- expect(within(dialog).getByText("AI remediation")).toBeInTheDocument();
1336
- expect(
1337
- within(dialog).getByTestId("ai-investigation-field"),
1338
- ).toBeInTheDocument();
1339
- expect(
1340
- within(dialog).getByTestId("kubernetes-ai-access-admin-note"),
1341
- ).toBeInTheDocument();
1342
-
1343
- expect(listRequestsFor(Runner)).toHaveLength(0);
1344
- expect(listRequestsFor(RunbookCredential)).toHaveLength(0);
1345
- });
1302
+ const row: HTMLElement = await findTestId(
1303
+ "ai-agent-gap-last_access_check_failed",
1304
+ );
1305
+ fireEvent.click(within(row).getByText("Test connection"));
1346
1306
 
1347
- test("an admin-set holder who may read credentials but not Runners gets the credential picker, and may unbind the Runner", async () => {
1348
- grant([
1349
- ...BASE_PERMISSIONS,
1350
- Permission.EditKubernetesCluster,
1351
- Permission.ReadKubernetesCluster,
1352
- Permission.EditAutoRemediationRule,
1353
- Permission.ReadRunbookCredential,
1354
- ]);
1355
- openAiPage();
1356
- const dialog: HTMLElement = await openEditModal();
1357
-
1358
- expect(
1359
- await within(dialog).findByText(
1360
- "Kubernetes credential",
1361
- {},
1362
- { timeout: WAIT_TIMEOUT },
1363
- ),
1364
- ).toBeInTheDocument();
1365
- expect(within(dialog).queryByText("Runner")).not.toBeInTheDocument();
1366
- expect(
1367
- within(dialog).getByTestId("kubernetes-runner-picker-permission-note"),
1368
- ).toHaveTextContent("Read Runbook Agent");
1369
- expect(
1370
- within(dialog).getByTestId("ai-access-clear-runner-field"),
1371
- ).toBeInTheDocument();
1372
- expect(listRequestsFor(RunbookCredential)).toHaveLength(1);
1373
- expect(listRequestsFor(Runner)).toHaveLength(0);
1374
- });
1307
+ await waitFor(
1308
+ () => {
1309
+ expect(postsTo(TEST_ROUTE)).toHaveLength(1);
1310
+ },
1311
+ { timeout: WAIT_TIMEOUT },
1312
+ );
1313
+ expect(await findText("The connection works")).toBeInTheDocument();
1314
+ });
1375
1315
 
1376
- test("one picker's failed list leaves the other picker and the form working", async () => {
1377
- grant(ADMIN_PERMISSIONS);
1378
- const listImplementation: (args: unknown) => Promise<unknown> =
1379
- getListSpy.getMockImplementation() as (
1380
- args: unknown,
1381
- ) => Promise<unknown>;
1382
- getListSpy.mockImplementation(async (args: unknown): Promise<unknown> => {
1383
- if ((args as ListRequest).modelType === Runner) {
1384
- throw new Error("You do not have permissions to read Runner.");
1385
- }
1386
- return await listImplementation(args);
1387
- });
1388
- openAiPage();
1389
- const dialog: HTMLElement = await openEditModal();
1390
-
1391
- expect(
1392
- await within(dialog).findByText(
1393
- "Kubernetes credential",
1394
- {},
1395
- { timeout: WAIT_TIMEOUT },
1396
- ),
1397
- ).toBeInTheDocument();
1398
- expect(within(dialog).queryByText("Runner")).not.toBeInTheDocument();
1399
- expect(
1400
- within(dialog).getByText(/The Runner list could not be loaded/),
1401
- ).toBeInTheDocument();
1402
- });
1316
+ test("an advanced Runner's gap links to the Runner", async () => {
1317
+ serve(
1318
+ advancedStatus({
1319
+ runner: { ...advancedStatus().runner!, isOnline: false },
1320
+ isInvestigationReady: false,
1321
+ gaps: [gap("runner_offline")],
1322
+ }),
1323
+ );
1324
+ openAgentPage();
1325
+
1326
+ const row: HTMLElement = await findTestId("ai-agent-gap-runner_offline");
1327
+ expect(
1328
+ within(row).getByText("View Runner").closest("a")?.getAttribute("href"),
1329
+ ).toBe(`/dashboard/${PROJECT_ID}/settings/runners/${HOST_RUNNER_ID}`);
1330
+ });
1331
+
1332
+ test("the not-installed gap has no extra action: the command is on the card", async () => {
1333
+ serve(notInstalledStatus());
1334
+ openAgentPage();
1335
+
1336
+ const row: HTMLElement = await findTestId(
1337
+ "ai-agent-gap-ai_agent_not_connected",
1338
+ );
1339
+ expect(within(row).queryByRole("button")).not.toBeInTheDocument();
1340
+ expect(within(row).queryByRole("link")).not.toBeInTheDocument();
1341
+ });
1342
+ });
1343
+
1344
+ describe("What AI may do", () => {
1345
+ test("shows investigation and fixes in plain words, with the fixes-off hint", async () => {
1346
+ openAgentPage();
1347
+
1348
+ expect(await findTestId("ai-access-investigation-value")).toHaveTextContent(
1349
+ "Yes — read-only",
1350
+ );
1351
+ expect(screen.getByTestId("ai-access-fixes-value")).toHaveTextContent(
1352
+ `Off — ${REMEDIATION_MODE_SUMMARIES[KubernetesAiRemediationMode.Disabled]}`,
1353
+ );
1354
+ expect(screen.getByTestId("ai-access-fixes-off-hint")).toHaveTextContent(
1355
+ "Want AI to propose fixes? Choose Ask for approval.",
1356
+ );
1357
+ // Off: no write commands, no allowlist.
1358
+ expect(
1359
+ screen.queryByTestId("ai-access-write-commands"),
1360
+ ).not.toBeInTheDocument();
1361
+ expect(
1362
+ screen.queryByTestId("kubectl-allowlist-in-effect"),
1363
+ ).not.toBeInTheDocument();
1364
+ });
1365
+
1366
+ test("investigation off says so", async () => {
1367
+ serve(
1368
+ makeStatus({
1369
+ isInvestigationEnabled: false,
1370
+ isInvestigationReady: false,
1371
+ }),
1372
+ );
1373
+ openAgentPage();
1374
+
1375
+ expect(await findTestId("ai-access-investigation-value")).toHaveTextContent(
1376
+ "No — AI investigates with OneUptime data only",
1377
+ );
1403
1378
  });
1404
1379
 
1405
1380
  /*
1406
- * The finding: every cluster editor — SettingsMember, SettingsAdmin,
1407
- * EditKubernetesCluster, ProjectMember — could switch a cluster to
1408
- * Bypass approval, or Automatic with a "kubectl *" allowlist, and so
1409
- * remove an approval they could not give. The server refuses those
1410
- * writes without KUBERNETES_AI_ACCESS_ADMIN_PERMISSIONS; the page must
1411
- * not offer them, and must not re-send them unchanged either.
1381
+ * The write-access command appears only once fixes are on and the agent
1382
+ * is read-only — never on a default install.
1412
1383
  */
1413
- describe("who may loosen AI access", () => {
1414
- test("a member is offered only Off and Ask for approval on an Ask-for-approval cluster, and told why", async () => {
1415
- openAiPage();
1416
-
1417
- expect(
1418
- await screen.findByTestId(
1419
- "kubernetes-ai-access-admin-note",
1420
- {},
1421
- { timeout: WAIT_TIMEOUT },
1422
- ),
1423
- ).toHaveTextContent("Edit Auto Remediation Rule");
1424
-
1425
- const dialog: HTMLElement = await openEditModal();
1426
-
1427
- expect(
1428
- within(dialog).getByTestId("kubernetes-ai-access-admin-note"),
1429
- ).toHaveTextContent("Project Admin");
1430
- // Nothing saved to remove: no allowlist field.
1431
- expect(
1432
- within(dialog).queryByText("kubectl allowlist (Automatic mode)"),
1433
- ).not.toBeInTheDocument();
1434
- expect(within(dialog).queryByText("Runner")).not.toBeInTheDocument();
1435
-
1436
- const options: Array<string> = await openDropdown(
1437
- dialog,
1438
- /^AI remediation/,
1439
- );
1440
- expect(options).toEqual([
1441
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Disabled],
1442
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.RequireApproval],
1443
- ]);
1444
- expect(listRequestsFor(Runner)).toHaveLength(0);
1445
- expect(listRequestsFor(RunbookCredential)).toHaveLength(0);
1446
- });
1384
+ test("fixes on with a read-only agent: the scoped command, the cluster-wide one and the disclosure", async () => {
1385
+ serve(
1386
+ makeStatus({
1387
+ remediationMode: KubernetesAiRemediationMode.RequireApproval,
1388
+ gaps: [gap("remediation_write_access_missing", "remediation")],
1389
+ }),
1390
+ );
1391
+ openAgentPage();
1447
1392
 
1448
- test("an admin is offered every mode and the allowlist", async () => {
1449
- grant(ADMIN_PERMISSIONS);
1450
- openAiPage();
1451
- await findText(SETTINGS_CARD_TITLE);
1452
- expect(
1453
- screen.queryByTestId("kubernetes-ai-access-admin-note"),
1454
- ).not.toBeInTheDocument();
1455
-
1456
- const dialog: HTMLElement = await openEditModal();
1457
- expect(
1458
- await within(dialog).findByText(
1459
- "kubectl allowlist (Automatic mode)",
1460
- {},
1461
- { timeout: WAIT_TIMEOUT },
1462
- ),
1463
- ).toBeInTheDocument();
1393
+ const section: HTMLElement = await findTestId("ai-access-write-commands");
1394
+ expect(section).toHaveTextContent("Give the agent write access");
1395
+ expect(
1396
+ codeIn(screen.getByTestId("ai-access-helm-remediation-scoped-command")),
1397
+ ).toBe(getAiAgentHelmCommands().enableRemediationScoped);
1398
+ expect(
1399
+ codeIn(screen.getByTestId("ai-access-helm-remediation-command")),
1400
+ ).toBe(getAiAgentHelmCommands().enableRemediation);
1401
+ expect(screen.getByTestId("ai-access-write-disclosure")).toHaveTextContent(
1402
+ getAiAgentWriteDisclosure(),
1403
+ );
1404
+ expect(
1405
+ screen.queryByTestId("ai-access-fixes-off-hint"),
1406
+ ).not.toBeInTheDocument();
1407
+ });
1464
1408
 
1465
- const options: Array<string> = await openDropdown(
1466
- dialog,
1467
- /^AI remediation/,
1468
- );
1469
- expect(options).toEqual(
1470
- Object.values(KubernetesAiRemediationMode).map(
1471
- (mode: KubernetesAiRemediationMode): string => {
1472
- return REMEDIATION_MODE_LABELS[mode];
1409
+ test("an agent that already writes gets no command, and says where it may change", async () => {
1410
+ serve(
1411
+ makeStatus(
1412
+ { remediationMode: KubernetesAiRemediationMode.RequireApproval },
1413
+ makeAgent({
1414
+ posture: {
1415
+ inCluster: true,
1416
+ allowWrites: true,
1417
+ writeNamespaces: ["web", "api"],
1418
+ allowNodeOperations: false,
1419
+ kubectlVersion: "v1.31.2",
1473
1420
  },
1474
- ),
1475
- );
1476
- });
1421
+ }),
1422
+ ),
1423
+ );
1424
+ openAgentPage();
1425
+
1426
+ expect(await findTestId("ai-agent-meta")).toHaveTextContent(
1427
+ "Can change: web, api · node operations off",
1428
+ );
1429
+ expect(
1430
+ screen.queryByTestId("ai-access-write-commands"),
1431
+ ).not.toBeInTheDocument();
1432
+ });
1477
1433
 
1478
- test("a member on an Automatic cluster sees the current mode but no higher one", async () => {
1479
- serveCluster({
1480
- aiRemediationMode: KubernetesAiRemediationMode.Automatic,
1481
- });
1482
- openAiPage();
1483
- const dialog: HTMLElement = await openEditModal();
1434
+ test("an advanced Runner is bounded by its credential, not the chart", async () => {
1435
+ serve(
1436
+ advancedStatus({
1437
+ remediationMode: KubernetesAiRemediationMode.Automatic,
1438
+ }),
1439
+ );
1440
+ openAgentPage();
1484
1441
 
1485
- const options: Array<string> = await openDropdown(
1486
- dialog,
1487
- /^AI remediation/,
1488
- );
1489
- expect(options).toEqual([
1490
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Disabled],
1491
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.RequireApproval],
1492
- `${REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Automatic]} (current)`,
1493
- ]);
1494
- });
1442
+ expect(
1443
+ await findTestId("ai-access-credential-rbac-note"),
1444
+ ).toHaveTextContent("limited by the Runner's credential");
1445
+ expect(
1446
+ screen.queryByTestId("ai-access-write-commands"),
1447
+ ).not.toBeInTheDocument();
1448
+ });
1495
1449
 
1496
- /*
1497
- * The finding (dashboard-ai-page-4, XP-7): Bypass approval -> Automatic
1498
- * is a tightening the server accepts from every cluster editor, and the
1499
- * page hid Automatic from them.
1500
- */
1501
- test("a member on a Bypass-approval cluster may step down to Automatic", async () => {
1502
- serveCluster({
1503
- aiRemediationMode: KubernetesAiRemediationMode.BypassApproval,
1504
- });
1505
- openAiPage();
1506
- const dialog: HTMLElement = await openEditModal();
1507
-
1508
- const options: Array<string> = await openDropdown(
1509
- dialog,
1510
- /^AI remediation/,
1511
- );
1512
- expect(options).toEqual([
1513
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Disabled],
1514
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.RequireApproval],
1515
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Automatic],
1516
- `${REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.BypassApproval]} (current)`,
1517
- ]);
1518
-
1519
- fireEvent.click(
1520
- menuOptions().find((option: HTMLElement): boolean => {
1521
- return (
1522
- option.textContent ===
1523
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Automatic]
1524
- );
1525
- })!,
1526
- );
1527
- saveEditModal(dialog);
1528
-
1529
- expect(await waitForOneUpdate()).toEqual({
1530
- aiRemediationMode: KubernetesAiRemediationMode.Automatic,
1531
- });
1532
- expect(
1533
- screen.queryByTestId("ai-access-save-error"),
1534
- ).not.toBeInTheDocument();
1535
- // A step down is not a new risk: no confirmation either.
1536
- expect(
1537
- screen.queryByText("Let riskier changes run without approval?"),
1538
- ).not.toBeInTheDocument();
1539
- });
1450
+ test("the allowlist in effect is listed in Automatic mode only", async () => {
1451
+ serve(
1452
+ makeStatus({
1453
+ remediationMode: KubernetesAiRemediationMode.Automatic,
1454
+ kubectlAllowlist: [SET_IMAGE_PATTERN],
1455
+ }),
1456
+ );
1457
+ openAgentPage();
1540
1458
 
1541
- test("a member may remove an allowlist pattern", async () => {
1542
- serveCluster({
1543
- aiRemediationMode: KubernetesAiRemediationMode.Automatic,
1544
- aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN, PATCH_PATTERN],
1545
- });
1546
- openAiPage();
1547
- const dialog: HTMLElement = await openEditModal();
1548
-
1549
- expect(
1550
- within(dialog).getByText(/You can remove patterns or clear the list/),
1551
- ).toBeInTheDocument();
1552
- await setAllowlistText(dialog, SET_IMAGE_PATTERN);
1553
- saveEditModal(dialog);
1554
-
1555
- expect(await waitForOneUpdate()).toEqual({
1556
- aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN],
1557
- });
1558
- });
1459
+ expect(await findTestId("kubectl-allowlist-in-effect")).toHaveTextContent(
1460
+ SET_IMAGE_PATTERN,
1461
+ );
1462
+ cleanup();
1559
1463
 
1560
- test("a member may clear the allowlist", async () => {
1561
- serveCluster({
1562
- aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN],
1563
- });
1564
- openAiPage();
1565
- const dialog: HTMLElement = await openEditModal();
1464
+ serve(
1465
+ makeStatus({
1466
+ remediationMode: KubernetesAiRemediationMode.BypassApproval,
1467
+ kubectlAllowlist: [SET_IMAGE_PATTERN],
1468
+ }),
1469
+ );
1470
+ openAgentPage();
1471
+ expect(await findTestId("ai-access-fixes-value")).toHaveTextContent(
1472
+ "Bypass approval",
1473
+ );
1474
+ expect(
1475
+ screen.queryByTestId("kubectl-allowlist-in-effect"),
1476
+ ).not.toBeInTheDocument();
1477
+ });
1566
1478
 
1567
- await setAllowlistText(dialog, "");
1568
- saveEditModal(dialog);
1479
+ test("an empty allowlist in Automatic mode reads None", async () => {
1480
+ serve(
1481
+ makeStatus({ remediationMode: KubernetesAiRemediationMode.Automatic }),
1482
+ );
1483
+ openAgentPage();
1569
1484
 
1570
- expect(await waitForOneUpdate()).toEqual({
1571
- aiKubectlCommandAllowlist: [],
1572
- });
1573
- });
1485
+ expect(await findTestId("kubectl-allowlist-in-effect")).toHaveTextContent(
1486
+ "None",
1487
+ );
1488
+ });
1574
1489
 
1575
- test("a member who adds or edits a pattern is told it needs the admin set, and nothing is sent", async () => {
1576
- serveCluster({
1577
- aiRemediationMode: KubernetesAiRemediationMode.Automatic,
1578
- aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN],
1579
- });
1580
- openAiPage();
1581
- const dialog: HTMLElement = await openEditModal();
1582
-
1583
- await setAllowlistText(
1584
- dialog,
1585
- "kubectl set image deployment/web * -n prod",
1586
- );
1587
- saveEditModal(dialog);
1490
+ test("the Change button is locked, with the reason, for a reader", async () => {
1491
+ grant(READER_PERMISSIONS);
1492
+ openAgentPage();
1588
1493
 
1589
- expect(
1590
- await within(dialog).findByText(
1591
- /is not in the saved allowlist/,
1592
- {},
1593
- { timeout: WAIT_TIMEOUT },
1594
- ),
1595
- ).toHaveTextContent("Edit Auto Remediation Rule");
1596
- expect(updateByIdSpy).not.toHaveBeenCalled();
1597
- });
1494
+ const button: HTMLElement = await findTestId("ai-access-change-button");
1495
+ expect(button).toBeDisabled();
1496
+ fireEvent.click(button);
1497
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
1498
+ });
1499
+ });
1598
1500
 
1599
- /*
1600
- * The finding (SIA-4): the server and the gap copy tell every cluster
1601
- * editor they may clear the Runner, and the page offered them no way
1602
- * to — Settings roles and EditKubernetesCluster cannot even list
1603
- * Runners.
1604
- */
1605
- for (const [role, permissions] of [
1606
- ["a member", MEMBER_PERMISSIONS],
1607
- ["a Settings member", [...BASE_PERMISSIONS, Permission.SettingsMember]],
1608
- [
1609
- "an EditKubernetesCluster editor",
1610
- [
1611
- ...BASE_PERMISSIONS,
1612
- Permission.EditKubernetesCluster,
1613
- Permission.ReadKubernetesCluster,
1614
- ],
1615
- ],
1616
- ] as Array<[string, Array<Permission>]>) {
1617
- test(`${role} may unbind the Runner without listing Runners`, async () => {
1618
- grant(permissions);
1619
- openAiPage();
1620
- const dialog: HTMLElement = await openEditModal();
1501
+ describe("automatic investigation footer", () => {
1502
+ test("reads On for both and offers nothing more", async () => {
1503
+ grant(ADMIN_PERMISSIONS);
1504
+ openAgentPage();
1621
1505
 
1622
- expect(
1623
- within(dialog).getByText(/Bound now: kubernetes-agent\/prod-east/),
1624
- ).toBeInTheDocument();
1625
- await toggleSwitch(dialog, "ai-access-clear-runner-field", false);
1626
- saveEditModal(dialog);
1627
-
1628
- expect(await waitForOneUpdate()).toEqual({ aiAccessRunnerId: null });
1629
- expect(listRequestsFor(Runner)).toHaveLength(0);
1630
- });
1631
- }
1506
+ const footer: HTMLElement = await findTestId(
1507
+ "ai-access-automatic-investigation",
1508
+ );
1509
+ expect(footer).toHaveTextContent(
1510
+ "Automatic investigation for new incidents in this project: On · alerts: On",
1511
+ );
1512
+ expect(
1513
+ within(footer).queryByTestId("ai-access-automatic-investigation-turn-on"),
1514
+ ).not.toBeInTheDocument();
1515
+ });
1632
1516
 
1633
- test("a member may unbind the credential", async () => {
1634
- serveCluster({
1635
- ...hostRunnerBinding(),
1636
- aiAccessCredential: Object.assign(new RunbookCredential(), {
1637
- _id: CREDENTIAL_ID,
1638
- name: "prod-east token",
1639
- }),
1640
- });
1641
- openAiPage();
1642
- const dialog: HTMLElement = await openEditModal();
1517
+ /*
1518
+ * The opt-in is project-wide. It is never flipped inline: an owner or
1519
+ * admin confirms, told it applies to every incident in the project and
1520
+ * where its limits live.
1521
+ */
1522
+ test("an admin turns it on after a confirmation that says it is project-wide", async () => {
1523
+ grant(ADMIN_PERMISSIONS);
1524
+ serve(
1525
+ makeStatus({
1526
+ automaticInvestigation: { incidents: false, alerts: false },
1527
+ }),
1528
+ );
1529
+ openAgentPage();
1643
1530
 
1644
- await toggleSwitch(dialog, "ai-access-clear-credential-field", false);
1645
- saveEditModal(dialog);
1531
+ expect(
1532
+ await findTestId("ai-access-automatic-investigation"),
1533
+ ).toHaveTextContent(
1534
+ "Automatic investigation for new incidents in this project: Off · alerts: Off",
1535
+ );
1536
+ fireEvent.click(
1537
+ screen.getByTestId("ai-access-automatic-investigation-turn-on"),
1538
+ );
1646
1539
 
1647
- expect(await waitForOneUpdate()).toEqual({
1648
- aiAccessCredentialId: null,
1649
- });
1650
- expect(listRequestsFor(RunbookCredential)).toHaveLength(0);
1540
+ const confirm: HTMLElement = await findDialogTitled(
1541
+ "Turn on automatic investigation?",
1542
+ );
1543
+ expect(confirm).toHaveTextContent(
1544
+ "This applies to every new incident and alert in Acme, not just this cluster. Limits live under Incidents → Settings → AI.",
1545
+ );
1546
+ expect(
1547
+ within(confirm)
1548
+ .getByText("Open settings")
1549
+ .closest("a")
1550
+ ?.getAttribute("href"),
1551
+ ).toBe(`/dashboard/${PROJECT_ID}/incidents/settings/ai`);
1552
+ expect(updateByIdSpy).not.toHaveBeenCalled();
1553
+
1554
+ fireEvent.click(within(confirm).getByText("Turn on"));
1555
+
1556
+ expect(await waitForOneUpdate()).toEqual({
1557
+ enableAutomaticIncidentInvestigation: true,
1558
+ enableAutomaticAlertInvestigation: true,
1651
1559
  });
1560
+ expect(updateRequests()[0]!["modelType"]).toBe(Project);
1561
+ expect(String(updateRequests()[0]!["id"])).toBe(PROJECT_ID);
1562
+ expect(await findTestId("ai-agent-action-notice")).toHaveTextContent(
1563
+ "Automatic investigation is on for this project.",
1564
+ );
1565
+ });
1566
+
1567
+ test("turning it on writes only the flag that is off", async () => {
1568
+ grant(ADMIN_PERMISSIONS);
1569
+ serve(
1570
+ makeStatus({
1571
+ automaticInvestigation: { incidents: true, alerts: false },
1572
+ }),
1573
+ );
1574
+ openAgentPage();
1652
1575
 
1653
- test("with nothing bound, there is nothing to unbind", async () => {
1654
- serveCluster({ aiAccessRunner: undefined });
1655
- openAiPage();
1656
- const dialog: HTMLElement = await openEditModal();
1657
-
1658
- expect(
1659
- within(dialog).queryByTestId("ai-access-clear-runner-field"),
1660
- ).not.toBeInTheDocument();
1661
- expect(
1662
- within(dialog).queryByTestId("ai-access-clear-credential-field"),
1663
- ).not.toBeInTheDocument();
1576
+ fireEvent.click(
1577
+ await findTestId("ai-access-automatic-investigation-turn-on"),
1578
+ );
1579
+ const confirm: HTMLElement = await findDialogTitled(
1580
+ "Turn on automatic investigation?",
1581
+ );
1582
+ expect(confirm).toHaveTextContent("every new alert in Acme");
1583
+ fireEvent.click(within(confirm).getByText("Turn on"));
1584
+
1585
+ expect(await waitForOneUpdate()).toEqual({
1586
+ enableAutomaticAlertInvestigation: true,
1664
1587
  });
1588
+ });
1665
1589
 
1666
- /*
1667
- * ModelForm submitted every field, so a member who only switched
1668
- * investigation off on an Automatic cluster with an allowlist and a
1669
- * bound Runner re-sent all three — and the server refused the save of
1670
- * a change they were allowed to make.
1671
- */
1672
- test("a member who only switches investigation off sends only that", async () => {
1673
- serveCluster({
1674
- aiRemediationMode: KubernetesAiRemediationMode.Automatic,
1675
- aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN],
1676
- });
1677
- openAiPage();
1678
- const dialog: HTMLElement = await openEditModal();
1679
-
1680
- await toggleInvestigation(dialog, true);
1681
- saveEditModal(dialog);
1682
-
1683
- await waitFor(
1684
- () => {
1685
- expect(updateByIdSpy).toHaveBeenCalledTimes(1);
1686
- },
1687
- { timeout: WAIT_TIMEOUT },
1590
+ test("a refused opt-in stays in its dialog with the reason", async () => {
1591
+ grant(ADMIN_PERMISSIONS);
1592
+ serve(
1593
+ makeStatus({
1594
+ automaticInvestigation: { incidents: false, alerts: true },
1595
+ }),
1596
+ );
1597
+ updateByIdSpy.mockImplementation(async (): Promise<never> => {
1598
+ throw new HTTPErrorResponse(
1599
+ 422,
1600
+ { error: "You do not have permission to update this project." },
1601
+ {},
1688
1602
  );
1689
- const request: JSONObject = updateRequests()[0]!;
1690
- expect(request["data"]).toEqual({ isAiInvestigationEnabled: false });
1691
- expect(String(request["id"])).toBe(CLUSTER_ID.toString());
1692
- expect(request["modelType"]).toBe(KubernetesCluster);
1693
-
1694
- /*
1695
- * Saved: the modal closes and the status is read again. The re-read
1696
- * is a passive effect of the same state update that closes the
1697
- * modal, so it can land a tick after the dialog is gone: wait for
1698
- * it, rather than assume it has already run (a suite run once saw
1699
- * one status post here).
1700
- */
1701
- await waitFor(
1702
- () => {
1703
- expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
1704
- },
1603
+ });
1604
+ openAgentPage();
1605
+
1606
+ fireEvent.click(
1607
+ await findTestId("ai-access-automatic-investigation-turn-on"),
1608
+ );
1609
+ const confirm: HTMLElement = await findDialogTitled(
1610
+ "Turn on automatic investigation?",
1611
+ );
1612
+ fireEvent.click(within(confirm).getByText("Turn on"));
1613
+
1614
+ expect(
1615
+ await within(confirm).findByText(
1616
+ "You do not have permission to update this project.",
1617
+ {},
1705
1618
  { timeout: WAIT_TIMEOUT },
1706
- );
1707
- await waitFor(
1708
- () => {
1709
- expect(
1710
- postsTo("/kubernetes-cluster/ai-access/status").length,
1711
- ).toBeGreaterThanOrEqual(2);
1619
+ ),
1620
+ ).toBeInTheDocument();
1621
+ expect(
1622
+ screen.getByText("Turn on automatic investigation?"),
1623
+ ).toBeInTheDocument();
1624
+ });
1625
+
1626
+ test("a member is told who to ask", async () => {
1627
+ serve(
1628
+ makeStatus({
1629
+ automaticInvestigation: { incidents: false, alerts: false },
1630
+ }),
1631
+ );
1632
+ openAgentPage();
1633
+
1634
+ expect(
1635
+ await findTestId("ai-access-automatic-investigation-ask"),
1636
+ ).toHaveTextContent("Ask a project owner or admin.");
1637
+ expect(
1638
+ screen.queryByTestId("ai-access-automatic-investigation-turn-on"),
1639
+ ).not.toBeInTheDocument();
1640
+ });
1641
+ });
1642
+
1643
+ describe("the Change modal: who may loosen", () => {
1644
+ test("a member on an Ask-for-approval cluster is offered only Off and the current mode, and told why", async () => {
1645
+ openAgentPage();
1646
+ const dialog: HTMLElement = await openChangeModal();
1647
+
1648
+ expect(
1649
+ within(dialog).getByTestId("kubernetes-ai-access-admin-note"),
1650
+ ).toHaveTextContent("Edit Auto Remediation Rule");
1651
+ // Nothing saved to remove: no allowlist field; no Runner either.
1652
+ expect(
1653
+ within(dialog).queryByTestId("kubectl-allowlist-field"),
1654
+ ).not.toBeInTheDocument();
1655
+ expect(within(dialog).queryByText("Runner")).not.toBeInTheDocument();
1656
+
1657
+ expect(await openDropdown(dialog, /^Fixes/)).toEqual([
1658
+ REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Disabled],
1659
+ `${REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.RequireApproval]} (current)`,
1660
+ ]);
1661
+ expect(listRequestsFor(Runner)).toHaveLength(0);
1662
+ expect(listRequestsFor(RunbookCredential)).toHaveLength(0);
1663
+ });
1664
+
1665
+ /*
1666
+ * Regression (§11): Off -> Ask for approval used to be open to every
1667
+ * cluster editor. Turning fixes on at all now needs the admin set.
1668
+ */
1669
+ test("a member on an Off cluster cannot turn fixes on", async () => {
1670
+ serveCluster({ aiRemediationMode: KubernetesAiRemediationMode.Disabled });
1671
+ openAgentPage();
1672
+ const dialog: HTMLElement = await openChangeModal();
1673
+
1674
+ expect(await openDropdown(dialog, /^Fixes/)).toEqual([
1675
+ REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Disabled],
1676
+ ]);
1677
+ });
1678
+
1679
+ test("an admin is offered every mode", async () => {
1680
+ grant(ADMIN_PERMISSIONS);
1681
+ openAgentPage();
1682
+ const dialog: HTMLElement = await openChangeModal();
1683
+
1684
+ expect(
1685
+ within(dialog).queryByTestId("kubernetes-ai-access-admin-note"),
1686
+ ).not.toBeInTheDocument();
1687
+ expect(await openDropdown(dialog, /^Fixes/)).toEqual(
1688
+ Object.values(KubernetesAiRemediationMode).map(
1689
+ (mode: KubernetesAiRemediationMode): string => {
1690
+ return REMEDIATION_MODE_LABELS[mode];
1712
1691
  },
1713
- { timeout: WAIT_TIMEOUT },
1714
- );
1692
+ ),
1693
+ );
1694
+ });
1695
+
1696
+ test("an admin turns fixes on from Off", async () => {
1697
+ grant(ADMIN_PERMISSIONS);
1698
+ serveCluster({ aiRemediationMode: KubernetesAiRemediationMode.Disabled });
1699
+ openAgentPage();
1700
+ const dialog: HTMLElement = await openChangeModal();
1701
+
1702
+ await pickOption(
1703
+ dialog,
1704
+ /^Fixes/,
1705
+ REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.RequireApproval],
1706
+ );
1707
+ saveChangeModal(dialog);
1708
+
1709
+ expect(await waitForOneUpdate()).toEqual({
1710
+ aiRemediationMode: KubernetesAiRemediationMode.RequireApproval,
1715
1711
  });
1712
+ });
1716
1713
 
1717
- test("a member can still switch remediation off", async () => {
1718
- serveCluster({
1719
- aiRemediationMode: KubernetesAiRemediationMode.Automatic,
1720
- });
1721
- openAiPage();
1722
- const dialog: HTMLElement = await openEditModal();
1723
-
1724
- await pickOption(
1725
- dialog,
1726
- /^AI remediation/,
1727
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Disabled],
1728
- );
1729
- saveEditModal(dialog);
1714
+ test("a member on a Bypass-approval cluster may step down to Automatic", async () => {
1715
+ serveCluster({
1716
+ aiRemediationMode: KubernetesAiRemediationMode.BypassApproval,
1717
+ });
1718
+ openAgentPage();
1719
+ const dialog: HTMLElement = await openChangeModal();
1730
1720
 
1731
- expect(await waitForOneUpdate()).toEqual({
1732
- aiRemediationMode: KubernetesAiRemediationMode.Disabled,
1733
- });
1721
+ expect(await openDropdown(dialog, /^Fixes/)).toEqual([
1722
+ REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Disabled],
1723
+ REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.RequireApproval],
1724
+ REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Automatic],
1725
+ `${REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.BypassApproval]} (current)`,
1726
+ ]);
1727
+
1728
+ fireEvent.click(
1729
+ menuOptions().find((option: HTMLElement): boolean => {
1730
+ return (
1731
+ option.textContent ===
1732
+ REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.Automatic]
1733
+ );
1734
+ })!,
1735
+ );
1736
+ saveChangeModal(dialog);
1737
+
1738
+ expect(await waitForOneUpdate()).toEqual({
1739
+ aiRemediationMode: KubernetesAiRemediationMode.Automatic,
1734
1740
  });
1741
+ // A step down is not a new risk: no confirmation either.
1742
+ expect(
1743
+ screen.queryByText("Let riskier changes run without approval?"),
1744
+ ).not.toBeInTheDocument();
1745
+ });
1735
1746
 
1736
- test("an untouched form saves nothing", async () => {
1737
- grant(ADMIN_PERMISSIONS);
1738
- openAiPage();
1739
- const dialog: HTMLElement = await openEditModal();
1740
- await within(dialog).findByText("Runner", {}, { timeout: WAIT_TIMEOUT });
1747
+ test("a member may remove an allowlist pattern", async () => {
1748
+ serveCluster({
1749
+ aiRemediationMode: KubernetesAiRemediationMode.Automatic,
1750
+ aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN, PATCH_PATTERN],
1751
+ });
1752
+ openAgentPage();
1753
+ const dialog: HTMLElement = await openChangeModal();
1741
1754
 
1742
- saveEditModal(dialog);
1755
+ expect(
1756
+ within(dialog).getByText(/You can remove patterns or clear the list/),
1757
+ ).toBeInTheDocument();
1758
+ await setAllowlistText(dialog, SET_IMAGE_PATTERN);
1759
+ saveChangeModal(dialog);
1743
1760
 
1744
- await waitFor(
1745
- () => {
1746
- expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
1747
- },
1748
- { timeout: WAIT_TIMEOUT },
1749
- );
1750
- expect(updateByIdSpy).not.toHaveBeenCalled();
1761
+ expect(await waitForOneUpdate()).toEqual({
1762
+ aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN],
1751
1763
  });
1764
+ });
1752
1765
 
1753
- test("a server refusal is shown and the modal stays open", async () => {
1754
- updateByIdSpy.mockImplementation(async (): Promise<never> => {
1755
- throw new HTTPErrorResponse(
1756
- 422,
1757
- {
1758
- error: "You do not have permission to switch on unattended fixes.",
1759
- },
1760
- {},
1761
- );
1762
- });
1763
- openAiPage();
1764
- const dialog: HTMLElement = await openEditModal();
1766
+ test("a member who adds a pattern is told it needs the admin set, and nothing is sent", async () => {
1767
+ serveCluster({
1768
+ aiRemediationMode: KubernetesAiRemediationMode.Automatic,
1769
+ aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN],
1770
+ });
1771
+ openAgentPage();
1772
+ const dialog: HTMLElement = await openChangeModal();
1765
1773
 
1766
- await toggleInvestigation(dialog, true);
1767
- saveEditModal(dialog);
1774
+ await setAllowlistText(
1775
+ dialog,
1776
+ `${SET_IMAGE_PATTERN}\nkubectl set image deployment/web * -n prod`,
1777
+ );
1778
+ saveChangeModal(dialog);
1768
1779
 
1769
- const error: HTMLElement = await screen.findByTestId(
1770
- "ai-access-save-error",
1780
+ expect(
1781
+ await within(dialog).findByText(
1782
+ /is not in the saved allowlist/,
1771
1783
  {},
1772
1784
  { timeout: WAIT_TIMEOUT },
1773
- );
1774
- expect(error).toHaveTextContent(
1775
- "You do not have permission to switch on unattended fixes.",
1776
- );
1777
- expect(screen.getByRole("dialog")).toBe(dialog);
1778
- });
1785
+ ),
1786
+ ).toHaveTextContent("Edit Auto Remediation Rule");
1787
+ expect(updateByIdSpy).not.toHaveBeenCalled();
1779
1788
  });
1780
1789
 
1781
- describe("confirming unattended riskier changes", () => {
1782
- test("Bypass approval is saved only after a confirmation that names what it unlocks", async () => {
1783
- grant(ADMIN_PERMISSIONS);
1784
- openAiPage();
1785
- const dialog: HTMLElement = await openEditModal();
1790
+ test("the allowlist field is shown only while Automatic is chosen", async () => {
1791
+ grant(ADMIN_PERMISSIONS);
1792
+ serveCluster({ aiRemediationMode: KubernetesAiRemediationMode.Automatic });
1793
+ openAgentPage();
1794
+ const dialog: HTMLElement = await openChangeModal();
1786
1795
 
1787
- await pickOption(
1788
- dialog,
1789
- /^AI remediation/,
1790
- REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.BypassApproval],
1791
- );
1792
- saveEditModal(dialog);
1796
+ expect(
1797
+ await within(dialog).findByTestId(
1798
+ "kubectl-allowlist-field",
1799
+ {},
1800
+ { timeout: WAIT_TIMEOUT },
1801
+ ),
1802
+ ).toBeInTheDocument();
1793
1803
 
1794
- const confirm: HTMLElement = await findDialogTitled(
1795
- "Turn on Bypass approval?",
1796
- );
1797
- expect(confirm).toHaveTextContent("set image");
1798
- expect(confirm).toHaveTextContent("drain");
1799
- expect(confirm).toHaveTextContent("kube-system");
1800
- expect(updateByIdSpy).not.toHaveBeenCalled();
1804
+ await pickOption(
1805
+ dialog,
1806
+ /^Fixes/,
1807
+ REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.RequireApproval],
1808
+ );
1809
+ await waitFor(
1810
+ () => {
1811
+ expect(
1812
+ within(dialog).queryByTestId("kubectl-allowlist-field"),
1813
+ ).not.toBeInTheDocument();
1814
+ },
1815
+ { timeout: WAIT_TIMEOUT },
1816
+ );
1817
+ });
1818
+
1819
+ test("a member who only switches investigation off sends only that", async () => {
1820
+ serveCluster({
1821
+ aiRemediationMode: KubernetesAiRemediationMode.Automatic,
1822
+ aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN],
1823
+ });
1824
+ openAgentPage();
1825
+ const dialog: HTMLElement = await openChangeModal();
1801
1826
 
1802
- fireEvent.click(within(confirm).getByText("Confirm and save"));
1827
+ await toggleSwitch(dialog, "ai-investigation-field", true);
1828
+ saveChangeModal(dialog);
1803
1829
 
1804
- expect(await waitForOneUpdate()).toEqual({
1805
- aiRemediationMode: KubernetesAiRemediationMode.BypassApproval,
1806
- });
1830
+ expect(await waitForOneUpdate()).toEqual({
1831
+ isAiInvestigationEnabled: false,
1807
1832
  });
1833
+ const request: JSONObject = updateRequests()[0]!;
1834
+ expect(String(request["id"])).toBe(CLUSTER_ID.toString());
1835
+ expect(request["modelType"]).toBe(KubernetesCluster);
1836
+
1837
+ // Saved: the modal closes and the status is read again.
1838
+ await waitFor(
1839
+ () => {
1840
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
1841
+ },
1842
+ { timeout: WAIT_TIMEOUT },
1843
+ );
1844
+ await waitFor(
1845
+ () => {
1846
+ expect(postsTo(STATUS_ROUTE).length).toBeGreaterThanOrEqual(2);
1847
+ },
1848
+ { timeout: WAIT_TIMEOUT },
1849
+ );
1850
+ });
1808
1851
 
1809
- test("cancelling the confirmation saves nothing and keeps the form", async () => {
1810
- grant(ADMIN_PERMISSIONS);
1811
- openAiPage();
1812
- const dialog: HTMLElement = await openEditModal();
1852
+ test("an untouched form saves nothing", async () => {
1853
+ grant(ADMIN_PERMISSIONS);
1854
+ openAgentPage();
1855
+ const dialog: HTMLElement = await openChangeModal();
1813
1856
 
1814
- await setAllowlistText(dialog, BROAD_PATTERN);
1815
- saveEditModal(dialog);
1857
+ saveChangeModal(dialog);
1816
1858
 
1817
- const confirm: HTMLElement = await findDialogTitled(
1818
- "Let riskier changes run without approval?",
1819
- );
1820
- expect(confirm).toHaveTextContent(`"${BROAD_PATTERN}"`);
1821
- fireEvent.click(within(confirm).getByText("Cancel"));
1822
-
1823
- await waitFor(
1824
- () => {
1825
- expect(
1826
- screen.queryByText("Let riskier changes run without approval?"),
1827
- ).not.toBeInTheDocument();
1828
- },
1829
- { timeout: WAIT_TIMEOUT },
1859
+ await waitFor(
1860
+ () => {
1861
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
1862
+ },
1863
+ { timeout: WAIT_TIMEOUT },
1864
+ );
1865
+ expect(updateByIdSpy).not.toHaveBeenCalled();
1866
+ });
1867
+
1868
+ test("a server refusal is shown and the modal stays open", async () => {
1869
+ updateByIdSpy.mockImplementation(async (): Promise<never> => {
1870
+ throw new HTTPErrorResponse(
1871
+ 422,
1872
+ { error: "You do not have permission to change fixes." },
1873
+ {},
1830
1874
  );
1831
- expect(updateByIdSpy).not.toHaveBeenCalled();
1832
- expect(screen.getByRole("dialog")).toBe(dialog);
1833
1875
  });
1876
+ openAgentPage();
1877
+ const dialog: HTMLElement = await openChangeModal();
1834
1878
 
1835
- /*
1836
- * "kubectl delete deployment * -n *" auto-approves deleting any
1837
- * Deployment in any non-protected namespace; the old whole-string
1838
- * check let such an entry through without a confirmation because it
1839
- * spells out "-n".
1840
- */
1841
- test("a broad allowlist is saved after confirmation, one pattern per line", async () => {
1842
- grant(ADMIN_PERMISSIONS);
1843
- openAiPage();
1844
- const dialog: HTMLElement = await openEditModal();
1845
-
1846
- await setAllowlistText(
1847
- dialog,
1848
- `${SET_IMAGE_PATTERN}\n\n${BROAD_PATTERN}\n`,
1849
- );
1850
- saveEditModal(dialog);
1879
+ await toggleSwitch(dialog, "ai-investigation-field", true);
1880
+ saveChangeModal(dialog);
1851
1881
 
1852
- const confirm: HTMLElement = await findDialogTitled(
1853
- "Let riskier changes run without approval?",
1854
- );
1855
- expect(confirm).toHaveTextContent(
1856
- "a wildcard for an object, the namespace, a selector or a --from source",
1857
- );
1858
- fireEvent.click(within(confirm).getByText("Confirm and save"));
1882
+ expect(await findTestId("ai-access-save-error")).toHaveTextContent(
1883
+ "You do not have permission to change fixes.",
1884
+ );
1885
+ expect(screen.getByRole("dialog")).toBe(dialog);
1886
+ });
1887
+ });
1888
+
1889
+ describe("the Change modal: confirmations", () => {
1890
+ test("Bypass approval is saved only after a confirmation that names what it unlocks", async () => {
1891
+ grant(ADMIN_PERMISSIONS);
1892
+ openAgentPage();
1893
+ const dialog: HTMLElement = await openChangeModal();
1894
+
1895
+ await pickOption(
1896
+ dialog,
1897
+ /^Fixes/,
1898
+ REMEDIATION_MODE_LABELS[KubernetesAiRemediationMode.BypassApproval],
1899
+ );
1900
+ saveChangeModal(dialog);
1901
+
1902
+ const confirm: HTMLElement = await findDialogTitled(
1903
+ "Turn on Bypass approval?",
1904
+ );
1905
+ expect(confirm).toHaveTextContent("set image");
1906
+ expect(confirm).toHaveTextContent("drain");
1907
+ expect(confirm).toHaveTextContent("kube-system");
1908
+ expect(updateByIdSpy).not.toHaveBeenCalled();
1909
+
1910
+ fireEvent.click(within(confirm).getByText("Confirm and save"));
1859
1911
 
1860
- expect(await waitForOneUpdate()).toEqual({
1861
- aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN, BROAD_PATTERN],
1862
- });
1912
+ expect(await waitForOneUpdate()).toEqual({
1913
+ aiRemediationMode: KubernetesAiRemediationMode.BypassApproval,
1863
1914
  });
1915
+ });
1864
1916
 
1865
- test("a narrow allowlist saves without a confirmation", async () => {
1866
- grant(ADMIN_PERMISSIONS);
1867
- openAiPage();
1868
- const dialog: HTMLElement = await openEditModal();
1917
+ test("cancelling a confirmation saves nothing and keeps the form", async () => {
1918
+ grant(ADMIN_PERMISSIONS);
1919
+ serveCluster({ aiRemediationMode: KubernetesAiRemediationMode.Automatic });
1920
+ openAgentPage();
1921
+ const dialog: HTMLElement = await openChangeModal();
1869
1922
 
1870
- await setAllowlistText(dialog, SET_IMAGE_PATTERN);
1871
- saveEditModal(dialog);
1923
+ await setAllowlistText(dialog, BROAD_PATTERN);
1924
+ saveChangeModal(dialog);
1872
1925
 
1873
- expect(await waitForOneUpdate()).toEqual({
1874
- aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN],
1875
- });
1876
- expect(
1877
- screen.queryByText("Let riskier changes run without approval?"),
1878
- ).not.toBeInTheDocument();
1879
- });
1926
+ const confirm: HTMLElement = await findDialogTitled(
1927
+ "Let riskier changes run without approval?",
1928
+ );
1929
+ expect(confirm).toHaveTextContent(`"${BROAD_PATTERN}"`);
1930
+ fireEvent.click(within(confirm).getByText("Cancel"));
1880
1931
 
1881
- /*
1882
- * Validity is KubectlPolicy's: a bare "kubectl" names no command. (A
1883
- * pattern without the leading "kubectl" is valid — the matcher reads
1884
- * it — so it is no longer the example here.)
1885
- */
1886
- test("an allowlist pattern the matcher cannot use is refused in the form", async () => {
1887
- grant(ADMIN_PERMISSIONS);
1888
- openAiPage();
1889
- const dialog: HTMLElement = await openEditModal();
1890
-
1891
- await setAllowlistText(dialog, `${SET_IMAGE_PATTERN}\nkubectl`);
1892
- saveEditModal(dialog);
1893
-
1894
- expect(
1895
- await within(dialog).findByText(
1896
- /^Pattern 2: /,
1897
- {},
1898
- { timeout: WAIT_TIMEOUT },
1899
- ),
1900
- ).toBeInTheDocument();
1901
- expect(updateByIdSpy).not.toHaveBeenCalled();
1902
- });
1932
+ await waitFor(
1933
+ () => {
1934
+ expect(
1935
+ screen.queryByText("Let riskier changes run without approval?"),
1936
+ ).not.toBeInTheDocument();
1937
+ },
1938
+ { timeout: WAIT_TIMEOUT },
1939
+ );
1940
+ expect(updateByIdSpy).not.toHaveBeenCalled();
1941
+ expect(screen.getByRole("dialog")).toBe(dialog);
1942
+ });
1903
1943
 
1904
- test("explains how patterns match", async () => {
1905
- grant(ADMIN_PERMISSIONS);
1906
- openAiPage();
1907
- const dialog: HTMLElement = await openEditModal();
1944
+ test("a broad allowlist is saved after confirmation, one pattern per line", async () => {
1945
+ grant(ADMIN_PERMISSIONS);
1946
+ serveCluster({ aiRemediationMode: KubernetesAiRemediationMode.Automatic });
1947
+ openAgentPage();
1948
+ const dialog: HTMLElement = await openChangeModal();
1908
1949
 
1909
- const description: HTMLElement = within(dialog).getByText(
1910
- /\* matches exactly one word/,
1911
- );
1912
- expect(description).toHaveTextContent("flags must be written out");
1913
- expect(description).toHaveTextContent("One pattern per line");
1914
- expect(description).toHaveTextContent('a leading "kubectl" is optional');
1915
- /*
1916
- * KubectlPolicy refuses a wildcard verb and (from round three) a
1917
- * one-word entry; the help said a wildcard verb was merely broad.
1918
- */
1919
- expect(description).toHaveTextContent(
1920
- "Write the verb (and the subcommand of rollout, set or create) out, never as *, and use more than one word",
1921
- );
1922
- expect(description).toHaveTextContent(
1923
- "A wildcard for the object or the namespace pre-approves a whole class of changes",
1924
- );
1925
- expect(description).not.toHaveTextContent("A wildcard for the verb");
1950
+ await setAllowlistText(
1951
+ dialog,
1952
+ `${SET_IMAGE_PATTERN}\n\n${BROAD_PATTERN}\n`,
1953
+ );
1954
+ saveChangeModal(dialog);
1955
+
1956
+ const confirm: HTMLElement = await findDialogTitled(
1957
+ "Let riskier changes run without approval?",
1958
+ );
1959
+ fireEvent.click(within(confirm).getByText("Confirm and save"));
1960
+
1961
+ expect(await waitForOneUpdate()).toEqual({
1962
+ aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN, BROAD_PATTERN],
1926
1963
  });
1927
1964
  });
1928
1965
 
1929
- describe("allowlist in effect", () => {
1930
- test("the Remediation tile lists the patterns the policy actually uses", async () => {
1931
- serveStatus(
1932
- makeStatus({
1933
- remediationMode: KubernetesAiRemediationMode.Automatic,
1934
- kubectlAllowlist: [SET_IMAGE_PATTERN],
1935
- }),
1936
- );
1937
- openAiPage();
1966
+ test("an allowlist pattern the matcher cannot use is refused in the form", async () => {
1967
+ grant(ADMIN_PERMISSIONS);
1968
+ serveCluster({ aiRemediationMode: KubernetesAiRemediationMode.Automatic });
1969
+ openAgentPage();
1970
+ const dialog: HTMLElement = await openChangeModal();
1938
1971
 
1939
- const tile: HTMLElement = await screen.findByTestId(
1940
- "kubectl-allowlist-in-effect",
1972
+ await setAllowlistText(dialog, `${SET_IMAGE_PATTERN}\nkubectl`);
1973
+ saveChangeModal(dialog);
1974
+
1975
+ expect(
1976
+ await within(dialog).findByText(
1977
+ /^Pattern 2: /,
1941
1978
  {},
1942
1979
  { timeout: WAIT_TIMEOUT },
1943
- );
1944
- expect(tile).toHaveTextContent(SET_IMAGE_PATTERN);
1945
- expect(tile).toHaveTextContent("also run on their own");
1946
- // The banner says so too, and that any other riskier fix asks.
1947
- expect(
1948
- screen.getByText(/plus riskier ones that match the kubectl allowlist/),
1949
- ).toHaveTextContent(
1950
- "Any other riskier fix is proposed for your one-click approval.",
1951
- );
1952
- });
1980
+ ),
1981
+ ).toBeInTheDocument();
1982
+ expect(updateByIdSpy).not.toHaveBeenCalled();
1983
+ });
1953
1984
 
1954
- test("an empty allowlist shows nothing extra", async () => {
1955
- serveStatus(
1956
- makeStatus({
1957
- remediationMode: KubernetesAiRemediationMode.Automatic,
1958
- kubectlAllowlist: [],
1959
- }),
1960
- );
1961
- openAiPage();
1962
- await findText(STATUS_CARD_TITLE);
1963
-
1964
- expect(
1965
- screen.queryByTestId("kubectl-allowlist-in-effect"),
1966
- ).not.toBeInTheDocument();
1967
- expect(
1968
- screen.getByText(
1969
- /and apply safe fixes on its own\. A riskier fix is proposed for your one-click approval\.$/,
1970
- ),
1971
- ).toBeInTheDocument();
1972
- });
1985
+ test("explains how patterns match", async () => {
1986
+ grant(ADMIN_PERMISSIONS);
1987
+ serveCluster({ aiRemediationMode: KubernetesAiRemediationMode.Automatic });
1988
+ openAgentPage();
1989
+ const dialog: HTMLElement = await openChangeModal();
1990
+
1991
+ const description: HTMLElement = await within(dialog).findByText(
1992
+ /\* matches exactly one word/,
1993
+ {},
1994
+ { timeout: WAIT_TIMEOUT },
1995
+ );
1996
+ expect(description).toHaveTextContent("flags must be written out");
1997
+ expect(description).toHaveTextContent('a leading "kubectl" is optional');
1998
+ expect(description).toHaveTextContent(
1999
+ "Write the verb (and the subcommand of rollout, set or create) out, never as *, and use more than one word",
2000
+ );
2001
+ });
2002
+ });
1973
2003
 
1974
- test("a stored allowlist the policy cannot use is flagged", async () => {
1975
- serveStatus(makeStatus({ kubectlAllowlist: [] }));
1976
- getItemSpy.mockImplementation(async (): Promise<KubernetesCluster> => {
1977
- return makeCluster({ aiKubectlCommandAllowlist: { a: "b" } });
1978
- });
1979
- openAiPage();
2004
+ /*
2005
+ * The Runner and credential pickers exist only for a cluster already bound
2006
+ * to a Runner outside the chart. Every other cluster is reached through its
2007
+ * Kubernetes AI agent: nothing is listed, not even for an admin.
2008
+ */
2009
+ describe("the Change modal: advanced Runner bindings", () => {
2010
+ test("a cluster on its AI agent never lists Runners or credentials, even for an admin", async () => {
2011
+ grant(ADMIN_PERMISSIONS);
2012
+ openAgentPage();
2013
+ const dialog: HTMLElement = await openChangeModal();
1980
2014
 
1981
- expect(
1982
- await screen.findByTestId(
1983
- "kubectl-allowlist-ignored-warning",
1984
- {},
1985
- { timeout: WAIT_TIMEOUT },
1986
- ),
1987
- ).toHaveTextContent("no allowlist pattern is in effect");
1988
- });
2015
+ expect(within(dialog).queryByText("Runner")).not.toBeInTheDocument();
2016
+ expect(
2017
+ within(dialog).queryByText("Kubernetes credential"),
2018
+ ).not.toBeInTheDocument();
2019
+ expect(
2020
+ within(dialog).queryByTestId("ai-access-clear-runner-field"),
2021
+ ).not.toBeInTheDocument();
2022
+ expect(listRequestsFor(Runner)).toHaveLength(0);
2023
+ expect(listRequestsFor(RunbookCredential)).toHaveLength(0);
2024
+ });
1989
2025
 
1990
- test("a usable stored allowlist is not flagged", async () => {
1991
- serveStatus(makeStatus({ kubectlAllowlist: [SET_IMAGE_PATTERN] }));
1992
- serveCluster({ aiKubectlCommandAllowlist: [SET_IMAGE_PATTERN] });
1993
- openAiPage();
2026
+ test("an admin gets the pickers: Runners outside the chart only, credentials that follow the Runner", async () => {
2027
+ grant(ADMIN_PERMISSIONS);
2028
+ serve(advancedStatus());
2029
+ serveCluster(hostRunnerBinding());
2030
+ openAgentPage();
2031
+ const dialog: HTMLElement = await openChangeModal();
2032
+
2033
+ await within(dialog).findByText("Runner", {}, { timeout: WAIT_TIMEOUT });
2034
+ expect(await openDropdown(dialog, /^Runner/)).toEqual(["bash-runner"]);
2035
+ fireEvent.keyDown(
2036
+ within(dialog).getByRole("combobox", { name: /^Runner/ }),
2037
+ {
2038
+ key: "Escape",
2039
+ code: "Escape",
2040
+ },
2041
+ );
1994
2042
 
1995
- await findText(SETTINGS_CARD_TITLE);
1996
- await waitFor(
1997
- () => {
1998
- expect(screen.getAllByText(SET_IMAGE_PATTERN).length).toBe(2);
1999
- },
2000
- { timeout: WAIT_TIMEOUT },
2001
- );
2002
- expect(
2003
- screen.queryByTestId("kubectl-allowlist-ignored-warning"),
2004
- ).not.toBeInTheDocument();
2043
+ expect(await openDropdown(dialog, /^Kubernetes credential/)).toEqual([
2044
+ "prod-east token",
2045
+ ]);
2046
+ const credentialRequest: ListRequest =
2047
+ listRequestsFor(RunbookCredential)[0]!;
2048
+ expect(credentialRequest.query).toEqual({
2049
+ credentialType: RunbookCredentialType.Kubernetes,
2005
2050
  });
2006
2051
  });
2007
2052
 
2008
- describe("connect a Runner", () => {
2009
- const noRunnerGap: KubernetesAiAccessGap = {
2010
- code: "no_runner_bound",
2011
- title: "No Runner can reach this cluster",
2012
- description:
2013
- "OneUptime AI runs kubectl through a Runner. None is bound to this cluster yet.",
2014
- nextStep:
2015
- "Upgrade the Kubernetes agent with --set aiAccess.enabled=true to install an in-cluster Runner (one command), or bind an existing Runner and a Kubernetes credential on this cluster's AI page.",
2016
- blocks: "both",
2017
- };
2053
+ /*
2054
+ * Regression (§11): unbinding an advanced Runner on a cluster that has
2055
+ * an AI agent moves AI to the agent (possibly broader RBAC), so a
2056
+ * cluster editor without the admin set is no longer offered the switch.
2057
+ */
2058
+ test("a member may not unbind an advanced Runner once the cluster has an AI agent", async () => {
2059
+ serve(advancedStatus());
2060
+ serveCluster(hostRunnerBinding());
2061
+ openAgentPage();
2062
+ const dialog: HTMLElement = await openChangeModal();
2018
2063
 
2019
- // The server's gap when this cluster's in-cluster Runner is registered.
2020
- const agentNotSelectedGap: KubernetesAiAccessGap = {
2021
- code: "no_runner_bound",
2022
- title: "The in-cluster Runner is installed but not selected",
2023
- description:
2024
- 'Runner "kubernetes-agent/prod-east", this cluster\'s in-cluster Runner, is registered and online, but no Runner is bound to this cluster, so OneUptime AI does not use it.',
2025
- nextStep:
2026
- 'Select the kubernetes-agent Runner "kubernetes-agent/prod-east" as this cluster\'s Runner on this page (leave the credential empty). No helm change is needed.',
2027
- blocks: "both",
2028
- };
2064
+ expect(
2065
+ within(dialog).queryByTestId("ai-access-clear-runner-field"),
2066
+ ).not.toBeInTheDocument();
2067
+ expect(
2068
+ within(dialog).queryByTestId("ai-access-clear-credential-field"),
2069
+ ).not.toBeInTheDocument();
2070
+ expect(listRequestsFor(Runner)).toHaveLength(0);
2071
+ });
2029
2072
 
2030
- function installReleaseAndNamespace(): {
2031
- release: string;
2032
- namespace: string;
2033
- } {
2034
- const match: RegExpMatchArray | null = getKubernetesInstallationMarkdown({
2035
- clusterName: "prod-east",
2036
- oneuptimeUrl: "https://oneuptime.example.com",
2037
- apiKey: "key",
2038
- }).match(
2039
- /helm install (\S+) oneuptime\/kubernetes-agent[\s\\]+--namespace (\S+)/,
2040
- );
2041
- if (!match) {
2042
- throw new Error("The install markdown has no helm install command.");
2043
- }
2044
- return { release: match[1]!, namespace: match[2]! };
2045
- }
2073
+ test("without an AI agent a member may still unbind, which only takes access away", async () => {
2074
+ serve(advancedStatus({ aiAgent: null }));
2075
+ serveCluster(hostRunnerBinding());
2076
+ openAgentPage();
2077
+ const dialog: HTMLElement = await openChangeModal();
2046
2078
 
2047
- test("the default command matches the Dashboard's install and grants read-only access", async () => {
2048
- serveStatus(makeStatus({ runner: null, gaps: [noRunnerGap] }));
2049
- openAiPage();
2050
-
2051
- await findText("Connect a Runner (one command)");
2052
- const readOnly: string = textOf("ai-access-helm-command");
2053
- const install: { release: string; namespace: string } =
2054
- installReleaseAndNamespace();
2055
-
2056
- expect(install).toEqual({
2057
- release: KUBERNETES_AGENT_HELM_RELEASE,
2058
- namespace: KUBERNETES_AGENT_HELM_NAMESPACE,
2059
- });
2060
- expect(readOnly).toContain("helm repo update");
2061
- expect(readOnly.indexOf("helm repo update")).toBeLessThan(
2062
- readOnly.indexOf("helm upgrade"),
2063
- );
2064
- expect(readOnly).toContain(
2065
- `helm upgrade ${install.release} oneuptime/kubernetes-agent`,
2066
- );
2067
- expect(readOnly).toContain(`--namespace ${install.namespace}`);
2068
- expect(readOnly).toContain("--set aiAccess.enabled=true");
2069
- expect(readOnly).not.toContain("aiAccess.remediation.enabled=true");
2070
- expect(screen.queryByText(/Drop the last line/)).not.toBeInTheDocument();
2079
+ expect(
2080
+ within(dialog).getByText(/Bound now: bash-runner/),
2081
+ ).toBeInTheDocument();
2082
+ await toggleSwitch(dialog, "ai-access-clear-runner-field", false);
2083
+ await toggleSwitch(dialog, "ai-access-clear-credential-field", false);
2084
+ saveChangeModal(dialog);
2085
+
2086
+ expect(await waitForOneUpdate()).toEqual({
2087
+ aiAccessRunnerId: null,
2088
+ aiAccessCredentialId: null,
2071
2089
  });
2090
+ });
2091
+
2092
+ test("an admin who may not read Runners gets the unbind switch, told why", async () => {
2093
+ grant([
2094
+ ...BASE_PERMISSIONS,
2095
+ Permission.EditKubernetesCluster,
2096
+ Permission.ReadKubernetesCluster,
2097
+ Permission.EditAutoRemediationRule,
2098
+ ]);
2099
+ serve(advancedStatus());
2100
+ serveCluster(hostRunnerBinding(false));
2101
+ openAgentPage();
2102
+ const dialog: HTMLElement = await openChangeModal();
2103
+
2104
+ expect(
2105
+ within(dialog).getByTestId("kubernetes-runner-picker-permission-note"),
2106
+ ).toHaveTextContent("Read Runbook Agent");
2107
+ expect(
2108
+ within(dialog).getByTestId("ai-access-clear-runner-field"),
2109
+ ).toBeInTheDocument();
2110
+ expect(listRequestsFor(Runner)).toHaveLength(0);
2111
+ });
2072
2112
 
2073
- test("an unbound cluster shows the card and a locked test button", async () => {
2074
- serveStatus(makeStatus({ runner: null, gaps: [noRunnerGap] }));
2075
- openAiPage();
2076
-
2077
- expect(
2078
- await findText("Connect a Runner (one command)"),
2079
- ).toBeInTheDocument();
2080
- expect(
2081
- screen.getByText("Bind a Runner first — there is nothing to test yet."),
2082
- ).toBeInTheDocument();
2083
- expect(screen.getByTestId("ai-access-test-button")).toBeDisabled();
2113
+ test("one picker's failed list leaves the other picker and the form working", async () => {
2114
+ grant(ADMIN_PERMISSIONS);
2115
+ serve(advancedStatus());
2116
+ serveCluster(hostRunnerBinding());
2117
+ const listImplementation: (args: unknown) => Promise<unknown> =
2118
+ getListSpy.getMockImplementation() as (args: unknown) => Promise<unknown>;
2119
+ getListSpy.mockImplementation(async (args: unknown): Promise<unknown> => {
2120
+ if ((args as ListRequest).modelType === Runner) {
2121
+ throw new Error("You do not have permissions to read Runner.");
2122
+ }
2123
+ return await listImplementation(args);
2084
2124
  });
2125
+ openAgentPage();
2126
+ const dialog: HTMLElement = await openChangeModal();
2085
2127
 
2086
- /*
2087
- * Known follow-up 7: with this cluster's in-cluster Runner installed
2088
- * but not selected, the helm card told the operator to run a command
2089
- * that changes nothing.
2090
- */
2091
- test("an installed but unselected in-cluster Runner asks to select it, not to run helm", async () => {
2092
- serveStatus(makeStatus({ runner: null, gaps: [agentNotSelectedGap] }));
2093
- openAiPage();
2094
-
2095
- const card: HTMLElement = await screen.findByTestId(
2096
- "ai-access-select-agent-runner",
2128
+ expect(
2129
+ await within(dialog).findByText(
2130
+ "Kubernetes credential",
2097
2131
  {},
2098
2132
  { timeout: WAIT_TIMEOUT },
2099
- );
2100
- expect(card).toHaveTextContent(
2101
- 'Select the kubernetes-agent Runner "kubernetes-agent/prod-east"',
2102
- );
2103
- // A member cannot choose a Runner: the card says what it takes.
2104
- expect(card).toHaveTextContent("Edit Auto Remediation Rule");
2105
- expect(
2106
- screen.queryByText("Connect a Runner (one command)"),
2107
- ).not.toBeInTheDocument();
2108
- expect(
2109
- screen.queryByTestId("ai-access-helm-command"),
2110
- ).not.toBeInTheDocument();
2111
- });
2112
-
2113
- test("an admin is pointed at Edit AI access to select the installed Runner", async () => {
2114
- grant(ADMIN_PERMISSIONS);
2115
- serveStatus(makeStatus({ runner: null, gaps: [agentNotSelectedGap] }));
2116
- openAiPage();
2133
+ ),
2134
+ ).toBeInTheDocument();
2135
+ expect(
2136
+ within(dialog).getByText(/The Runner list could not be loaded/),
2137
+ ).toBeInTheDocument();
2138
+ });
2139
+ });
2117
2140
 
2118
- expect(
2119
- await screen.findByTestId(
2120
- "ai-access-select-agent-runner",
2121
- {},
2122
- { timeout: WAIT_TIMEOUT },
2123
- ),
2124
- ).toHaveTextContent("Open Edit AI access below and choose it");
2125
- });
2141
+ describe("Switch to the AI agent", () => {
2142
+ test("an admin moves an advanced binding to the online agent after confirming", async () => {
2143
+ grant(ADMIN_PERMISSIONS);
2144
+ serve(advancedStatus());
2145
+ openAgentPage();
2126
2146
 
2127
- test("a ready cluster in Bypass approval shows the qualified banner and no connect card", async () => {
2128
- serveStatus(
2129
- makeStatus({
2130
- remediationMode: KubernetesAiRemediationMode.BypassApproval,
2131
- gaps: [],
2132
- }),
2133
- );
2134
- openAiPage();
2147
+ fireEvent.click(await findTestId("ai-agent-switch-button"));
2148
+ const confirm: HTMLElement = await findDialogTitled(
2149
+ "Switch to the AI agent?",
2150
+ );
2151
+ expect(confirm).toHaveTextContent(
2152
+ 'AI stops using Runner "bash-runner" and credential "prod-east token"',
2153
+ );
2154
+ expect(confirm).toHaveTextContent("(Read-only)");
2155
+ expect(updateByIdSpy).not.toHaveBeenCalled();
2135
2156
 
2136
- const banner: HTMLElement = await findText(
2137
- /apply every fix the policy allows on its own/,
2138
- );
2139
- expect(banner).toHaveTextContent("protected namespace");
2140
- expect(banner).toHaveTextContent("drain");
2141
- expect(banner).toHaveTextContent("a node taint");
2142
- // Round four: a patch of a node asks a human in Bypass approval too.
2143
- expect(banner).toHaveTextContent("a patch of a node still asks a human");
2144
- expect(banner).toHaveTextContent("circuit breaker");
2145
- // The canonical comment's second proposal case.
2146
- expect(banner).toHaveTextContent(
2147
- "while another unattended round holds this cluster",
2148
- );
2149
- expect(banner).not.toHaveTextContent("without asking anyone");
2150
- expect(banner).not.toHaveTextContent("only a write");
2151
- expect(
2152
- screen.queryByText("Connect a Runner (one command)"),
2153
- ).not.toBeInTheDocument();
2154
- });
2157
+ fireEvent.click(within(confirm).getByText("Switch"));
2155
2158
 
2156
- test("a Runner bound to another cluster shows its gap, not the card", async () => {
2157
- serveStatus(
2158
- makeStatus({
2159
- gaps: [
2160
- {
2161
- code: "runner_cluster_mismatch",
2162
- title: "The bound Runner runs in another cluster",
2163
- description: "kubernetes-agent/prod-eu runs inside prod-eu.",
2164
- nextStep: "Bind this cluster's own Runner on this page.",
2165
- blocks: "both",
2166
- },
2167
- ],
2168
- }),
2169
- );
2170
- openAiPage();
2171
-
2172
- expect(
2173
- await findText("The bound Runner runs in another cluster"),
2174
- ).toBeInTheDocument();
2175
- expect(
2176
- screen.getByText("Bind this cluster's own Runner on this page."),
2177
- ).toBeInTheDocument();
2178
- expect(
2179
- screen.queryByText("Connect a Runner (one command)"),
2180
- ).not.toBeInTheDocument();
2159
+ expect(await waitForOneUpdate()).toEqual({
2160
+ aiAccessRunnerId: null,
2161
+ aiAccessCredentialId: null,
2181
2162
  });
2163
+ expect(await findTestId("ai-agent-action-notice")).toHaveTextContent(
2164
+ "AI now reaches this cluster through its AI agent.",
2165
+ );
2166
+ });
2182
2167
 
2183
- // Known follow-up 7: this gap renders like every other, with its next step.
2184
- test("a credential on another cluster's in-cluster Runner shows its gap and next step", async () => {
2185
- serveStatus(
2186
- makeStatus({
2187
- runner: {
2188
- id: OTHER_CLUSTER_RUNNER_ID,
2189
- name: "kubernetes-agent/prod-eu",
2190
- isOnline: true,
2191
- canRunAiCommands: true,
2192
- posture: {
2193
- inCluster: true,
2194
- allowWrites: false,
2195
- clusterIdentifier: "prod-eu",
2196
- },
2197
- },
2198
- accessMethod: "none",
2199
- isInvestigationReady: false,
2200
- isRemediationReady: false,
2201
- gaps: [
2202
- {
2203
- code: "credential_on_agent_runner",
2204
- title: "An in-cluster Runner cannot carry a credential",
2205
- description:
2206
- 'Runner "kubernetes-agent/prod-eu" is the in-cluster Runner of cluster "prod-eu".',
2207
- nextStep:
2208
- "Create a Runner under Project Settings → Runners, assign the Kubernetes credential to it and select both on this page — or install the in-cluster Runner on THIS cluster with --set aiAccess.enabled=true, which needs no credential.",
2209
- blocks: "both",
2210
- },
2211
- ],
2212
- }),
2213
- );
2214
- openAiPage();
2215
-
2216
- expect(
2217
- await findText("An in-cluster Runner cannot carry a credential"),
2218
- ).toBeInTheDocument();
2219
- expect(
2220
- screen.getByText(/Create a Runner under Project Settings → Runners/),
2221
- ).toBeInTheDocument();
2222
- expect(
2223
- screen.queryByText("Connect a Runner (one command)"),
2224
- ).not.toBeInTheDocument();
2225
- });
2168
+ test("is not offered while the agent is offline, or to a member", async () => {
2169
+ grant(ADMIN_PERMISSIONS);
2170
+ serve(advancedStatus({ aiAgent: silentAgent() }));
2171
+ openAgentPage();
2172
+ await findTestId("ai-agent-status");
2173
+ expect(
2174
+ screen.queryByTestId("ai-agent-switch-button"),
2175
+ ).not.toBeInTheDocument();
2176
+ cleanup();
2177
+
2178
+ grant(MEMBER_PERMISSIONS);
2179
+ serve(advancedStatus());
2180
+ openAgentPage();
2181
+ await findTestId("ai-agent-status");
2182
+ expect(
2183
+ screen.queryByTestId("ai-agent-switch-button"),
2184
+ ).not.toBeInTheDocument();
2226
2185
  });
2186
+ });
2227
2187
 
2228
- /*
2229
- * The finding (dashboard-ai-page-3): the write-access command lived in
2230
- * the connect card, so it vanished once the recommended read-only
2231
- * Runner connected; it granted write RBAC cluster-wide with no scoped
2232
- * form, no `helm repo update` and understated copy.
2233
- */
2234
- describe("letting AI apply fixes", () => {
2235
- const writeAccessGap: KubernetesAiAccessGap = {
2236
- code: "remediation_write_access_missing",
2237
- title: "The in-cluster Runner is read-only",
2238
- description:
2239
- "The Kubernetes agent was installed without write access, so kubectl changes would be refused by the cluster.",
2240
- nextStep:
2241
- "Upgrade the agent with --set aiAccess.remediation.enabled=true to grant the Runner's ServiceAccount the write verbs OneUptime AI may use.",
2242
- blocks: "remediation",
2243
- };
2188
+ describe("Reset agent", () => {
2189
+ test("an admin resets the agent after confirming, and the page says what happens next", async () => {
2190
+ grant(ADMIN_PERMISSIONS);
2191
+ openAgentPage();
2244
2192
 
2245
- test("a connected read-only in-cluster Runner still gets complete write-access commands", async () => {
2246
- serveStatus(
2247
- makeStatus({
2248
- remediationMode: KubernetesAiRemediationMode.RequireApproval,
2249
- isRemediationReady: false,
2250
- gaps: [writeAccessGap],
2251
- }),
2252
- );
2253
- openAiPage();
2193
+ fireEvent.click(await findTestId("ai-agent-reset-button"));
2194
+ const confirm: HTMLElement = await findDialogTitled("Reset the AI agent?");
2195
+ expect(confirm).toHaveTextContent("revokes the agent's key");
2196
+ expect(postsTo(RESET_ROUTE)).toHaveLength(0);
2254
2197
 
2255
- const section: HTMLElement = await screen.findByTestId(
2256
- "ai-access-remediation-setup",
2257
- {},
2258
- { timeout: WAIT_TIMEOUT },
2259
- );
2260
- expect(section).toHaveTextContent(
2261
- "The in-cluster Runner has read-only RBAC",
2262
- );
2263
- expect(
2264
- screen.queryByText("Connect a Runner (one command)"),
2265
- ).not.toBeInTheDocument();
2198
+ fireEvent.click(within(confirm).getByText("Reset agent"));
2266
2199
 
2267
- const scoped: string = textOf(
2268
- "ai-access-helm-remediation-scoped-command",
2269
- );
2270
- expect(scoped.startsWith("helm repo update")).toBe(true);
2271
- expect(scoped).toContain(
2272
- `helm upgrade ${KUBERNETES_AGENT_HELM_RELEASE} oneuptime/kubernetes-agent`,
2273
- );
2274
- expect(scoped).toContain("--set aiAccess.remediation.enabled=true");
2275
- expect(scoped).toContain("aiAccess.remediation.namespaces=");
2276
- expect(scoped).toContain("aiAccess.remediation.nodeOperations=false");
2277
- expect(scoped).toBe(getAiAccessHelmCommands().enableRemediationScoped);
2278
-
2279
- const clusterWide: string = textOf("ai-access-helm-remediation-command");
2280
- expect(clusterWide.startsWith("helm repo update")).toBe(true);
2281
- expect(clusterWide).toContain("--set aiAccess.remediation.enabled=true");
2282
- /*
2283
- * Round four: the reset that works under --reuse-values. `=null` kept
2284
- * a stored list there, and failed the schema on installs from a
2285
- * chart without aiAccess.
2286
- */
2287
- expect(clusterWide).toContain(
2288
- "--set-json 'aiAccess.remediation.namespaces=[]'",
2289
- );
2290
- expect(clusterWide).not.toContain("namespaces=null");
2291
- expect(clusterWide).toBe(getAiAccessHelmCommands().enableRemediation);
2292
-
2293
- /*
2294
- * Under each command, what the chart and the server do with the list:
2295
- * every namespace must already exist, the empty-list `--set-json`
2296
- * resets a stored list (`=null` does not, under --reuse-values), and
2297
- * a write outside it is refused before it reaches the Runner.
2298
- */
2299
- const scopedNote: string = textOf(
2300
- "ai-access-helm-remediation-scoped-note",
2301
- );
2302
- expect(scopedNote).toBe(getAiAccessScopedCommandNote());
2303
- expect(scopedNote).toContain("must already exist");
2304
- expect(scopedNote).toContain(
2305
- "--set-json 'aiAccess.remediation.namespaces=[]' resets it to cluster-wide",
2306
- );
2307
- expect(scopedNote).toContain(
2308
- "--set aiAccess.remediation.namespaces=null does not reset a stored list under --reuse-values",
2309
- );
2310
- expect(scopedNote).toContain("refused when it is proposed or approved");
2311
- const clusterWideNote: string = textOf("ai-access-helm-remediation-note");
2312
- expect(clusterWideNote).toBe(getAiAccessClusterWideCommandNote());
2313
- expect(clusterWideNote).toContain(
2314
- "--set-json 'aiAccess.remediation.namespaces=[]' resets a namespace list stored on the release",
2315
- );
2316
- expect(clusterWideNote).not.toContain(
2317
- "--set aiAccess.remediation.namespaces=null resets",
2318
- );
2200
+ await waitFor(
2201
+ () => {
2202
+ expect(postsTo(RESET_ROUTE)).toHaveLength(1);
2203
+ },
2204
+ { timeout: WAIT_TIMEOUT },
2205
+ );
2206
+ expect(postsTo(RESET_ROUTE)[0]!["data"]).toEqual({
2207
+ clusterId: CLUSTER_ID.toString(),
2208
+ });
2209
+ expect(await findTestId("ai-agent-action-notice")).toHaveTextContent(
2210
+ "The AI agent was reset. It reconnects on its own within a few minutes.",
2211
+ );
2212
+ await waitFor(
2213
+ () => {
2214
+ expect(postsTo(STATUS_ROUTE).length).toBeGreaterThanOrEqual(2);
2215
+ },
2216
+ { timeout: WAIT_TIMEOUT },
2217
+ );
2218
+ });
2319
2219
 
2320
- const disclosure: HTMLElement = screen.getByTestId(
2321
- "ai-access-write-disclosure",
2322
- );
2323
- expect(disclosure).toHaveTextContent("any image as any ServiceAccount");
2324
- expect(disclosure).toHaveTextContent("kube-system");
2220
+ /*
2221
+ * The reset marks the agent disconnected at once, so the next status
2222
+ * reads Offline while its last heartbeat is seconds old. The card says it
2223
+ * was reset and comes back, not that it went quiet five minutes ago.
2224
+ */
2225
+ test("after a reset the card says the agent reconnects, not that it went quiet", async () => {
2226
+ grant(ADMIN_PERMISSIONS);
2227
+ serve(makeStatus(), {
2228
+ reset: async (): Promise<HTTPResponse<JSONObject>> => {
2229
+ serve(makeStatus({}, signedOffAgent()));
2230
+ return new HTTPResponse<JSONObject>(200, {}, {});
2231
+ },
2325
2232
  });
2233
+ openAgentPage();
2326
2234
 
2327
- test("stays on the page for a Runner that already writes, saying where", async () => {
2328
- serveStatus(
2329
- makeStatus({
2330
- runner: {
2331
- ...makeStatus().runner!,
2332
- posture: {
2333
- inCluster: true,
2334
- allowWrites: true,
2335
- writeNamespaces: ["web", "api"],
2336
- kubectlVersion: "v1.31.0",
2337
- },
2338
- },
2339
- }),
2340
- );
2341
- openAiPage();
2235
+ fireEvent.click(await findTestId("ai-agent-reset-button"));
2236
+ const confirm: HTMLElement = await findDialogTitled("Reset the AI agent?");
2237
+ fireEvent.click(within(confirm).getByText("Reset agent"));
2238
+
2239
+ await waitFor(
2240
+ () => {
2241
+ expect(screen.getByTestId("ai-agent-status")).toHaveTextContent(
2242
+ "Offline",
2243
+ );
2244
+ },
2245
+ { timeout: WAIT_TIMEOUT },
2246
+ );
2247
+ expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
2248
+ AI_AGENT_SIGNED_OFF_TEXT,
2249
+ );
2250
+ expect(screen.getByTestId("ai-agent-sentence")).not.toHaveTextContent(
2251
+ "has not checked in",
2252
+ );
2253
+ expect(screen.getByTestId("ai-agent-meta")).toHaveTextContent(
2254
+ /^last seen /,
2255
+ );
2256
+ });
2342
2257
 
2343
- expect(
2344
- await screen.findByTestId(
2345
- "ai-access-runner-write-access",
2258
+ test("a refused reset stays in its dialog with the server's reason", async () => {
2259
+ grant(ADMIN_PERMISSIONS);
2260
+ serve(makeStatus(), {
2261
+ reset: async (): Promise<HTTPErrorResponse> => {
2262
+ return new HTTPErrorResponse(
2263
+ 422,
2264
+ { error: "You do not have permission to reset the agent." },
2346
2265
  {},
2347
- { timeout: WAIT_TIMEOUT },
2348
- ),
2349
- ).toHaveTextContent("writes allowed in web, api");
2350
- expect(
2351
- screen.getByTestId("ai-access-remediation-setup"),
2352
- ).toHaveTextContent(
2353
- "The in-cluster Runner reports: writes allowed in web, api.",
2354
- );
2355
- expect(
2356
- screen.getByTestId("ai-access-helm-remediation-scoped-command"),
2357
- ).toBeInTheDocument();
2266
+ );
2267
+ },
2358
2268
  });
2269
+ openAgentPage();
2359
2270
 
2360
- test("an external Runner is told its credential's RBAC decides", async () => {
2361
- serveStatus(
2362
- makeStatus({
2363
- runner: {
2364
- id: HOST_RUNNER_ID,
2365
- name: "bash-runner",
2366
- isOnline: true,
2367
- canRunAiCommands: true,
2368
- },
2369
- accessMethod: "credential",
2370
- credentialId: CREDENTIAL_ID,
2371
- credentialName: "prod-east token",
2372
- }),
2373
- );
2374
- openAiPage();
2271
+ fireEvent.click(await findTestId("ai-agent-reset-button"));
2272
+ const confirm: HTMLElement = await findDialogTitled("Reset the AI agent?");
2273
+ fireEvent.click(within(confirm).getByText("Reset agent"));
2375
2274
 
2376
- const setup: HTMLElement = await screen.findByTestId(
2377
- "ai-access-remediation-setup",
2275
+ expect(
2276
+ await within(confirm).findByText(
2277
+ "You do not have permission to reset the agent.",
2378
2278
  {},
2379
2279
  { timeout: WAIT_TIMEOUT },
2380
- );
2381
- expect(setup).toHaveTextContent("bounded by that credential's RBAC");
2382
- /*
2383
- * Round four: an ordinary Runner reports the write limits it was
2384
- * started with, so the page names them — and that a fix outside
2385
- * them is refused before it reaches the Runner — instead of leaving
2386
- * everything to the credential's RBAC.
2387
- */
2388
- expect(setup).toHaveTextContent(
2389
- "the write limits the Runner was started with, if any (ONEUPTIME_KUBECTL_WRITE_NAMESPACES, ONEUPTIME_KUBECTL_ALLOW_NODE_OPERATIONS=false)",
2390
- );
2391
- expect(setup).toHaveTextContent(
2392
- "The Runner reports those limits, so a fix outside them is refused when it is proposed or approved, before it reaches the Runner.",
2393
- );
2394
- expect(
2395
- screen.queryByTestId("ai-access-runner-write-access"),
2396
- ).not.toBeInTheDocument();
2397
- });
2280
+ ),
2281
+ ).toBeInTheDocument();
2282
+ expect(
2283
+ screen.queryByTestId("ai-agent-action-notice"),
2284
+ ).not.toBeInTheDocument();
2398
2285
  });
2399
2286
 
2400
- describe("access test", () => {
2401
- const TEST_ROUTE: string = "/kubernetes-cluster/ai-access/test";
2402
-
2403
- /*
2404
- * The finding: the button was enabled for everyone once a Runner was
2405
- * bound, but the route needs edit access to the cluster; a reader got
2406
- * a red "You do not have permission to change this cluster's AI
2407
- * access" for a read-only test.
2408
- */
2409
- test("is locked, with the reason, for a user who may only read the cluster", async () => {
2410
- grant([...BASE_PERMISSIONS, Permission.ReadKubernetesCluster]);
2411
- openAiPage();
2412
-
2413
- const button: HTMLElement = await screen.findByTestId(
2414
- "ai-access-test-button",
2415
- {},
2416
- { timeout: WAIT_TIMEOUT },
2417
- );
2418
- expect(button).toBeDisabled();
2419
- expect(textOf("ai-access-test-permission-note")).toMatch(
2420
- /Running the access test needs permission to edit this cluster/,
2421
- );
2422
- expect(textOf("ai-access-test-permission-note")).toContain(
2423
- "Edit Kubernetes Cluster",
2424
- );
2287
+ test("is offered to an admin of an offline agent, never to a member", async () => {
2288
+ grant(ADMIN_PERMISSIONS);
2289
+ serve(makeStatus({}, silentAgent()));
2290
+ openAgentPage();
2291
+ expect(await findTestId("ai-agent-reset-button")).toBeInTheDocument();
2292
+ cleanup();
2293
+
2294
+ grant(MEMBER_PERMISSIONS);
2295
+ serve(makeStatus());
2296
+ openAgentPage();
2297
+ await findTestId("ai-agent-status");
2298
+ expect(
2299
+ screen.queryByTestId("ai-agent-reset-button"),
2300
+ ).not.toBeInTheDocument();
2301
+ });
2302
+ });
2425
2303
 
2426
- fireEvent.click(button);
2427
- expect(postsTo(TEST_ROUTE)).toHaveLength(0);
2428
- });
2304
+ describe("Test connection", () => {
2305
+ test("is locked, with the reason, for a user who may only read the cluster", async () => {
2306
+ grant(READER_PERMISSIONS);
2307
+ openAgentPage();
2429
2308
 
2430
- test("is hidden while the permission snapshot has not landed", async () => {
2431
- grant([]);
2432
- openAiPage();
2433
-
2434
- await findText(STATUS_CARD_TITLE);
2435
- expect(
2436
- screen.queryByTestId("ai-access-test-button"),
2437
- ).not.toBeInTheDocument();
2438
- expect(
2439
- screen.queryByTestId("ai-access-test-permission-note"),
2440
- ).not.toBeInTheDocument();
2441
- });
2309
+ const button: HTMLElement = await findTestId("ai-agent-test-button");
2310
+ expect(button).toBeDisabled();
2311
+ expect(
2312
+ screen.getByTestId("ai-agent-test-permission-note"),
2313
+ ).toHaveTextContent(
2314
+ /Testing the connection needs permission to edit this cluster/,
2315
+ );
2316
+ expect(
2317
+ screen.getByTestId("ai-agent-test-permission-note"),
2318
+ ).toHaveTextContent("Edit Kubernetes Cluster");
2442
2319
 
2443
- test("runs once for a member and shows every command's result", async () => {
2444
- let answer: (value: HTTPResponse<JSONObject>) => void = (): void => {
2445
- // replaced below
2446
- };
2447
- serveStatusAndTest(
2448
- makeStatus(),
2449
- (): Promise<HTTPResponse<JSONObject>> => {
2450
- return new Promise(
2451
- (resolve: (value: HTTPResponse<JSONObject>) => void) => {
2452
- answer = resolve;
2453
- },
2454
- );
2455
- },
2456
- );
2457
- openAiPage();
2320
+ fireEvent.click(button);
2321
+ expect(postsTo(TEST_ROUTE)).toHaveLength(0);
2322
+ });
2458
2323
 
2459
- const button: HTMLElement = await screen.findByTestId(
2460
- "ai-access-test-button",
2461
- {},
2462
- { timeout: WAIT_TIMEOUT },
2463
- );
2464
- expect(button).not.toBeDisabled();
2465
- expect(
2466
- screen.queryByTestId("ai-access-test-permission-note"),
2467
- ).not.toBeInTheDocument();
2324
+ test("is hidden while the permission snapshot has not landed", async () => {
2325
+ grant([]);
2326
+ openAgentPage();
2468
2327
 
2469
- fireEvent.click(button);
2470
- fireEvent.click(button);
2328
+ await findText(AGENT_CARD_TITLE);
2329
+ expect(
2330
+ screen.queryByTestId("ai-agent-test-button"),
2331
+ ).not.toBeInTheDocument();
2332
+ expect(
2333
+ screen.queryByTestId("ai-agent-test-permission-note"),
2334
+ ).not.toBeInTheDocument();
2335
+ });
2471
2336
 
2472
- await waitFor(
2473
- () => {
2474
- expect(screen.getByTestId("ai-access-test-button")).toBeDisabled();
2475
- },
2476
- { timeout: WAIT_TIMEOUT },
2477
- );
2478
- expect(postsTo(TEST_ROUTE)).toHaveLength(1);
2479
- expect(postsTo(TEST_ROUTE)[0]!["data"]).toEqual({
2480
- clusterId: CLUSTER_ID.toString(),
2481
- });
2482
-
2483
- await act(async () => {
2484
- answer(
2485
- new HTTPResponse<JSONObject>(
2486
- 200,
2487
- {
2488
- ok: true,
2489
- message: "OneUptime AI can run kubectl on prod-east.",
2490
- results: [
2491
- {
2492
- command: "kubectl version",
2493
- succeeded: true,
2494
- exitCode: 0,
2495
- output: "Server Version: v1.31.0",
2496
- errorMessage: null,
2497
- },
2498
- ],
2499
- status: makeStatus({
2500
- lastVerifiedAt: "2026-09-22T10:05:00.000Z",
2501
- }) as unknown as JSONObject,
2502
- },
2503
- {},
2504
- ),
2337
+ test("runs once for a member and shows every command's result inline", async () => {
2338
+ let answer: (value: HTTPResponse<JSONObject>) => void = (): void => {
2339
+ // replaced below
2340
+ };
2341
+ serve(makeStatus(), {
2342
+ test: (): Promise<HTTPResponse<JSONObject>> => {
2343
+ return new Promise(
2344
+ (resolve: (value: HTTPResponse<JSONObject>) => void) => {
2345
+ answer = resolve;
2346
+ },
2505
2347
  );
2506
- });
2507
-
2508
- expect(await findText("Access works")).toBeInTheDocument();
2509
- expect(screen.getByText("succeeded")).toBeInTheDocument();
2510
- expect(screen.getByText("Server Version: v1.31.0")).toBeInTheDocument();
2511
- expect(
2512
- screen.getByText(/Last successful kubectl command:/),
2513
- ).toBeInTheDocument();
2514
- expect(screen.getByTestId("ai-access-test-button")).not.toBeDisabled();
2348
+ },
2515
2349
  });
2350
+ openAgentPage();
2516
2351
 
2517
- test("shows a failed command with its exit code", async () => {
2518
- serveStatusAndTest(
2519
- makeStatus(),
2520
- async (): Promise<HTTPResponse<JSONObject>> => {
2521
- return new HTTPResponse<JSONObject>(
2522
- 200,
2523
- {
2524
- ok: false,
2525
- message: "kubectl could not run successfully.",
2526
- results: [
2527
- {
2528
- command: "kubectl version",
2529
- succeeded: false,
2530
- exitCode: 1,
2531
- output: "",
2532
- errorMessage: "forbidden",
2533
- },
2534
- ],
2535
- },
2536
- {},
2537
- );
2538
- },
2539
- );
2540
- openAiPage();
2352
+ const button: HTMLElement = await findTestId("ai-agent-test-button");
2353
+ expect(button).not.toBeDisabled();
2354
+
2355
+ fireEvent.click(button);
2356
+ fireEvent.click(button);
2357
+
2358
+ await waitFor(
2359
+ () => {
2360
+ expect(screen.getByTestId("ai-agent-test-button")).toBeDisabled();
2361
+ },
2362
+ { timeout: WAIT_TIMEOUT },
2363
+ );
2364
+ expect(postsTo(TEST_ROUTE)).toHaveLength(1);
2365
+ expect(postsTo(TEST_ROUTE)[0]!["data"]).toEqual({
2366
+ clusterId: CLUSTER_ID.toString(),
2367
+ });
2541
2368
 
2542
- fireEvent.click(
2543
- await screen.findByTestId(
2544
- "ai-access-test-button",
2369
+ await act(async () => {
2370
+ answer(
2371
+ new HTTPResponse<JSONObject>(
2372
+ 200,
2373
+ {
2374
+ ok: true,
2375
+ message: "OneUptime AI can run kubectl on prod-east.",
2376
+ results: [
2377
+ {
2378
+ command: "kubectl version",
2379
+ succeeded: true,
2380
+ exitCode: 0,
2381
+ output: "Server Version: v1.31.0",
2382
+ errorMessage: null,
2383
+ },
2384
+ {
2385
+ command: "kubectl auth can-i --list",
2386
+ succeeded: true,
2387
+ exitCode: 0,
2388
+ output: "pods [] [] [get list watch]",
2389
+ errorMessage: null,
2390
+ },
2391
+ ],
2392
+ status: makeStatus({
2393
+ lastVerifiedAt: "2026-09-22T10:05:00.000Z",
2394
+ }) as unknown as JSONObject,
2395
+ },
2545
2396
  {},
2546
- { timeout: WAIT_TIMEOUT },
2547
2397
  ),
2548
2398
  );
2399
+ });
2400
+
2401
+ const results: HTMLElement = await findTestId("ai-agent-test-results");
2402
+ expect(results).toHaveTextContent("The connection works");
2403
+ expect(results).toHaveTextContent(
2404
+ "OneUptime AI can run kubectl on prod-east.",
2405
+ );
2406
+ expect(within(results).getAllByText("succeeded")).toHaveLength(2);
2407
+ expect(results).toHaveTextContent("Server Version: v1.31.0");
2408
+ expect(results).toHaveTextContent("kubectl auth can-i --list");
2409
+ expect(screen.getByTestId("ai-agent-test-button")).not.toBeDisabled();
2410
+ });
2549
2411
 
2550
- expect(await findText("Access is not working yet")).toBeInTheDocument();
2551
- expect(screen.getByText("failed (exit 1)")).toBeInTheDocument();
2552
- expect(screen.getByText("forbidden")).toBeInTheDocument();
2412
+ test("shows a failed command with its exit code", async () => {
2413
+ serve(makeStatus(), {
2414
+ test: async (): Promise<HTTPResponse<JSONObject>> => {
2415
+ return new HTTPResponse<JSONObject>(
2416
+ 200,
2417
+ {
2418
+ ok: false,
2419
+ message: "kubectl could not run successfully.",
2420
+ results: [
2421
+ {
2422
+ command: "kubectl version",
2423
+ succeeded: false,
2424
+ exitCode: 1,
2425
+ output: "",
2426
+ errorMessage:
2427
+ "dial tcp 127.0.0.1:8080: connect: connection refused",
2428
+ },
2429
+ ],
2430
+ },
2431
+ {},
2432
+ );
2433
+ },
2553
2434
  });
2435
+ openAgentPage();
2436
+
2437
+ fireEvent.click(await findTestId("ai-agent-test-button"));
2438
+
2439
+ expect(
2440
+ await findText("The connection is not working yet"),
2441
+ ).toBeInTheDocument();
2442
+ expect(screen.getByText("failed (exit 1)")).toBeInTheDocument();
2443
+ expect(
2444
+ screen.getByText("dial tcp 127.0.0.1:8080: connect: connection refused"),
2445
+ ).toBeInTheDocument();
2446
+ });
2554
2447
 
2555
- test("shows an error the server returns", async () => {
2556
- serveStatusAndTest(makeStatus(), async (): Promise<HTTPErrorResponse> => {
2448
+ test("shows an error the server returns", async () => {
2449
+ serve(makeStatus(), {
2450
+ test: async (): Promise<HTTPErrorResponse> => {
2557
2451
  return new HTTPErrorResponse(
2558
2452
  400,
2559
2453
  { error: "Kubernetes cluster not found." },
2560
2454
  {},
2561
2455
  );
2562
- });
2563
- openAiPage();
2456
+ },
2457
+ });
2458
+ openAgentPage();
2564
2459
 
2565
- fireEvent.click(
2566
- await screen.findByTestId(
2567
- "ai-access-test-button",
2568
- {},
2569
- { timeout: WAIT_TIMEOUT },
2570
- ),
2571
- );
2460
+ fireEvent.click(await findTestId("ai-agent-test-button"));
2572
2461
 
2573
- const error: HTMLElement = await screen.findByTestId(
2574
- "ai-access-test-error",
2575
- {},
2576
- { timeout: WAIT_TIMEOUT },
2577
- );
2578
- expect(error).toHaveTextContent("The test could not run");
2579
- expect(error).toHaveTextContent("Kubernetes cluster not found.");
2580
- });
2462
+ const error: HTMLElement = await findTestId("ai-agent-test-error");
2463
+ expect(error).toHaveTextContent("The test could not run");
2464
+ expect(error).toHaveTextContent("Kubernetes cluster not found.");
2465
+ });
2581
2466
 
2582
- test("a permission refusal talks about running the test, not changing settings", async () => {
2583
- serveStatusAndTest(makeStatus(), async (): Promise<HTTPErrorResponse> => {
2467
+ test("a permission refusal talks about testing, not changing settings", async () => {
2468
+ serve(makeStatus(), {
2469
+ test: async (): Promise<HTTPErrorResponse> => {
2584
2470
  return new HTTPErrorResponse(
2585
2471
  422,
2586
2472
  {
@@ -2589,100 +2475,44 @@ describe("Kubernetes cluster AI page", () => {
2589
2475
  },
2590
2476
  {},
2591
2477
  );
2592
- });
2593
- openAiPage();
2594
-
2595
- fireEvent.click(
2596
- await screen.findByTestId(
2597
- "ai-access-test-button",
2598
- {},
2599
- { timeout: WAIT_TIMEOUT },
2600
- ),
2601
- );
2602
-
2603
- const error: HTMLElement = await screen.findByTestId(
2604
- "ai-access-test-error",
2605
- {},
2606
- { timeout: WAIT_TIMEOUT },
2607
- );
2608
- expect(error).toHaveTextContent(
2609
- "Running the access test needs permission to edit this cluster",
2610
- );
2611
- expect(error).toHaveTextContent("Nothing on the cluster");
2612
- expect(error).not.toHaveTextContent("change this cluster's AI access");
2478
+ },
2613
2479
  });
2614
- });
2480
+ openAgentPage();
2615
2481
 
2616
- /*
2617
- * The finding: Settings roles and ReadKubernetesCluster may open this
2618
- * page but not read RunnerJob rows, so the command history showed a
2619
- * permission error. RunnerJob's ACL is not widened; they get an
2620
- * explanation and no failing request.
2621
- */
2622
- describe("command history permission", () => {
2623
- for (const permission of [
2624
- Permission.SettingsAdmin,
2625
- Permission.SettingsViewer,
2626
- Permission.ReadKubernetesCluster,
2627
- ]) {
2628
- test(`explains instead of failing for ${permission}`, async () => {
2629
- grant([...BASE_PERMISSIONS, permission]);
2630
- openAiPage();
2482
+ fireEvent.click(await findTestId("ai-agent-test-button"));
2631
2483
 
2632
- const note: HTMLElement = await screen.findByTestId(
2633
- "kubectl-jobs-permission-note",
2634
- {},
2635
- { timeout: WAIT_TIMEOUT },
2636
- );
2637
- expect(note).toHaveTextContent("Runbook Viewer");
2638
- expect(note).toHaveTextContent("Viewer");
2639
- expect(
2640
- screen.getByText("Commands OneUptime AI ran on this cluster"),
2641
- ).toBeInTheDocument();
2642
- expect(jobListRequest()).toBeUndefined();
2643
- });
2644
- }
2484
+ const error: HTMLElement = await findTestId("ai-agent-test-error");
2485
+ expect(error).toHaveTextContent(
2486
+ "Testing the connection needs permission to edit this cluster",
2487
+ );
2488
+ expect(error).toHaveTextContent("Nothing on the cluster");
2489
+ expect(error).not.toHaveTextContent("change this cluster's AI access");
2490
+ });
2491
+ });
2645
2492
 
2646
- for (const permission of [Permission.ProjectMember, Permission.Viewer]) {
2647
- test(`shows the table for ${permission}`, async () => {
2648
- grant([...BASE_PERMISSIONS, permission]);
2649
- openAiPage();
2493
+ describe("the old AI page route", () => {
2494
+ function CurrentPath(): React.ReactElement {
2495
+ const location: ReturnType<typeof useLocation> = useLocation();
2496
+ return <p data-testid="current-path">{location.pathname}</p>;
2497
+ }
2650
2498
 
2651
- await findText(
2652
- "OneUptime AI has not run any kubectl commands on this cluster yet.",
2653
- );
2654
- expect(
2655
- screen.queryByTestId("kubectl-jobs-permission-note"),
2656
- ).not.toBeInTheDocument();
2657
- const request: ListRequest | undefined = jobListRequest();
2658
- expect(request?.query).toEqual(
2659
- expect.objectContaining({ stepType: RunbookStepType.Kubectl }),
2660
- );
2661
- expect(String(request?.query?.["kubernetesClusterId"])).toBe(
2662
- CLUSTER_ID.toString(),
2663
- );
2664
- });
2665
- }
2666
- });
2499
+ test("sends /ai to the AI agent page", async () => {
2500
+ goTo(OLD_AI_PAGE_PATH);
2501
+ render(
2502
+ <MemoryRouter initialEntries={[OLD_AI_PAGE_PATH]}>
2503
+ <Routes>
2504
+ <PageRoute
2505
+ path={String(RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_AI])}
2506
+ element={<KubernetesClusterViewAiRedirect />}
2507
+ />
2508
+ <PageRoute
2509
+ path={String(RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_AI_AGENT])}
2510
+ element={<CurrentPath />}
2511
+ />
2512
+ </Routes>
2513
+ </MemoryRouter>,
2514
+ );
2667
2515
 
2668
- describe("settings edit button", () => {
2669
- test("is locked, with the reason, for a user who may only read the cluster", async () => {
2670
- grant([...BASE_PERMISSIONS, Permission.ReadKubernetesCluster]);
2671
- openAiPage();
2672
-
2673
- await findText(SETTINGS_CARD_TITLE);
2674
- const button: HTMLElement | null = (
2675
- await findText("Edit AI access")
2676
- ).closest("button");
2677
- expect(button).not.toBeNull();
2678
- expect(button).toBeDisabled();
2679
-
2680
- fireEvent.click(button as HTMLElement);
2681
- expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
2682
- // A reader is not told about loosening permissions they could not use.
2683
- expect(
2684
- screen.queryByTestId("kubernetes-ai-access-admin-note"),
2685
- ).not.toBeInTheDocument();
2686
- });
2516
+ expect(await findTestId("current-path")).toHaveTextContent(AGENT_PAGE_PATH);
2687
2517
  });
2688
2518
  });