@oneuptime/common 14.0.9 → 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.
- package/Models/DatabaseModels/AutoRemediationSuggestion.ts +60 -0
- package/Models/DatabaseModels/CephCluster.ts +213 -0
- package/Models/DatabaseModels/DatabaseServer.ts +213 -0
- package/Models/DatabaseModels/DockerHost.ts +213 -0
- package/Models/DatabaseModels/DockerSwarmCluster.ts +213 -0
- package/Models/DatabaseModels/GlobalConfig.ts +1 -1
- package/Models/DatabaseModels/Host.ts +213 -0
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/PodmanHost.ts +213 -0
- package/Models/DatabaseModels/ProxmoxCluster.ts +213 -0
- package/Models/DatabaseModels/ResourceAiAgent.ts +419 -0
- package/Models/DatabaseModels/RunnerJob.ts +144 -0
- package/Models/DatabaseModels/VMwareVCenter.ts +213 -0
- package/Server/API/AutoRemediationAPI.ts +392 -1
- package/Server/API/ResourceAiAccessAPI.ts +1669 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.ts +423 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Infrastructure/Semaphore.ts +22 -0
- package/Server/Middleware/TelemetryIngest.ts +15 -5
- package/Server/Services/AnalyticsDatabaseService.ts +45 -1
- package/Server/Services/AutoRemediationRuleEngineService.ts +940 -112
- package/Server/Services/CephClusterService.ts +109 -1
- package/Server/Services/DatabaseServerService.ts +116 -1
- package/Server/Services/DockerHostService.ts +109 -1
- package/Server/Services/DockerSwarmClusterService.ts +113 -1
- package/Server/Services/HostService.ts +126 -4
- package/Server/Services/MetricRecordingRuleService.ts +47 -0
- package/Server/Services/PodmanHostService.ts +109 -1
- package/Server/Services/ProxmoxClusterService.ts +110 -1
- package/Server/Services/ResourceAiAccessService.ts +1439 -0
- package/Server/Services/ResourceAiAgentJobService.ts +202 -0
- package/Server/Services/ResourceAiAgentService.ts +2432 -0
- package/Server/Services/RunnerJobService.ts +611 -4
- package/Server/Services/TelemetryUsageBillingService.ts +28 -0
- package/Server/Services/TraceRecordingRuleService.ts +33 -0
- package/Server/Services/VMwareVCenterService.ts +110 -1
- package/Server/Utils/AI/Remediation/RemediationCommandTools.ts +1434 -32
- package/Server/Utils/AI/Remediation/RemediationExecutionRunner.ts +935 -21
- package/Server/Utils/AI/Remediation/RemediationPlanRunner.ts +25 -1
- package/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.ts +577 -0
- package/Server/Utils/AI/ResourceAccess/ResourceAccessContext.ts +271 -0
- package/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.ts +45 -0
- package/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.ts +964 -0
- package/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.ts +333 -0
- package/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.ts +873 -0
- package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +72 -2
- package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +72 -5
- package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +72 -5
- package/Server/Utils/AutoRemediation/CommandPlanExecutor.ts +396 -13
- package/Server/Utils/AutoRemediation/RemediationVerifier.ts +35 -2
- package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +9 -1
- package/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.ts +920 -0
- package/Server/Utils/SessionReplay/SessionReplayUsage.ts +84 -6
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +35 -15
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.ts +33 -15
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +21 -1
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +298 -224
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.ts +35 -15
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.ts +389 -175
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +366 -143
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.ts +163 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.ts +396 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.ts +418 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.ts +152 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.ts +251 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.ts +214 -0
- package/Tests/App/Dashboard/ClusterAccessNotice.test.tsx +93 -0
- package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +18 -6
- package/Tests/App/Dashboard/InvestigationInfrastructureTools.test.tsx +506 -0
- package/Tests/App/Dashboard/RemediationSuggestionCardDescription.test.tsx +209 -0
- package/Tests/App/Dashboard/RemediationSuggestionCardResource.test.tsx +317 -0
- package/Tests/App/Dashboard/ResourceAiAccessSettingsUtil.test.ts +921 -0
- package/Tests/App/Dashboard/ResourceAiAgentInstall.test.ts +860 -0
- package/Tests/App/Dashboard/ResourceAiAgentPage.test.tsx +1721 -0
- package/Tests/App/Dashboard/ResourceAiAgentStatus.test.ts +1002 -0
- package/Tests/App/Dashboard/ResourceAiInsightsPage.test.tsx +931 -0
- package/Tests/App/Dashboard/ResourceAiNavigation.test.tsx +575 -0
- package/Tests/App/Dashboard/RunbookStepTypeMaps.test.ts +38 -7
- package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +33 -1
- package/Tests/Models/DatabaseModels/ResourceAiAccessColumns.test.ts +738 -0
- package/Tests/Models/DatabaseModels/ResourceAiAgentModel.test.ts +842 -0
- package/Tests/Server/API/AutoRemediationApproveResourceRound.test.ts +835 -0
- package/Tests/Server/API/ResourceAiAccessAPI.test.ts +2730 -0
- package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +60 -0
- package/Tests/Server/Infrastructure/Postgres/AddResourceAiAgentsMigration.test.ts +659 -0
- package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +31 -0
- package/Tests/Server/Middleware/TelemetryIngestBrowserKey.test.ts +63 -5
- package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerPinnedKey.test.ts +103 -5
- package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerRateLimit.test.ts +19 -1
- package/Tests/Server/Services/AutoRemediationResourceRuleEngine.test.ts +1279 -0
- package/Tests/Server/Services/DatabaseServerService.test.ts +55 -0
- package/Tests/Server/Services/GroupTelemetryUsageExcludeNames.test.ts +299 -0
- package/Tests/Server/Services/HostServiceFindOrCreateMemo.test.ts +44 -0
- package/Tests/Server/Services/MonitorProbeServiceIntervalScheduling.test.ts +45 -6
- package/Tests/Server/Services/RecordingRuleReservedMetricName.test.ts +171 -0
- package/Tests/Server/Services/ResourceAiAccessChangeAuthorization.test.ts +256 -0
- package/Tests/Server/Services/ResourceAiAccessService.test.ts +1373 -0
- package/Tests/Server/Services/ResourceAiAgentJobService.test.ts +375 -0
- package/Tests/Server/Services/ResourceAiAgentServiceHelpers.test.ts +1114 -0
- package/Tests/Server/Services/ResourceAiAgentServiceLifecycle.test.ts +1050 -0
- package/Tests/Server/Services/ResourceAiAgentServiceRegister.test.ts +2251 -0
- package/Tests/Server/Services/ResourceAiSettingsCreate.test.ts +373 -0
- package/Tests/Server/Services/ResourceAiSettingsPermission.test.ts +1042 -0
- package/Tests/Server/Services/ResourceServiceDeleteCleansUpAi.test.ts +319 -0
- package/Tests/Server/Services/RunnerJobEnqueueKubectl.test.ts +105 -0
- package/Tests/Server/Services/RunnerJobEnqueueResourceCommand.test.ts +986 -0
- package/Tests/Server/Services/RunnerJobResourceCommandLane.test.ts +211 -0
- package/Tests/Server/Services/RunnerJobResourceCommandTimeoutAndRedaction.test.ts +352 -0
- package/Tests/Server/Services/TelemetryUsageBillingSloExclusion.test.ts +218 -0
- package/Tests/Server/TestingUtils/Services/FakeRunnerJobCount.ts +145 -0
- package/Tests/Server/Utils/AI/InvestigationInfrastructureAccessWiring.test.ts +453 -0
- package/Tests/Server/Utils/AI/InvestigationInfrastructureReport.test.ts +721 -0
- package/Tests/Server/Utils/AI/RemediationCommandTools.test.ts +94 -1
- package/Tests/Server/Utils/AI/RemediationCommandToolsResource.test.ts +1530 -0
- package/Tests/Server/Utils/AI/RemediationExecutionRunnerResourceMode.test.ts +1249 -0
- package/Tests/Server/Utils/AI/RemediationPlanRunner.test.ts +117 -0
- package/Tests/Server/Utils/AI/RemediationResourceCopyParity.test.ts +463 -0
- package/Tests/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.test.ts +722 -0
- package/Tests/Server/Utils/AI/ResourceAccess/ResourceAccessContext.test.ts +266 -0
- package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.test.ts +1220 -0
- package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.test.ts +569 -0
- package/Tests/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.test.ts +830 -0
- package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorResource.test.ts +717 -0
- package/Tests/Server/Utils/AutoRemediation/RemediationVerifierResourceFollowUp.test.ts +338 -0
- package/Tests/Server/Utils/Monitor/Criteria/SessionReplayBudgetTemplateCriteria.test.ts +422 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.test.ts +1647 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayUsage.test.ts +143 -1
- package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.test.ts +33 -3
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsAccountNotLinked.test.ts +1093 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.test.ts +1049 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.test.ts +1676 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCards.test.ts +2884 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.test.ts +2490 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmit.test.ts +2293 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmitServerTimezone.test.ts +386 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.test.ts +1477 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.test.ts +1967 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsStripHtmlTags.test.ts +92 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsSubmittedFormRemoval.test.ts +1819 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.test.ts +1033 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezoneServerZone.test.ts +610 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsBotMessageHandling.test.ts +2610 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsCreateCommandsEndToEnd.test.ts +4672 -0
- package/Tests/Server/Utils/Workspace/WorkspaceCreateProjectReferences.test.ts +190 -65
- package/Tests/Types/AutoRemediation/AiRemediationCommandPlan.test.ts +10 -4
- package/Tests/Types/AutoRemediation/AiRemediationCommandPlanResourceCommand.test.ts +305 -0
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +277 -13
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +113 -0
- package/Tests/Types/Monitor/RumAlertTemplates.test.ts +773 -2
- package/Tests/Types/ResourceAiAgent/AiResourceType.test.ts +419 -0
- package/Tests/Types/ResourceAiAgent/ResourceAiAccess.test.ts +517 -0
- package/Tests/Types/Runbook/RunbookStepType.test.ts +147 -8
- package/Tests/UI/Rum/RecordingHealthDashboard.test.tsx +769 -1
- package/Tests/Utils/AiRemediation/Resource/CephCommandPolicy.test.ts +1653 -0
- package/Tests/Utils/AiRemediation/Resource/CephOutputRedaction.test.ts +204 -0
- package/Tests/Utils/AiRemediation/Resource/DatabaseCommandPolicy.test.ts +1077 -0
- package/Tests/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.test.ts +558 -0
- package/Tests/Utils/AiRemediation/Resource/DatabaseQueryRedactor.test.ts +834 -0
- package/Tests/Utils/AiRemediation/Resource/DockerCliGrammar.test.ts +747 -0
- package/Tests/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.test.ts +1259 -0
- package/Tests/Utils/AiRemediation/Resource/DockerOutputRedaction.test.ts +386 -0
- package/Tests/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.test.ts +782 -0
- package/Tests/Utils/AiRemediation/Resource/GovcCommandPolicy.test.ts +2259 -0
- package/Tests/Utils/AiRemediation/Resource/HostCommandPolicy.test.ts +2019 -0
- package/Tests/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.test.ts +2497 -0
- package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicy.test.ts +1305 -0
- package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.test.ts +585 -0
- package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactor.test.ts +334 -0
- package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactorCopyParity.test.ts +98 -0
- package/Tests/Utils/AiRemediation/Resource/ResourcePolicyImportClosure.test.ts +391 -0
- package/Tests/Utils/AiRemediation/ResourceAiAgentPolicyCopyParity.test.ts +497 -0
- package/Tests/Utils/SessionReplay/SessionReplayBudgetMetricType.test.ts +383 -0
- package/Types/AI/ResourceAiAccessApi.ts +140 -0
- package/Types/AI/ResourceAiAccessPermissions.ts +126 -0
- package/Types/AutoRemediation/AiRemediationCommandPlan.ts +183 -2
- package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +55 -22
- package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +35 -3
- package/Types/Monitor/RumAlertTemplates.ts +351 -4
- package/Types/ResourceAiAgent/AiResourceType.ts +310 -0
- package/Types/ResourceAiAgent/ResourceAiAccess.ts +574 -0
- package/Types/Rum/SessionReplayBudgetMetricType.ts +47 -0
- package/Types/Runbook/RunbookStepType.ts +29 -0
- package/Types/Telemetry/TelemetryIngestSurface.ts +14 -2
- package/Utils/AI/InvestigationReport.ts +121 -15
- package/Utils/AiRemediation/Resource/CephCommandPolicy.ts +1936 -0
- package/Utils/AiRemediation/Resource/DatabaseCommandPolicy.ts +166 -0
- package/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.ts +1532 -0
- package/Utils/AiRemediation/Resource/DatabaseQueryRedactor.ts +1394 -0
- package/Utils/AiRemediation/Resource/DockerCliGrammar.ts +1839 -0
- package/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.ts +490 -0
- package/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.ts +687 -0
- package/Utils/AiRemediation/Resource/GovcCommandPolicy.ts +2096 -0
- package/Utils/AiRemediation/Resource/HostCommandPolicy.ts +2982 -0
- package/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.ts +1679 -0
- package/Utils/AiRemediation/Resource/ResourceCommandPolicy.ts +851 -0
- package/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.ts +593 -0
- package/Utils/AiRemediation/Resource/ResourceOutputRedactor.ts +2812 -0
- package/Utils/SessionReplay/SessionReplayBudgetMetricType.ts +184 -0
- package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js +62 -0
- package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js.map +1 -1
- package/build/dist/Models/DatabaseModels/CephCluster.js +219 -0
- package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DatabaseServer.js +219 -0
- package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js +219 -0
- package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js +219 -0
- package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalConfig.js +1 -1
- package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Host.js +219 -0
- package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/PodmanHost.js +219 -0
- package/build/dist/Models/DatabaseModels/PodmanHost.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +219 -0
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ResourceAiAgent.js +439 -0
- package/build/dist/Models/DatabaseModels/ResourceAiAgent.js.map +1 -0
- package/build/dist/Models/DatabaseModels/RunnerJob.js +145 -0
- package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
- package/build/dist/Models/DatabaseModels/VMwareVCenter.js +219 -0
- package/build/dist/Models/DatabaseModels/VMwareVCenter.js.map +1 -1
- package/build/dist/Server/API/AutoRemediationAPI.js +231 -1
- package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
- package/build/dist/Server/API/ResourceAiAccessAPI.js +1119 -0
- package/build/dist/Server/API/ResourceAiAccessAPI.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js +168 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Infrastructure/Semaphore.js +6 -0
- package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
- package/build/dist/Server/Middleware/TelemetryIngest.js +12 -5
- package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +26 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +601 -3
- package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/CephClusterService.js +104 -0
- package/build/dist/Server/Services/CephClusterService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseServerService.js +99 -0
- package/build/dist/Server/Services/DatabaseServerService.js.map +1 -1
- package/build/dist/Server/Services/DockerHostService.js +104 -0
- package/build/dist/Server/Services/DockerHostService.js.map +1 -1
- package/build/dist/Server/Services/DockerSwarmClusterService.js +104 -0
- package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
- package/build/dist/Server/Services/HostService.js +114 -2
- package/build/dist/Server/Services/HostService.js.map +1 -1
- package/build/dist/Server/Services/MetricRecordingRuleService.js +46 -0
- package/build/dist/Server/Services/MetricRecordingRuleService.js.map +1 -1
- package/build/dist/Server/Services/PodmanHostService.js +104 -0
- package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
- package/build/dist/Server/Services/ProxmoxClusterService.js +104 -0
- package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
- package/build/dist/Server/Services/ResourceAiAccessService.js +1028 -0
- package/build/dist/Server/Services/ResourceAiAccessService.js.map +1 -0
- package/build/dist/Server/Services/ResourceAiAgentJobService.js +173 -0
- package/build/dist/Server/Services/ResourceAiAgentJobService.js.map +1 -0
- package/build/dist/Server/Services/ResourceAiAgentService.js +1687 -0
- package/build/dist/Server/Services/ResourceAiAgentService.js.map +1 -0
- package/build/dist/Server/Services/RunnerJobService.js +344 -5
- package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
- package/build/dist/Server/Services/TelemetryUsageBillingService.js +26 -0
- package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
- package/build/dist/Server/Services/TraceRecordingRuleService.js +37 -0
- package/build/dist/Server/Services/TraceRecordingRuleService.js.map +1 -1
- package/build/dist/Server/Services/VMwareVCenterService.js +104 -0
- package/build/dist/Server/Services/VMwareVCenterService.js.map +1 -1
- package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js +1029 -30
- package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js +633 -28
- package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js +21 -1
- package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js +330 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js.map +1 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js +160 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js.map +1 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js +33 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js.map +1 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js +640 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js.map +1 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js +226 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js.map +1 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js +600 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js.map +1 -0
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +50 -4
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +55 -4
- package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +55 -4
- package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js.map +1 -1
- package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js +290 -13
- package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js.map +1 -1
- package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js +29 -1
- package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js.map +1 -1
- package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +9 -1
- package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js +627 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js.map +1 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js +60 -5
- package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +19 -15
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js +19 -15
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +17 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +183 -209
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js +19 -15
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js +233 -167
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +263 -110
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js +129 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js +266 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js +258 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js +104 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js +183 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js +123 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js.map +1 -0
- package/build/dist/Types/AI/ResourceAiAccessApi.js +30 -0
- package/build/dist/Types/AI/ResourceAiAccessApi.js.map +1 -0
- package/build/dist/Types/AI/ResourceAiAccessPermissions.js +98 -0
- package/build/dist/Types/AI/ResourceAiAccessPermissions.js.map +1 -0
- package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js +116 -29
- package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +47 -21
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +5 -0
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
- package/build/dist/Types/Monitor/RumAlertTemplates.js +199 -3
- package/build/dist/Types/Monitor/RumAlertTemplates.js.map +1 -1
- package/build/dist/Types/ResourceAiAgent/AiResourceType.js +242 -0
- package/build/dist/Types/ResourceAiAgent/AiResourceType.js.map +1 -0
- package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js +275 -0
- package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js.map +1 -0
- package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js +45 -0
- package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js.map +1 -0
- package/build/dist/Types/Runbook/RunbookStepType.js +27 -0
- package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
- package/build/dist/Types/Telemetry/TelemetryIngestSurface.js +13 -2
- package/build/dist/Types/Telemetry/TelemetryIngestSurface.js.map +1 -1
- package/build/dist/Utils/AI/InvestigationReport.js +71 -11
- package/build/dist/Utils/AI/InvestigationReport.js.map +1 -1
- package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js +1462 -0
- package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js +122 -0
- package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js +1139 -0
- package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js +1060 -0
- package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js +1301 -0
- package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js +370 -0
- package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js +498 -0
- package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js +1565 -0
- package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js +2206 -0
- package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js +1129 -0
- package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js +565 -0
- package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js +408 -0
- package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js +1910 -0
- package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js.map +1 -0
- package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js +160 -0
- package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1721 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
jest,
|
|
8
|
+
test,
|
|
9
|
+
} from "@jest/globals";
|
|
10
|
+
import {
|
|
11
|
+
act,
|
|
12
|
+
cleanup,
|
|
13
|
+
fireEvent,
|
|
14
|
+
render,
|
|
15
|
+
screen,
|
|
16
|
+
waitFor,
|
|
17
|
+
within,
|
|
18
|
+
} from "@testing-library/react";
|
|
19
|
+
import React from "react";
|
|
20
|
+
import { MemoryRouter, Route as PageRoute, Routes } from "react-router-dom";
|
|
21
|
+
import ResourceAiAgentPage from "../../../../App/FeatureSet/Dashboard/src/Components/ResourceAiAgent/ResourceAiAgentPage";
|
|
22
|
+
import {
|
|
23
|
+
ResourceAiAgentDescriptor,
|
|
24
|
+
getResourceAiAgentDescriptor,
|
|
25
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/ResourceAiAgent/ResourceAiAgentDescriptors";
|
|
26
|
+
import {
|
|
27
|
+
RESOURCE_AI_AGENT_STATUS_POLL_INTERVAL_MS,
|
|
28
|
+
getResourceAiAgentNotInstalledText,
|
|
29
|
+
getResourceAiAgentPageSubtitle,
|
|
30
|
+
getResourceAiAgentReadyText,
|
|
31
|
+
getResourceAiAgentSignedOffText,
|
|
32
|
+
getResourceAiAgentSilentText,
|
|
33
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/ResourceAiAgent/ResourceAiAgentStatus";
|
|
34
|
+
import {
|
|
35
|
+
RESOURCE_REMEDIATION_MODE_LABELS,
|
|
36
|
+
RESOURCE_REMEDIATION_MODE_SUMMARIES,
|
|
37
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/ResourceAiAgent/ResourceAiAccessSettingsUtil";
|
|
38
|
+
import {
|
|
39
|
+
getResourceAiAgentComposeSnippet,
|
|
40
|
+
getResourceAiAgentLogsCommand,
|
|
41
|
+
getResourceAiAgentServiceName,
|
|
42
|
+
getResourceAiAgentWriteAccessCommands,
|
|
43
|
+
getResourceAiAgentWriteDisclosure,
|
|
44
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/ResourceAiAgent/ResourceAiAgentInstall";
|
|
45
|
+
import RouteMap from "../../../../App/FeatureSet/Dashboard/src/Utils/RouteMap";
|
|
46
|
+
import DockerHost from "../../../Models/DatabaseModels/DockerHost";
|
|
47
|
+
import Project from "../../../Models/DatabaseModels/Project";
|
|
48
|
+
import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
|
|
49
|
+
import HTTPResponse from "../../../Types/API/HTTPResponse";
|
|
50
|
+
import Route from "../../../Types/API/Route";
|
|
51
|
+
import ExceptionCode from "../../../Types/Exception/ExceptionCode";
|
|
52
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
53
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
54
|
+
import Permission from "../../../Types/Permission";
|
|
55
|
+
import AiResourceType, {
|
|
56
|
+
ALL_AI_RESOURCE_TYPES,
|
|
57
|
+
} from "../../../Types/ResourceAiAgent/AiResourceType";
|
|
58
|
+
import {
|
|
59
|
+
ResourceAiAccessGap,
|
|
60
|
+
ResourceAiAccessGapCode,
|
|
61
|
+
ResourceAiAccessStatus,
|
|
62
|
+
ResourceAiAgentPosture,
|
|
63
|
+
ResourceAiAgentSummary,
|
|
64
|
+
ResourceAiRemediationMode,
|
|
65
|
+
} from "../../../Types/ResourceAiAgent/ResourceAiAccess";
|
|
66
|
+
import API from "../../../UI/Utils/API/API";
|
|
67
|
+
import ModelAPI from "../../../UI/Utils/ModelAPI/ModelAPI";
|
|
68
|
+
import PermissionUtil from "../../../UI/Utils/Permission";
|
|
69
|
+
import User from "../../../UI/Utils/User";
|
|
70
|
+
import { goTo, PROJECT_ID } from "./SideMenuHarness";
|
|
71
|
+
|
|
72
|
+
jest.mock("react-i18next", () => {
|
|
73
|
+
return {
|
|
74
|
+
useTranslation: () => {
|
|
75
|
+
return {
|
|
76
|
+
t: (value: string): string => {
|
|
77
|
+
return value;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
* A resource's AI agent page (AI → AI agent) renders for real on its real
|
|
86
|
+
* route, with the status, test and reset-agent routes, the model API and
|
|
87
|
+
* the permission snapshot stubbed. One generic page serves every resource
|
|
88
|
+
* type through its descriptor; most cases run on a Docker host, and a
|
|
89
|
+
* table runs the page on each of the eight. Three cards at most: the
|
|
90
|
+
* agent's card in each of its states (connected, offline, unreachable, not
|
|
91
|
+
* installed — with the install instructions), "Needs attention" only when
|
|
92
|
+
* the server has gaps, and "What AI may do" with its Change modal — whose
|
|
93
|
+
* loosening rules, allowlist checks and confirmations mirror the server's.
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
// Real components fetch; give the waits room on a loaded CI box.
|
|
97
|
+
const WAIT_TIMEOUT: number = 20000;
|
|
98
|
+
|
|
99
|
+
const RESOURCE_ID: string = "44444444-0000-4000-8000-000000000004";
|
|
100
|
+
const AGENT_ID: string = "99999999-0000-4000-8000-000000000009";
|
|
101
|
+
|
|
102
|
+
const DOCKER: ResourceAiAgentDescriptor = getResourceAiAgentDescriptor(
|
|
103
|
+
AiResourceType.DockerHost,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const STATUS_ROUTE: string = "/resource-ai-access/status";
|
|
107
|
+
const TEST_ROUTE: string = "/resource-ai-access/test";
|
|
108
|
+
const RESET_ROUTE: string = "/resource-ai-access/reset-agent";
|
|
109
|
+
|
|
110
|
+
const SETTINGS_CARD_TITLE: string = "What AI may do";
|
|
111
|
+
const GAPS_CARD_TITLE: string = "Needs attention";
|
|
112
|
+
|
|
113
|
+
const BASE_PERMISSIONS: Array<Permission> = [
|
|
114
|
+
Permission.Public,
|
|
115
|
+
Permission.User,
|
|
116
|
+
Permission.CurrentUser,
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
// May edit the resource, but not loosen AI access.
|
|
120
|
+
const MEMBER_PERMISSIONS: Array<Permission> = [
|
|
121
|
+
...BASE_PERMISSIONS,
|
|
122
|
+
Permission.ProjectMember,
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
// May loosen AI access, reset the agent and change project AI settings.
|
|
126
|
+
const ADMIN_PERMISSIONS: Array<Permission> = [
|
|
127
|
+
...BASE_PERMISSIONS,
|
|
128
|
+
Permission.ProjectAdmin,
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const READER_PERMISSIONS: Array<Permission> = [
|
|
132
|
+
...BASE_PERMISSIONS,
|
|
133
|
+
Permission.ReadDockerHost,
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
function minutesAgo(minutes: number): string {
|
|
137
|
+
return new Date(Date.now() - minutes * 60 * 1000).toISOString();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function makePosture(
|
|
141
|
+
overrides: Partial<ResourceAiAgentPosture> = {},
|
|
142
|
+
): ResourceAiAgentPosture {
|
|
143
|
+
return {
|
|
144
|
+
resourceType: AiResourceType.DockerHost,
|
|
145
|
+
resourceIdentifier: "prod-docker-01",
|
|
146
|
+
agentVersion: "14.1.0",
|
|
147
|
+
allowWrites: false,
|
|
148
|
+
writeTargets: [],
|
|
149
|
+
protectedTargets: [],
|
|
150
|
+
toolVersion: "27.3.1",
|
|
151
|
+
reachable: true,
|
|
152
|
+
details: {},
|
|
153
|
+
...overrides,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function makeAgent(
|
|
158
|
+
overrides: Partial<ResourceAiAgentSummary> = {},
|
|
159
|
+
): ResourceAiAgentSummary {
|
|
160
|
+
return {
|
|
161
|
+
agentId: AGENT_ID,
|
|
162
|
+
connectionStatus: "connected",
|
|
163
|
+
isOnline: true,
|
|
164
|
+
agentVersion: "14.1.0",
|
|
165
|
+
lastAliveAt: new Date().toISOString(),
|
|
166
|
+
lastRegisteredAt: minutesAgo(60),
|
|
167
|
+
posture: makePosture(),
|
|
168
|
+
...overrides,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function gap(code: ResourceAiAccessGapCode): ResourceAiAccessGap {
|
|
173
|
+
return {
|
|
174
|
+
code,
|
|
175
|
+
title: `Title of ${code}`,
|
|
176
|
+
nextStep: `Next step for ${code}`,
|
|
177
|
+
blocksInvestigation: true,
|
|
178
|
+
blocksRemediation: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function makeStatus(
|
|
183
|
+
overrides: Partial<ResourceAiAccessStatus> = {},
|
|
184
|
+
): ResourceAiAccessStatus {
|
|
185
|
+
return {
|
|
186
|
+
resourceType: AiResourceType.DockerHost,
|
|
187
|
+
resourceId: RESOURCE_ID,
|
|
188
|
+
resourceName: "prod-docker-01",
|
|
189
|
+
isAiInvestigationEnabled: true,
|
|
190
|
+
aiRemediationMode: ResourceAiRemediationMode.Disabled,
|
|
191
|
+
aiCommandAllowlist: [],
|
|
192
|
+
agent: makeAgent(),
|
|
193
|
+
gaps: [gap("remediation_disabled")],
|
|
194
|
+
isInvestigationReady: true,
|
|
195
|
+
isRemediationReady: false,
|
|
196
|
+
...overrides,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function notInstalledStatus(): ResourceAiAccessStatus {
|
|
201
|
+
return makeStatus({
|
|
202
|
+
agent: null,
|
|
203
|
+
isInvestigationReady: false,
|
|
204
|
+
gaps: [gap("ai_agent_not_connected")],
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function statusResponse(
|
|
209
|
+
status: ResourceAiAccessStatus,
|
|
210
|
+
): HTTPResponse<JSONObject> {
|
|
211
|
+
return new HTTPResponse<JSONObject>(200, status as unknown as JSONObject, {});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let postSpy: ReturnType<typeof jest.spyOn>;
|
|
215
|
+
let getItemSpy: ReturnType<typeof jest.spyOn>;
|
|
216
|
+
let updateByIdSpy: ReturnType<typeof jest.spyOn>;
|
|
217
|
+
let savedModel: JSONObject = {};
|
|
218
|
+
|
|
219
|
+
type RouteAnswer = () => Promise<HTTPResponse<JSONObject> | HTTPErrorResponse>;
|
|
220
|
+
|
|
221
|
+
/*
|
|
222
|
+
* Answers the status route with `status`, and the test and reset routes
|
|
223
|
+
* with their handlers (a plain 200 by default).
|
|
224
|
+
*/
|
|
225
|
+
function serve(
|
|
226
|
+
status: ResourceAiAccessStatus,
|
|
227
|
+
handlers: { test?: RouteAnswer; reset?: RouteAnswer } = {},
|
|
228
|
+
): void {
|
|
229
|
+
postSpy.mockImplementation(
|
|
230
|
+
async (
|
|
231
|
+
request: unknown,
|
|
232
|
+
): Promise<HTTPResponse<JSONObject> | HTTPErrorResponse> => {
|
|
233
|
+
const url: string = String((request as JSONObject)["url"]);
|
|
234
|
+
if (url.endsWith(TEST_ROUTE) && handlers.test) {
|
|
235
|
+
return await handlers.test();
|
|
236
|
+
}
|
|
237
|
+
if (url.endsWith(RESET_ROUTE)) {
|
|
238
|
+
return handlers.reset
|
|
239
|
+
? await handlers.reset()
|
|
240
|
+
: new HTTPResponse<JSONObject>(200, { ok: true }, {});
|
|
241
|
+
}
|
|
242
|
+
return statusResponse(status);
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function failStatusRequest(): Promise<HTTPResponse<JSONObject>> {
|
|
248
|
+
throw new Error("Network Error");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The resource row the modal (and the install instructions) read.
|
|
252
|
+
function serveModel(overrides: JSONObject = {}): void {
|
|
253
|
+
savedModel = {
|
|
254
|
+
_id: RESOURCE_ID,
|
|
255
|
+
hostIdentifier: "prod-docker-01",
|
|
256
|
+
name: "prod-docker-01",
|
|
257
|
+
isAiInvestigationEnabled: true,
|
|
258
|
+
aiRemediationMode: ResourceAiRemediationMode.RequireApproval,
|
|
259
|
+
aiCommandAllowlist: [],
|
|
260
|
+
...overrides,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function postsTo(route: string): Array<JSONObject> {
|
|
265
|
+
return postSpy.mock.calls
|
|
266
|
+
.map((call: Array<unknown>): JSONObject => {
|
|
267
|
+
return (call[0] || {}) as JSONObject;
|
|
268
|
+
})
|
|
269
|
+
.filter((request: JSONObject): boolean => {
|
|
270
|
+
return String(request["url"]).endsWith(route);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function updateRequests(): Array<JSONObject> {
|
|
275
|
+
return updateByIdSpy.mock.calls.map((call: Array<unknown>): JSONObject => {
|
|
276
|
+
return (call[0] || {}) as JSONObject;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function grant(permissions: Array<Permission>): void {
|
|
281
|
+
jest.spyOn(User, "isMasterAdmin").mockReturnValue(false);
|
|
282
|
+
jest.spyOn(PermissionUtil, "getAllPermissions").mockReturnValue(permissions);
|
|
283
|
+
jest.spyOn(PermissionUtil, "getGlobalPermissions").mockReturnValue(null);
|
|
284
|
+
jest.spyOn(PermissionUtil, "getProjectPermissions").mockReturnValue({
|
|
285
|
+
projectId: new ObjectID(PROJECT_ID),
|
|
286
|
+
userId: ObjectID.generate(),
|
|
287
|
+
permissions: permissions.map((permission: Permission) => {
|
|
288
|
+
return {
|
|
289
|
+
permission: permission,
|
|
290
|
+
labelIds: [],
|
|
291
|
+
_type: "UserPermission",
|
|
292
|
+
};
|
|
293
|
+
}),
|
|
294
|
+
_type: "UserTenantAccessPermission",
|
|
295
|
+
} as unknown as ReturnType<typeof PermissionUtil.getProjectPermissions>);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function pagePath(descriptor: ResourceAiAgentDescriptor): string {
|
|
299
|
+
return RouteMap[descriptor.agentPage]!.toString()
|
|
300
|
+
.replace(":projectId", PROJECT_ID)
|
|
301
|
+
.replace(":id", RESOURCE_ID);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function openAgentPage(descriptor: ResourceAiAgentDescriptor = DOCKER): void {
|
|
305
|
+
const path: string = pagePath(descriptor);
|
|
306
|
+
goTo(path);
|
|
307
|
+
|
|
308
|
+
render(
|
|
309
|
+
<MemoryRouter initialEntries={[path]}>
|
|
310
|
+
<Routes>
|
|
311
|
+
<PageRoute
|
|
312
|
+
path={String(RouteMap[descriptor.agentPage])}
|
|
313
|
+
element={
|
|
314
|
+
<ResourceAiAgentPage
|
|
315
|
+
descriptor={descriptor}
|
|
316
|
+
pageRoute={RouteMap[descriptor.agentPage] as Route}
|
|
317
|
+
currentProject={Object.assign(new Project(), { name: "Acme" })}
|
|
318
|
+
hasPaymentMethod={true}
|
|
319
|
+
/>
|
|
320
|
+
}
|
|
321
|
+
/>
|
|
322
|
+
</Routes>
|
|
323
|
+
</MemoryRouter>,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function findText(text: string | RegExp): Promise<HTMLElement> {
|
|
328
|
+
return await screen.findByText(text, {}, { timeout: WAIT_TIMEOUT });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function findTestId(testId: string): Promise<HTMLElement> {
|
|
332
|
+
return await screen.findByTestId(testId, {}, { timeout: WAIT_TIMEOUT });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// The command a CodeBlock inside the element shows, exactly.
|
|
336
|
+
function codeIn(element: HTMLElement): string {
|
|
337
|
+
return element.querySelector("code")?.textContent || "";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function openChangeModal(
|
|
341
|
+
descriptor: ResourceAiAgentDescriptor = DOCKER,
|
|
342
|
+
): Promise<HTMLElement> {
|
|
343
|
+
fireEvent.click(await findTestId("ai-access-change-button"));
|
|
344
|
+
const dialog: HTMLElement = await screen.findByRole(
|
|
345
|
+
"dialog",
|
|
346
|
+
{},
|
|
347
|
+
{ timeout: WAIT_TIMEOUT },
|
|
348
|
+
);
|
|
349
|
+
// The form is on screen once its first field label is.
|
|
350
|
+
await within(dialog).findByText(
|
|
351
|
+
descriptor.investigateTitle,
|
|
352
|
+
{},
|
|
353
|
+
{ timeout: WAIT_TIMEOUT },
|
|
354
|
+
);
|
|
355
|
+
return dialog;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The open react-select menu's options (not native <option>s).
|
|
359
|
+
function menuOptions(): Array<HTMLElement> {
|
|
360
|
+
return screen
|
|
361
|
+
.queryAllByRole("option")
|
|
362
|
+
.filter((option: HTMLElement): boolean => {
|
|
363
|
+
return option.tagName !== "OPTION";
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function openDropdown(
|
|
368
|
+
dialog: HTMLElement,
|
|
369
|
+
name: RegExp,
|
|
370
|
+
): Promise<Array<string>> {
|
|
371
|
+
const combobox: HTMLElement = within(dialog).getByRole("combobox", {
|
|
372
|
+
name,
|
|
373
|
+
});
|
|
374
|
+
fireEvent.keyDown(combobox, { key: "ArrowDown", code: "ArrowDown" });
|
|
375
|
+
await waitFor(
|
|
376
|
+
() => {
|
|
377
|
+
expect(menuOptions().length).toBeGreaterThan(0);
|
|
378
|
+
},
|
|
379
|
+
{ timeout: WAIT_TIMEOUT },
|
|
380
|
+
);
|
|
381
|
+
return menuOptions().map((option: HTMLElement): string => {
|
|
382
|
+
return option.textContent || "";
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function pickOption(
|
|
387
|
+
dialog: HTMLElement,
|
|
388
|
+
name: RegExp,
|
|
389
|
+
optionText: string,
|
|
390
|
+
): Promise<void> {
|
|
391
|
+
await openDropdown(dialog, name);
|
|
392
|
+
const option: HTMLElement | undefined = menuOptions().find(
|
|
393
|
+
(candidate: HTMLElement): boolean => {
|
|
394
|
+
return candidate.textContent === optionText;
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
if (!option) {
|
|
398
|
+
throw new Error(`No option "${optionText}" in the ${name} dropdown.`);
|
|
399
|
+
}
|
|
400
|
+
fireEvent.click(option);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function toggleSwitch(
|
|
404
|
+
dialog: HTMLElement,
|
|
405
|
+
testId: string,
|
|
406
|
+
from: boolean,
|
|
407
|
+
): Promise<void> {
|
|
408
|
+
const toggle: HTMLElement = within(dialog).getByTestId(testId);
|
|
409
|
+
await waitFor(
|
|
410
|
+
() => {
|
|
411
|
+
expect(toggle).toHaveAttribute("aria-checked", String(from));
|
|
412
|
+
},
|
|
413
|
+
{ timeout: WAIT_TIMEOUT },
|
|
414
|
+
);
|
|
415
|
+
fireEvent.click(toggle);
|
|
416
|
+
expect(toggle).toHaveAttribute("aria-checked", String(!from));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function setAllowlistText(
|
|
420
|
+
dialog: HTMLElement,
|
|
421
|
+
value: string,
|
|
422
|
+
): Promise<void> {
|
|
423
|
+
const field: HTMLElement = await within(dialog).findByTestId(
|
|
424
|
+
"ai-command-allowlist-field",
|
|
425
|
+
{},
|
|
426
|
+
{ timeout: WAIT_TIMEOUT },
|
|
427
|
+
);
|
|
428
|
+
fireEvent.change(field, { target: { value } });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function waitForOneUpdate(): Promise<JSONObject> {
|
|
432
|
+
await waitFor(
|
|
433
|
+
() => {
|
|
434
|
+
expect(updateByIdSpy).toHaveBeenCalledTimes(1);
|
|
435
|
+
},
|
|
436
|
+
{ timeout: WAIT_TIMEOUT },
|
|
437
|
+
);
|
|
438
|
+
return updateRequests()[0]!["data"] as JSONObject;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function saveChangeModal(dialog: HTMLElement): void {
|
|
442
|
+
fireEvent.click(within(dialog).getByTestId("modal-footer-submit-button"));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function findDialogTitled(title: string): Promise<HTMLElement> {
|
|
446
|
+
const heading: HTMLElement = await findText(title);
|
|
447
|
+
const dialog: HTMLElement | null = heading.closest('[role="dialog"]');
|
|
448
|
+
if (!dialog) {
|
|
449
|
+
throw new Error(`"${title}" is not inside a dialog.`);
|
|
450
|
+
}
|
|
451
|
+
return dialog;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
beforeEach(() => {
|
|
455
|
+
window.localStorage.clear();
|
|
456
|
+
window.sessionStorage.clear();
|
|
457
|
+
grant(MEMBER_PERMISSIONS);
|
|
458
|
+
|
|
459
|
+
postSpy = jest.spyOn(API, "post");
|
|
460
|
+
serve(makeStatus());
|
|
461
|
+
|
|
462
|
+
serveModel();
|
|
463
|
+
getItemSpy = jest.spyOn(ModelAPI, "getItem");
|
|
464
|
+
getItemSpy.mockImplementation(async (): Promise<unknown> => {
|
|
465
|
+
return Object.assign(new DockerHost(), savedModel);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
updateByIdSpy = jest.spyOn(ModelAPI, "updateById");
|
|
469
|
+
updateByIdSpy.mockImplementation(
|
|
470
|
+
async (): Promise<HTTPResponse<JSONObject>> => {
|
|
471
|
+
return new HTTPResponse<JSONObject>(200, {}, {});
|
|
472
|
+
},
|
|
473
|
+
);
|
|
474
|
+
jest.spyOn(ModelAPI, "getCommonHeaders").mockReturnValue({});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
afterEach(() => {
|
|
478
|
+
cleanup();
|
|
479
|
+
jest.useRealTimers();
|
|
480
|
+
jest.restoreAllMocks();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe("the agent card", () => {
|
|
484
|
+
test("a connected agent: pill, sentence, meta line and the ready line", async () => {
|
|
485
|
+
openAgentPage();
|
|
486
|
+
|
|
487
|
+
expect(await findText("Docker AI agent")).toBeInTheDocument();
|
|
488
|
+
expect(await findTestId("ai-agent-status")).toHaveTextContent("Connected");
|
|
489
|
+
expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
|
|
490
|
+
"The Docker AI agent is running next to this Docker host.",
|
|
491
|
+
);
|
|
492
|
+
expect(screen.getByTestId("ai-agent-meta")).toHaveTextContent(
|
|
493
|
+
"agent v14.1.0 · Docker 27.3.1 · Read-only",
|
|
494
|
+
);
|
|
495
|
+
expect(screen.getByTestId("ai-agent-meta")).toHaveTextContent(
|
|
496
|
+
/^last seen /,
|
|
497
|
+
);
|
|
498
|
+
expect(screen.getByTestId("ai-agent-ready")).toHaveTextContent(
|
|
499
|
+
getResourceAiAgentReadyText(DOCKER),
|
|
500
|
+
);
|
|
501
|
+
expect(screen.queryByTestId("ai-agent-install")).not.toBeInTheDocument();
|
|
502
|
+
expect(
|
|
503
|
+
screen.queryByTestId("ai-agent-logs-command"),
|
|
504
|
+
).not.toBeInTheDocument();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("not installed: the service to add, pinned to this host's name, and nothing to test", async () => {
|
|
508
|
+
serve(notInstalledStatus());
|
|
509
|
+
openAgentPage();
|
|
510
|
+
|
|
511
|
+
expect(await findTestId("ai-agent-status")).toHaveTextContent(
|
|
512
|
+
"Not installed",
|
|
513
|
+
);
|
|
514
|
+
expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
|
|
515
|
+
getResourceAiAgentNotInstalledText(DOCKER),
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
// The name the collector reports, read from the host's row.
|
|
519
|
+
await waitFor(
|
|
520
|
+
() => {
|
|
521
|
+
expect(codeIn(screen.getByTestId("ai-agent-install-command"))).toBe(
|
|
522
|
+
getResourceAiAgentComposeSnippet({
|
|
523
|
+
resourceType: AiResourceType.DockerHost,
|
|
524
|
+
resourceId: RESOURCE_ID,
|
|
525
|
+
identity: "prod-docker-01",
|
|
526
|
+
}),
|
|
527
|
+
);
|
|
528
|
+
},
|
|
529
|
+
{ timeout: WAIT_TIMEOUT },
|
|
530
|
+
);
|
|
531
|
+
const identityRequest: JSONObject = getItemSpy.mock
|
|
532
|
+
.calls[0]![0] as JSONObject;
|
|
533
|
+
expect(identityRequest["modelType"]).toBe(DockerHost);
|
|
534
|
+
expect(String(identityRequest["id"])).toBe(RESOURCE_ID);
|
|
535
|
+
expect(identityRequest["select"]).toEqual({
|
|
536
|
+
_id: true,
|
|
537
|
+
hostIdentifier: true,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// No installer creates a Docker agent directory: never `cd` into one.
|
|
541
|
+
expect(codeIn(screen.getByTestId("ai-agent-install-start"))).toBe(
|
|
542
|
+
"# In the directory of your docker-compose.yml:\ndocker compose up -d oneuptime-docker-ai-agent",
|
|
543
|
+
);
|
|
544
|
+
// install.sh, the usual way, comes first.
|
|
545
|
+
expect(screen.getByTestId("ai-agent-install-where")).toHaveTextContent(
|
|
546
|
+
/^Installed the Docker agent with install\.sh\? Run it again/,
|
|
547
|
+
);
|
|
548
|
+
expect(
|
|
549
|
+
screen.getByTestId("ai-agent-install-variable-DOCKER_HOST_NAME"),
|
|
550
|
+
).toHaveTextContent("prod-docker-01");
|
|
551
|
+
expect(
|
|
552
|
+
screen.getByTestId("ai-agent-install-variable-ONEUPTIME_AI_ALLOW_WRITES"),
|
|
553
|
+
).toHaveTextContent("false");
|
|
554
|
+
expect(
|
|
555
|
+
screen.getByTestId("ai-agent-install-prerequisites"),
|
|
556
|
+
).toHaveTextContent("docker run");
|
|
557
|
+
expect(
|
|
558
|
+
screen.queryByTestId("ai-agent-test-button"),
|
|
559
|
+
).not.toBeInTheDocument();
|
|
560
|
+
expect(screen.queryByTestId("ai-agent-ready")).not.toBeInTheDocument();
|
|
561
|
+
expect(screen.queryByTestId("ai-agent-meta")).not.toBeInTheDocument();
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("not installed, and the host's name cannot be read: the collector's own variable", async () => {
|
|
565
|
+
serve(notInstalledStatus());
|
|
566
|
+
getItemSpy.mockImplementation(async (): Promise<never> => {
|
|
567
|
+
throw new Error("no access");
|
|
568
|
+
});
|
|
569
|
+
openAgentPage();
|
|
570
|
+
|
|
571
|
+
expect(codeIn(await findTestId("ai-agent-install-command"))).toContain(
|
|
572
|
+
"DOCKER_HOST_NAME=${DOCKER_HOST_NAME:-docker-host}",
|
|
573
|
+
);
|
|
574
|
+
expect(
|
|
575
|
+
screen.getByTestId("ai-agent-install-variable-DOCKER_HOST_NAME"),
|
|
576
|
+
).toHaveTextContent("from .env");
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("offline: the logs command for the agent's container", async () => {
|
|
580
|
+
serve(
|
|
581
|
+
makeStatus({
|
|
582
|
+
agent: makeAgent({ isOnline: false, lastAliveAt: minutesAgo(12) }),
|
|
583
|
+
}),
|
|
584
|
+
);
|
|
585
|
+
openAgentPage();
|
|
586
|
+
|
|
587
|
+
expect(await findTestId("ai-agent-status")).toHaveTextContent("Offline");
|
|
588
|
+
expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
|
|
589
|
+
getResourceAiAgentSilentText(DOCKER),
|
|
590
|
+
);
|
|
591
|
+
expect(codeIn(screen.getByTestId("ai-agent-logs-command"))).toBe(
|
|
592
|
+
getResourceAiAgentLogsCommand(AiResourceType.DockerHost),
|
|
593
|
+
);
|
|
594
|
+
expect(codeIn(screen.getByTestId("ai-agent-logs-command"))).toBe(
|
|
595
|
+
"docker logs --tail 100 oneuptime-docker-ai-agent",
|
|
596
|
+
);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("offline right after a sign-off: says it reconnects", async () => {
|
|
600
|
+
serve(
|
|
601
|
+
makeStatus({
|
|
602
|
+
agent: makeAgent({
|
|
603
|
+
isOnline: false,
|
|
604
|
+
connectionStatus: "disconnected",
|
|
605
|
+
lastAliveAt: new Date().toISOString(),
|
|
606
|
+
}),
|
|
607
|
+
}),
|
|
608
|
+
);
|
|
609
|
+
openAgentPage();
|
|
610
|
+
|
|
611
|
+
expect(await findTestId("ai-agent-sentence")).toHaveTextContent(
|
|
612
|
+
getResourceAiAgentSignedOffText(DOCKER),
|
|
613
|
+
);
|
|
614
|
+
expect(screen.getByTestId("ai-agent-sentence")).not.toHaveTextContent(
|
|
615
|
+
"has not checked in",
|
|
616
|
+
);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("online but unable to reach the host: why, the logs, and a test from the gap", async () => {
|
|
620
|
+
serve(
|
|
621
|
+
makeStatus({
|
|
622
|
+
agent: makeAgent({
|
|
623
|
+
posture: makePosture({
|
|
624
|
+
reachable: false,
|
|
625
|
+
reachError: "Cannot connect to the Docker daemon",
|
|
626
|
+
}),
|
|
627
|
+
}),
|
|
628
|
+
isInvestigationReady: false,
|
|
629
|
+
gaps: [gap("ai_agent_unreachable_resource")],
|
|
630
|
+
}),
|
|
631
|
+
{
|
|
632
|
+
test: async (): Promise<HTTPResponse<JSONObject>> => {
|
|
633
|
+
return new HTTPResponse<JSONObject>(
|
|
634
|
+
200,
|
|
635
|
+
{ ok: true, message: "It works.", results: [] },
|
|
636
|
+
{},
|
|
637
|
+
);
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
);
|
|
641
|
+
grant(ADMIN_PERMISSIONS);
|
|
642
|
+
openAgentPage();
|
|
643
|
+
|
|
644
|
+
expect(await findTestId("ai-agent-status")).toHaveTextContent("Connected");
|
|
645
|
+
expect(screen.getByTestId("ai-agent-sentence")).toHaveTextContent(
|
|
646
|
+
"could not reach this Docker host at its last check: Cannot connect to the Docker daemon",
|
|
647
|
+
);
|
|
648
|
+
expect(screen.getByTestId("ai-agent-logs-command")).toBeInTheDocument();
|
|
649
|
+
|
|
650
|
+
const row: HTMLElement = await findTestId(
|
|
651
|
+
"ai-agent-gap-ai_agent_unreachable_resource",
|
|
652
|
+
);
|
|
653
|
+
fireEvent.click(within(row).getByText("Test connection"));
|
|
654
|
+
expect(await findText("The connection works")).toBeInTheDocument();
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("warns when another agent tried to register for the host", async () => {
|
|
658
|
+
serve(
|
|
659
|
+
makeStatus({
|
|
660
|
+
agent: makeAgent({
|
|
661
|
+
lastRefusedRegistrationAt: new Date().toISOString(),
|
|
662
|
+
lastRefusedRegistrationReason: "previous_instance_online",
|
|
663
|
+
}),
|
|
664
|
+
}),
|
|
665
|
+
);
|
|
666
|
+
openAgentPage();
|
|
667
|
+
|
|
668
|
+
const warning: HTMLElement = await findTestId(
|
|
669
|
+
"ai-agent-refused-registration",
|
|
670
|
+
);
|
|
671
|
+
expect(warning).toHaveTextContent(
|
|
672
|
+
"Another agent tried to register for this Docker host at",
|
|
673
|
+
);
|
|
674
|
+
expect(warning).toHaveTextContent("DOCKER_HOST_NAME");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("shows the last command error the server recorded", async () => {
|
|
678
|
+
serve(
|
|
679
|
+
makeStatus({
|
|
680
|
+
aiAccessLastError: "docker: permission denied",
|
|
681
|
+
aiAccessLastVerifiedAt: minutesAgo(90),
|
|
682
|
+
}),
|
|
683
|
+
);
|
|
684
|
+
openAgentPage();
|
|
685
|
+
|
|
686
|
+
const line: HTMLElement = await findTestId("ai-agent-last-error");
|
|
687
|
+
expect(line).toHaveTextContent("Last command error (last worked");
|
|
688
|
+
expect(line).toHaveTextContent("docker: permission denied");
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
describe("the page heading", () => {
|
|
693
|
+
test("names the page and the resource", async () => {
|
|
694
|
+
openAgentPage();
|
|
695
|
+
|
|
696
|
+
const heading: HTMLElement = await screen.findByRole(
|
|
697
|
+
"heading",
|
|
698
|
+
{ level: 2, name: "AI agent" },
|
|
699
|
+
{ timeout: WAIT_TIMEOUT },
|
|
700
|
+
);
|
|
701
|
+
expect(heading).toBeInTheDocument();
|
|
702
|
+
expect(screen.getByTestId("ai-agent-page-heading")).toHaveTextContent(
|
|
703
|
+
getResourceAiAgentPageSubtitle(DOCKER),
|
|
704
|
+
);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("is shown while the status loads, and above the error when it fails", async () => {
|
|
708
|
+
let answer: (value: HTTPResponse<JSONObject>) => void = () => {
|
|
709
|
+
return undefined;
|
|
710
|
+
};
|
|
711
|
+
postSpy.mockImplementation((): Promise<HTTPResponse<JSONObject>> => {
|
|
712
|
+
return new Promise<HTTPResponse<JSONObject>>(
|
|
713
|
+
(resolve: (value: HTTPResponse<JSONObject>) => void) => {
|
|
714
|
+
answer = resolve;
|
|
715
|
+
},
|
|
716
|
+
);
|
|
717
|
+
});
|
|
718
|
+
openAgentPage();
|
|
719
|
+
|
|
720
|
+
expect(screen.getByTestId("ai-agent-page-heading")).toHaveTextContent(
|
|
721
|
+
"AI agent",
|
|
722
|
+
);
|
|
723
|
+
expect(screen.queryByText("Docker AI agent")).not.toBeInTheDocument();
|
|
724
|
+
|
|
725
|
+
await act(async () => {
|
|
726
|
+
answer(statusResponse(makeStatus()));
|
|
727
|
+
});
|
|
728
|
+
expect(await findText("Docker AI agent")).toBeInTheDocument();
|
|
729
|
+
cleanup();
|
|
730
|
+
|
|
731
|
+
postSpy.mockImplementation(failStatusRequest);
|
|
732
|
+
openAgentPage();
|
|
733
|
+
expect(await findText("Network Error")).toBeInTheDocument();
|
|
734
|
+
expect(screen.getByTestId("ai-agent-page-heading")).toBeInTheDocument();
|
|
735
|
+
expect(screen.queryByText("Docker AI agent")).not.toBeInTheDocument();
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
describe("status polling", () => {
|
|
740
|
+
test("asks about this resource once on load, by type and id", async () => {
|
|
741
|
+
openAgentPage();
|
|
742
|
+
|
|
743
|
+
expect(await findText(SETTINGS_CARD_TITLE)).toBeInTheDocument();
|
|
744
|
+
expect(postsTo(STATUS_ROUTE)).toHaveLength(1);
|
|
745
|
+
expect(postsTo(STATUS_ROUTE)[0]!["data"]).toEqual({
|
|
746
|
+
resourceType: AiResourceType.DockerHost,
|
|
747
|
+
resourceId: RESOURCE_ID,
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("keeps the last good status and warns inline when a background poll fails", async () => {
|
|
752
|
+
jest.useFakeTimers();
|
|
753
|
+
postSpy
|
|
754
|
+
.mockResolvedValueOnce(statusResponse(makeStatus()))
|
|
755
|
+
.mockImplementationOnce(failStatusRequest)
|
|
756
|
+
.mockResolvedValue(statusResponse(makeStatus()));
|
|
757
|
+
|
|
758
|
+
openAgentPage();
|
|
759
|
+
expect(await findText("Docker AI agent")).toBeInTheDocument();
|
|
760
|
+
|
|
761
|
+
await act(async () => {
|
|
762
|
+
jest.advanceTimersByTime(RESOURCE_AI_AGENT_STATUS_POLL_INTERVAL_MS);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
const warning: HTMLElement = await findTestId("ai-access-refresh-warning");
|
|
766
|
+
expect(warning).toHaveTextContent("Could not refresh the AI agent status");
|
|
767
|
+
expect(warning).toHaveTextContent("Network Error");
|
|
768
|
+
expect(screen.getByTestId("ai-agent-status")).toHaveTextContent(
|
|
769
|
+
"Connected",
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
await act(async () => {
|
|
773
|
+
jest.advanceTimersByTime(RESOURCE_AI_AGENT_STATUS_POLL_INTERVAL_MS);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
await waitFor(
|
|
777
|
+
() => {
|
|
778
|
+
expect(
|
|
779
|
+
screen.queryByTestId("ai-access-refresh-warning"),
|
|
780
|
+
).not.toBeInTheDocument();
|
|
781
|
+
},
|
|
782
|
+
{ timeout: WAIT_TIMEOUT },
|
|
783
|
+
);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("picks up an agent that connects while the page is open", async () => {
|
|
787
|
+
jest.useFakeTimers();
|
|
788
|
+
postSpy
|
|
789
|
+
.mockResolvedValueOnce(statusResponse(notInstalledStatus()))
|
|
790
|
+
.mockResolvedValue(statusResponse(makeStatus()));
|
|
791
|
+
|
|
792
|
+
openAgentPage();
|
|
793
|
+
expect(await findTestId("ai-agent-status")).toHaveTextContent(
|
|
794
|
+
"Not installed",
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
await act(async () => {
|
|
798
|
+
jest.advanceTimersByTime(RESOURCE_AI_AGENT_STATUS_POLL_INTERVAL_MS);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
await waitFor(
|
|
802
|
+
() => {
|
|
803
|
+
expect(screen.getByTestId("ai-agent-status")).toHaveTextContent(
|
|
804
|
+
"Connected",
|
|
805
|
+
);
|
|
806
|
+
},
|
|
807
|
+
{ timeout: WAIT_TIMEOUT },
|
|
808
|
+
);
|
|
809
|
+
expect(screen.queryByTestId("ai-agent-install")).not.toBeInTheDocument();
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("keeps the page when a poll returns a status it cannot read", async () => {
|
|
813
|
+
jest.useFakeTimers();
|
|
814
|
+
postSpy
|
|
815
|
+
.mockResolvedValueOnce(statusResponse(makeStatus()))
|
|
816
|
+
.mockResolvedValue(
|
|
817
|
+
new HTTPResponse<JSONObject>(200, { unexpected: true }, {}),
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
openAgentPage();
|
|
821
|
+
expect(await findText("Docker AI agent")).toBeInTheDocument();
|
|
822
|
+
|
|
823
|
+
await act(async () => {
|
|
824
|
+
jest.advanceTimersByTime(RESOURCE_AI_AGENT_STATUS_POLL_INTERVAL_MS);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
expect(await findTestId("ai-access-refresh-warning")).toHaveTextContent(
|
|
828
|
+
"cannot read",
|
|
829
|
+
);
|
|
830
|
+
expect(screen.getByText("Docker AI agent")).toBeInTheDocument();
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
describe("Needs attention", () => {
|
|
835
|
+
test("is not shown when the only gap is the fixes-off choice", async () => {
|
|
836
|
+
openAgentPage();
|
|
837
|
+
|
|
838
|
+
expect(await findText(SETTINGS_CARD_TITLE)).toBeInTheDocument();
|
|
839
|
+
expect(screen.queryByText(GAPS_CARD_TITLE)).not.toBeInTheDocument();
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("lists one row per server gap with its title and next step", async () => {
|
|
843
|
+
serve(
|
|
844
|
+
makeStatus({
|
|
845
|
+
isInvestigationReady: false,
|
|
846
|
+
gaps: [
|
|
847
|
+
gap("ai_balance_insufficient"),
|
|
848
|
+
gap("remediation_disabled"),
|
|
849
|
+
gap("llm_provider_missing"),
|
|
850
|
+
],
|
|
851
|
+
}),
|
|
852
|
+
);
|
|
853
|
+
openAgentPage();
|
|
854
|
+
|
|
855
|
+
const list: HTMLElement = await findTestId("ai-agent-gaps");
|
|
856
|
+
const rows: Array<HTMLElement> = within(list).getAllByRole("listitem");
|
|
857
|
+
expect(rows).toHaveLength(2);
|
|
858
|
+
expect(rows[0]).toHaveTextContent("Title of ai_balance_insufficient");
|
|
859
|
+
expect(rows[0]).toHaveTextContent("Next step for ai_balance_insufficient");
|
|
860
|
+
expect(rows[1]).toHaveTextContent("Title of llm_provider_missing");
|
|
861
|
+
expect(screen.getByText(GAPS_CARD_TITLE)).toBeInTheDocument();
|
|
862
|
+
expect(screen.queryByTestId("ai-agent-ready")).not.toBeInTheDocument();
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test("an editor turns investigation on from its row", async () => {
|
|
866
|
+
serve(
|
|
867
|
+
makeStatus({
|
|
868
|
+
isAiInvestigationEnabled: false,
|
|
869
|
+
isInvestigationReady: false,
|
|
870
|
+
gaps: [gap("investigation_disabled")],
|
|
871
|
+
}),
|
|
872
|
+
);
|
|
873
|
+
openAgentPage();
|
|
874
|
+
|
|
875
|
+
fireEvent.click(await findTestId("ai-agent-gap-turn-on-investigation"));
|
|
876
|
+
|
|
877
|
+
expect(await waitForOneUpdate()).toEqual({
|
|
878
|
+
isAiInvestigationEnabled: true,
|
|
879
|
+
});
|
|
880
|
+
expect(String(updateRequests()[0]!["id"])).toBe(RESOURCE_ID);
|
|
881
|
+
expect(updateRequests()[0]!["modelType"]).toBe(DockerHost);
|
|
882
|
+
expect(await findTestId("ai-agent-action-notice")).toHaveTextContent(
|
|
883
|
+
"AI may now investigate this Docker host with read-only docker commands.",
|
|
884
|
+
);
|
|
885
|
+
await waitFor(
|
|
886
|
+
() => {
|
|
887
|
+
expect(postsTo(STATUS_ROUTE).length).toBeGreaterThanOrEqual(2);
|
|
888
|
+
},
|
|
889
|
+
{ timeout: WAIT_TIMEOUT },
|
|
890
|
+
);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
test("a failed one-click fix is shown on the page", async () => {
|
|
894
|
+
serve(
|
|
895
|
+
makeStatus({
|
|
896
|
+
isAiInvestigationEnabled: false,
|
|
897
|
+
isInvestigationReady: false,
|
|
898
|
+
gaps: [gap("investigation_disabled")],
|
|
899
|
+
}),
|
|
900
|
+
);
|
|
901
|
+
updateByIdSpy.mockImplementation(async (): Promise<never> => {
|
|
902
|
+
throw new HTTPErrorResponse(422, { error: "Not allowed." }, {});
|
|
903
|
+
});
|
|
904
|
+
openAgentPage();
|
|
905
|
+
|
|
906
|
+
fireEvent.click(await findTestId("ai-agent-gap-turn-on-investigation"));
|
|
907
|
+
|
|
908
|
+
expect(await findTestId("ai-agent-action-error")).toHaveTextContent(
|
|
909
|
+
"Not allowed.",
|
|
910
|
+
);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
test("a reader is told who to ask instead of getting a button", async () => {
|
|
914
|
+
grant(READER_PERMISSIONS);
|
|
915
|
+
serve(
|
|
916
|
+
makeStatus({
|
|
917
|
+
isAiInvestigationEnabled: false,
|
|
918
|
+
isInvestigationReady: false,
|
|
919
|
+
gaps: [gap("investigation_disabled")],
|
|
920
|
+
}),
|
|
921
|
+
);
|
|
922
|
+
openAgentPage();
|
|
923
|
+
|
|
924
|
+
const row: HTMLElement = await findTestId(
|
|
925
|
+
"ai-agent-gap-investigation_disabled",
|
|
926
|
+
);
|
|
927
|
+
expect(within(row).getByTestId("ai-agent-gap-ask")).toHaveTextContent(
|
|
928
|
+
"Ask a project owner or admin.",
|
|
929
|
+
);
|
|
930
|
+
expect(
|
|
931
|
+
screen.queryByTestId("ai-agent-gap-turn-on-investigation"),
|
|
932
|
+
).not.toBeInTheDocument();
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test("project-level gaps link an admin to the page that fixes them", async () => {
|
|
936
|
+
grant(ADMIN_PERMISSIONS);
|
|
937
|
+
serve(
|
|
938
|
+
makeStatus({
|
|
939
|
+
isInvestigationReady: false,
|
|
940
|
+
gaps: [
|
|
941
|
+
gap("ai_disabled_for_project"),
|
|
942
|
+
gap("llm_provider_missing"),
|
|
943
|
+
gap("ai_balance_insufficient"),
|
|
944
|
+
],
|
|
945
|
+
}),
|
|
946
|
+
);
|
|
947
|
+
openAgentPage();
|
|
948
|
+
|
|
949
|
+
const expected: Array<[string, string, string]> = [
|
|
950
|
+
["ai_disabled_for_project", "Open AI Features", "settings/ai-features"],
|
|
951
|
+
["llm_provider_missing", "Open LLM Providers", "settings/llm-providers"],
|
|
952
|
+
["ai_balance_insufficient", "Open AI Credits", "settings/ai-credits"],
|
|
953
|
+
];
|
|
954
|
+
for (const [code, linkText, path] of expected) {
|
|
955
|
+
const row: HTMLElement = await findTestId(`ai-agent-gap-${code}`);
|
|
956
|
+
expect(
|
|
957
|
+
within(row).getByText(linkText).closest("a")?.getAttribute("href"),
|
|
958
|
+
).toBe(`/dashboard/${PROJECT_ID}/${path}`);
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
test("project-level gaps tell a member who to ask", async () => {
|
|
963
|
+
serve(
|
|
964
|
+
makeStatus({
|
|
965
|
+
isInvestigationReady: false,
|
|
966
|
+
gaps: [gap("auto_remediation_disabled_for_project")],
|
|
967
|
+
}),
|
|
968
|
+
);
|
|
969
|
+
openAgentPage();
|
|
970
|
+
|
|
971
|
+
const row: HTMLElement = await findTestId(
|
|
972
|
+
"ai-agent-gap-auto_remediation_disabled_for_project",
|
|
973
|
+
);
|
|
974
|
+
expect(row).toHaveTextContent("Ask a project owner or admin.");
|
|
975
|
+
expect(within(row).queryByText("Open AI Features")).not.toBeInTheDocument();
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
test("the not-installed gap has no extra action: the instructions are on the card", async () => {
|
|
979
|
+
serve(notInstalledStatus());
|
|
980
|
+
openAgentPage();
|
|
981
|
+
|
|
982
|
+
const row: HTMLElement = await findTestId(
|
|
983
|
+
"ai-agent-gap-ai_agent_not_connected",
|
|
984
|
+
);
|
|
985
|
+
expect(within(row).queryByRole("button")).not.toBeInTheDocument();
|
|
986
|
+
expect(within(row).queryByRole("link")).not.toBeInTheDocument();
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
describe("What AI may do", () => {
|
|
991
|
+
test("shows investigation and fixes in plain words, with the fixes-off hint", async () => {
|
|
992
|
+
openAgentPage();
|
|
993
|
+
|
|
994
|
+
expect(await findTestId("ai-access-investigation-value")).toHaveTextContent(
|
|
995
|
+
"Yes — read-only (ps, inspect, logs, stats, events)",
|
|
996
|
+
);
|
|
997
|
+
expect(screen.getByText(DOCKER.investigateTitle)).toBeInTheDocument();
|
|
998
|
+
expect(screen.getByTestId("ai-access-fixes-value")).toHaveTextContent(
|
|
999
|
+
`Off — ${RESOURCE_REMEDIATION_MODE_SUMMARIES[ResourceAiRemediationMode.Disabled]}`,
|
|
1000
|
+
);
|
|
1001
|
+
expect(screen.getByTestId("ai-access-fixes-off-hint")).toHaveTextContent(
|
|
1002
|
+
"Want AI to propose fixes? Choose Ask for approval.",
|
|
1003
|
+
);
|
|
1004
|
+
expect(
|
|
1005
|
+
screen.queryByTestId("ai-access-write-commands"),
|
|
1006
|
+
).not.toBeInTheDocument();
|
|
1007
|
+
expect(
|
|
1008
|
+
screen.queryByTestId("ai-command-allowlist-in-effect"),
|
|
1009
|
+
).not.toBeInTheDocument();
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test("investigation off says so", async () => {
|
|
1013
|
+
serve(
|
|
1014
|
+
makeStatus({
|
|
1015
|
+
isAiInvestigationEnabled: false,
|
|
1016
|
+
isInvestigationReady: false,
|
|
1017
|
+
}),
|
|
1018
|
+
);
|
|
1019
|
+
openAgentPage();
|
|
1020
|
+
|
|
1021
|
+
expect(await findTestId("ai-access-investigation-value")).toHaveTextContent(
|
|
1022
|
+
"No — AI investigates with OneUptime data only",
|
|
1023
|
+
);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
test("fixes on with a read-only agent: the write switch, scoped first, and the disclosure", async () => {
|
|
1027
|
+
serve(
|
|
1028
|
+
makeStatus({
|
|
1029
|
+
aiRemediationMode: ResourceAiRemediationMode.RequireApproval,
|
|
1030
|
+
gaps: [gap("remediation_write_access_missing")],
|
|
1031
|
+
}),
|
|
1032
|
+
);
|
|
1033
|
+
openAgentPage();
|
|
1034
|
+
|
|
1035
|
+
const section: HTMLElement = await findTestId("ai-access-write-commands");
|
|
1036
|
+
const commands: ReturnType<typeof getResourceAiAgentWriteAccessCommands> =
|
|
1037
|
+
getResourceAiAgentWriteAccessCommands(AiResourceType.DockerHost);
|
|
1038
|
+
expect(section).toHaveTextContent("Give the agent write access");
|
|
1039
|
+
expect(codeIn(screen.getByTestId("ai-access-write-scoped-env"))).toBe(
|
|
1040
|
+
commands.scopedEnv,
|
|
1041
|
+
);
|
|
1042
|
+
expect(codeIn(screen.getByTestId("ai-access-write-scoped-env"))).toBe(
|
|
1043
|
+
"ONEUPTIME_AI_ALLOW_WRITES=true\nONEUPTIME_AI_WRITE_TARGETS=web-*,api-*",
|
|
1044
|
+
);
|
|
1045
|
+
expect(codeIn(screen.getByTestId("ai-access-write-all-env"))).toBe(
|
|
1046
|
+
"ONEUPTIME_AI_ALLOW_WRITES=true",
|
|
1047
|
+
);
|
|
1048
|
+
expect(codeIn(screen.getByTestId("ai-access-write-restart-command"))).toBe(
|
|
1049
|
+
commands.restartCommand,
|
|
1050
|
+
);
|
|
1051
|
+
expect(
|
|
1052
|
+
codeIn(screen.getByTestId("ai-access-write-restart-command")),
|
|
1053
|
+
).not.toContain("cd /opt/");
|
|
1054
|
+
const installerNote: HTMLElement = screen.getByTestId(
|
|
1055
|
+
"ai-access-write-installer-note",
|
|
1056
|
+
);
|
|
1057
|
+
const envIntro: HTMLElement = screen.getByTestId(
|
|
1058
|
+
"ai-access-write-env-intro",
|
|
1059
|
+
);
|
|
1060
|
+
expect(installerNote).toHaveTextContent(commands.installerNote!);
|
|
1061
|
+
expect(envIntro).toHaveTextContent(commands.envIntro);
|
|
1062
|
+
// install.sh, the usual way, before the Compose way.
|
|
1063
|
+
expect(
|
|
1064
|
+
installerNote.compareDocumentPosition(envIntro) &
|
|
1065
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
1066
|
+
).toBeTruthy();
|
|
1067
|
+
expect(
|
|
1068
|
+
installerNote.compareDocumentPosition(
|
|
1069
|
+
screen.getByTestId("ai-access-write-scoped-env"),
|
|
1070
|
+
) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
1071
|
+
).toBeTruthy();
|
|
1072
|
+
expect(screen.getByTestId("ai-access-write-disclosure")).toHaveTextContent(
|
|
1073
|
+
getResourceAiAgentWriteDisclosure(AiResourceType.DockerHost),
|
|
1074
|
+
);
|
|
1075
|
+
expect(
|
|
1076
|
+
screen.queryByTestId("ai-access-fixes-off-hint"),
|
|
1077
|
+
).not.toBeInTheDocument();
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
test("a database server has no targets to scope: just the switch", async () => {
|
|
1081
|
+
const database: ResourceAiAgentDescriptor = getResourceAiAgentDescriptor(
|
|
1082
|
+
AiResourceType.DatabaseServer,
|
|
1083
|
+
);
|
|
1084
|
+
serve(
|
|
1085
|
+
makeStatus({
|
|
1086
|
+
resourceType: AiResourceType.DatabaseServer,
|
|
1087
|
+
aiRemediationMode: ResourceAiRemediationMode.RequireApproval,
|
|
1088
|
+
agent: makeAgent({
|
|
1089
|
+
posture: makePosture({ resourceType: AiResourceType.DatabaseServer }),
|
|
1090
|
+
}),
|
|
1091
|
+
}),
|
|
1092
|
+
);
|
|
1093
|
+
openAgentPage(database);
|
|
1094
|
+
|
|
1095
|
+
await findTestId("ai-access-write-commands");
|
|
1096
|
+
expect(
|
|
1097
|
+
screen.queryByTestId("ai-access-write-scoped-env"),
|
|
1098
|
+
).not.toBeInTheDocument();
|
|
1099
|
+
expect(codeIn(screen.getByTestId("ai-access-write-all-env"))).toBe(
|
|
1100
|
+
"ONEUPTIME_AI_ALLOW_WRITES=true",
|
|
1101
|
+
);
|
|
1102
|
+
expect(
|
|
1103
|
+
screen.queryByTestId("ai-access-write-installer-note"),
|
|
1104
|
+
).not.toBeInTheDocument();
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
test("an agent that already writes gets no instructions, and says where it may change", async () => {
|
|
1108
|
+
serve(
|
|
1109
|
+
makeStatus({
|
|
1110
|
+
aiRemediationMode: ResourceAiRemediationMode.RequireApproval,
|
|
1111
|
+
agent: makeAgent({
|
|
1112
|
+
posture: makePosture({
|
|
1113
|
+
allowWrites: true,
|
|
1114
|
+
writeTargets: ["web-*", "api"],
|
|
1115
|
+
}),
|
|
1116
|
+
}),
|
|
1117
|
+
}),
|
|
1118
|
+
);
|
|
1119
|
+
openAgentPage();
|
|
1120
|
+
|
|
1121
|
+
expect(await findTestId("ai-agent-meta")).toHaveTextContent(
|
|
1122
|
+
"Can change: web-*, api",
|
|
1123
|
+
);
|
|
1124
|
+
expect(
|
|
1125
|
+
screen.queryByTestId("ai-access-write-commands"),
|
|
1126
|
+
).not.toBeInTheDocument();
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
test("the allowlist in effect is listed in Automatic mode only", async () => {
|
|
1130
|
+
serve(
|
|
1131
|
+
makeStatus({
|
|
1132
|
+
aiRemediationMode: ResourceAiRemediationMode.Automatic,
|
|
1133
|
+
aiCommandAllowlist: ["docker stop web"],
|
|
1134
|
+
}),
|
|
1135
|
+
);
|
|
1136
|
+
openAgentPage();
|
|
1137
|
+
|
|
1138
|
+
expect(
|
|
1139
|
+
await findTestId("ai-command-allowlist-in-effect"),
|
|
1140
|
+
).toHaveTextContent("docker stop web");
|
|
1141
|
+
cleanup();
|
|
1142
|
+
|
|
1143
|
+
serve(
|
|
1144
|
+
makeStatus({
|
|
1145
|
+
aiRemediationMode: ResourceAiRemediationMode.BypassApproval,
|
|
1146
|
+
aiCommandAllowlist: ["docker stop web"],
|
|
1147
|
+
}),
|
|
1148
|
+
);
|
|
1149
|
+
openAgentPage();
|
|
1150
|
+
expect(await findTestId("ai-access-fixes-value")).toHaveTextContent(
|
|
1151
|
+
"Bypass approval",
|
|
1152
|
+
);
|
|
1153
|
+
expect(
|
|
1154
|
+
screen.queryByTestId("ai-command-allowlist-in-effect"),
|
|
1155
|
+
).not.toBeInTheDocument();
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
test("an empty allowlist in Automatic mode reads None", async () => {
|
|
1159
|
+
serve(
|
|
1160
|
+
makeStatus({ aiRemediationMode: ResourceAiRemediationMode.Automatic }),
|
|
1161
|
+
);
|
|
1162
|
+
openAgentPage();
|
|
1163
|
+
|
|
1164
|
+
expect(
|
|
1165
|
+
await findTestId("ai-command-allowlist-in-effect"),
|
|
1166
|
+
).toHaveTextContent("None");
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
test("the Change button is locked, with the reason, for a reader", async () => {
|
|
1170
|
+
grant(READER_PERMISSIONS);
|
|
1171
|
+
openAgentPage();
|
|
1172
|
+
|
|
1173
|
+
const button: HTMLElement = await findTestId("ai-access-change-button");
|
|
1174
|
+
expect(button).toBeDisabled();
|
|
1175
|
+
fireEvent.click(button);
|
|
1176
|
+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
1177
|
+
});
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
describe("the Change modal: who may loosen", () => {
|
|
1181
|
+
test("reads the saved settings of this resource, and nothing else", async () => {
|
|
1182
|
+
openAgentPage();
|
|
1183
|
+
await openChangeModal();
|
|
1184
|
+
|
|
1185
|
+
const request: JSONObject = getItemSpy.mock.calls[0]![0] as JSONObject;
|
|
1186
|
+
expect(request["modelType"]).toBe(DockerHost);
|
|
1187
|
+
expect(String(request["id"])).toBe(RESOURCE_ID);
|
|
1188
|
+
expect(request["select"]).toEqual({
|
|
1189
|
+
_id: true,
|
|
1190
|
+
isAiInvestigationEnabled: true,
|
|
1191
|
+
aiRemediationMode: true,
|
|
1192
|
+
aiCommandAllowlist: true,
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
test("a member on an Ask-for-approval host is offered only Off and the current mode, and told why", async () => {
|
|
1197
|
+
openAgentPage();
|
|
1198
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1199
|
+
|
|
1200
|
+
expect(
|
|
1201
|
+
within(dialog).getByTestId("resource-ai-access-admin-note"),
|
|
1202
|
+
).toHaveTextContent("Edit Auto Remediation Rule");
|
|
1203
|
+
// Nothing saved to remove: no allowlist field.
|
|
1204
|
+
expect(
|
|
1205
|
+
within(dialog).queryByTestId("ai-command-allowlist-field"),
|
|
1206
|
+
).not.toBeInTheDocument();
|
|
1207
|
+
|
|
1208
|
+
expect(await openDropdown(dialog, /^Fixes/)).toEqual([
|
|
1209
|
+
RESOURCE_REMEDIATION_MODE_LABELS[ResourceAiRemediationMode.Disabled],
|
|
1210
|
+
`${RESOURCE_REMEDIATION_MODE_LABELS[ResourceAiRemediationMode.RequireApproval]} (current)`,
|
|
1211
|
+
]);
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
test("a member on an Off host cannot turn fixes on", async () => {
|
|
1215
|
+
serveModel({ aiRemediationMode: ResourceAiRemediationMode.Disabled });
|
|
1216
|
+
openAgentPage();
|
|
1217
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1218
|
+
|
|
1219
|
+
expect(await openDropdown(dialog, /^Fixes/)).toEqual([
|
|
1220
|
+
RESOURCE_REMEDIATION_MODE_LABELS[ResourceAiRemediationMode.Disabled],
|
|
1221
|
+
]);
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
test("an admin is offered every mode, and turns fixes on from Off", async () => {
|
|
1225
|
+
grant(ADMIN_PERMISSIONS);
|
|
1226
|
+
serveModel({ aiRemediationMode: ResourceAiRemediationMode.Disabled });
|
|
1227
|
+
openAgentPage();
|
|
1228
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1229
|
+
|
|
1230
|
+
expect(
|
|
1231
|
+
within(dialog).queryByTestId("resource-ai-access-admin-note"),
|
|
1232
|
+
).not.toBeInTheDocument();
|
|
1233
|
+
expect(await openDropdown(dialog, /^Fixes/)).toEqual(
|
|
1234
|
+
Object.values(ResourceAiRemediationMode).map(
|
|
1235
|
+
(mode: ResourceAiRemediationMode): string => {
|
|
1236
|
+
return RESOURCE_REMEDIATION_MODE_LABELS[mode];
|
|
1237
|
+
},
|
|
1238
|
+
),
|
|
1239
|
+
);
|
|
1240
|
+
fireEvent.click(
|
|
1241
|
+
menuOptions().find((option: HTMLElement): boolean => {
|
|
1242
|
+
return (
|
|
1243
|
+
option.textContent ===
|
|
1244
|
+
RESOURCE_REMEDIATION_MODE_LABELS[
|
|
1245
|
+
ResourceAiRemediationMode.RequireApproval
|
|
1246
|
+
]
|
|
1247
|
+
);
|
|
1248
|
+
})!,
|
|
1249
|
+
);
|
|
1250
|
+
saveChangeModal(dialog);
|
|
1251
|
+
|
|
1252
|
+
expect(await waitForOneUpdate()).toEqual({
|
|
1253
|
+
aiRemediationMode: ResourceAiRemediationMode.RequireApproval,
|
|
1254
|
+
});
|
|
1255
|
+
expect(updateRequests()[0]!["modelType"]).toBe(DockerHost);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
test("a member turns investigation off, and only that is sent", async () => {
|
|
1259
|
+
serveModel({ aiRemediationMode: ResourceAiRemediationMode.Automatic });
|
|
1260
|
+
openAgentPage();
|
|
1261
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1262
|
+
|
|
1263
|
+
await toggleSwitch(dialog, "ai-investigation-field", true);
|
|
1264
|
+
saveChangeModal(dialog);
|
|
1265
|
+
|
|
1266
|
+
expect(await waitForOneUpdate()).toEqual({
|
|
1267
|
+
isAiInvestigationEnabled: false,
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
test("a member on a Bypass-approval host may step down to Automatic, unconfirmed", async () => {
|
|
1272
|
+
serveModel({ aiRemediationMode: ResourceAiRemediationMode.BypassApproval });
|
|
1273
|
+
openAgentPage();
|
|
1274
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1275
|
+
|
|
1276
|
+
await pickOption(
|
|
1277
|
+
dialog,
|
|
1278
|
+
/^Fixes/,
|
|
1279
|
+
RESOURCE_REMEDIATION_MODE_LABELS[ResourceAiRemediationMode.Automatic],
|
|
1280
|
+
);
|
|
1281
|
+
saveChangeModal(dialog);
|
|
1282
|
+
|
|
1283
|
+
expect(await waitForOneUpdate()).toEqual({
|
|
1284
|
+
aiRemediationMode: ResourceAiRemediationMode.Automatic,
|
|
1285
|
+
});
|
|
1286
|
+
expect(
|
|
1287
|
+
screen.queryByText("Let riskier changes run without approval?"),
|
|
1288
|
+
).not.toBeInTheDocument();
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
test("a member may remove an allowlist entry", async () => {
|
|
1292
|
+
serveModel({
|
|
1293
|
+
aiRemediationMode: ResourceAiRemediationMode.Automatic,
|
|
1294
|
+
aiCommandAllowlist: ["docker stop web", "docker stop api"],
|
|
1295
|
+
});
|
|
1296
|
+
openAgentPage();
|
|
1297
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1298
|
+
|
|
1299
|
+
expect(
|
|
1300
|
+
within(dialog).getByText(/You can remove entries or clear the list/),
|
|
1301
|
+
).toBeInTheDocument();
|
|
1302
|
+
await setAllowlistText(dialog, "docker stop web");
|
|
1303
|
+
saveChangeModal(dialog);
|
|
1304
|
+
|
|
1305
|
+
expect(await waitForOneUpdate()).toEqual({
|
|
1306
|
+
aiCommandAllowlist: ["docker stop web"],
|
|
1307
|
+
});
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
test("a member who adds an entry is told it needs the admin set, and nothing is sent", async () => {
|
|
1311
|
+
serveModel({
|
|
1312
|
+
aiRemediationMode: ResourceAiRemediationMode.Automatic,
|
|
1313
|
+
aiCommandAllowlist: ["docker stop web"],
|
|
1314
|
+
});
|
|
1315
|
+
openAgentPage();
|
|
1316
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1317
|
+
|
|
1318
|
+
await setAllowlistText(dialog, "docker stop web\ndocker stop api");
|
|
1319
|
+
saveChangeModal(dialog);
|
|
1320
|
+
|
|
1321
|
+
expect(
|
|
1322
|
+
await within(dialog).findByText(
|
|
1323
|
+
/is not in the saved allowlist/,
|
|
1324
|
+
{},
|
|
1325
|
+
{ timeout: WAIT_TIMEOUT },
|
|
1326
|
+
),
|
|
1327
|
+
).toHaveTextContent("Edit Auto Remediation Rule");
|
|
1328
|
+
expect(updateByIdSpy).not.toHaveBeenCalled();
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
test("the allowlist field is shown only while Automatic is chosen", async () => {
|
|
1332
|
+
grant(ADMIN_PERMISSIONS);
|
|
1333
|
+
serveModel({ aiRemediationMode: ResourceAiRemediationMode.Automatic });
|
|
1334
|
+
openAgentPage();
|
|
1335
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1336
|
+
|
|
1337
|
+
expect(
|
|
1338
|
+
await within(dialog).findByTestId(
|
|
1339
|
+
"ai-command-allowlist-field",
|
|
1340
|
+
{},
|
|
1341
|
+
{ timeout: WAIT_TIMEOUT },
|
|
1342
|
+
),
|
|
1343
|
+
).toHaveAttribute("placeholder", DOCKER.allowlistPlaceholder);
|
|
1344
|
+
|
|
1345
|
+
await pickOption(
|
|
1346
|
+
dialog,
|
|
1347
|
+
/^Fixes/,
|
|
1348
|
+
RESOURCE_REMEDIATION_MODE_LABELS[
|
|
1349
|
+
ResourceAiRemediationMode.RequireApproval
|
|
1350
|
+
],
|
|
1351
|
+
);
|
|
1352
|
+
await waitFor(
|
|
1353
|
+
() => {
|
|
1354
|
+
expect(
|
|
1355
|
+
within(dialog).queryByTestId("ai-command-allowlist-field"),
|
|
1356
|
+
).not.toBeInTheDocument();
|
|
1357
|
+
},
|
|
1358
|
+
{ timeout: WAIT_TIMEOUT },
|
|
1359
|
+
);
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
test("an allowlist entry is checked with this resource type's policy", async () => {
|
|
1363
|
+
grant(ADMIN_PERMISSIONS);
|
|
1364
|
+
serveModel({ aiRemediationMode: ResourceAiRemediationMode.Automatic });
|
|
1365
|
+
openAgentPage();
|
|
1366
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1367
|
+
|
|
1368
|
+
// A host command means nothing on a Docker host.
|
|
1369
|
+
await setAllowlistText(dialog, "docker stop web\nsystemctl stop nginx");
|
|
1370
|
+
saveChangeModal(dialog);
|
|
1371
|
+
|
|
1372
|
+
expect(
|
|
1373
|
+
await within(dialog).findByText(
|
|
1374
|
+
/Entry 2: "systemctl stop nginx" does not start with a program the Docker AI agent runs/,
|
|
1375
|
+
{},
|
|
1376
|
+
{ timeout: WAIT_TIMEOUT },
|
|
1377
|
+
),
|
|
1378
|
+
).toBeInTheDocument();
|
|
1379
|
+
expect(updateByIdSpy).not.toHaveBeenCalled();
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
test("a failed save stays in the modal with the server's reason", async () => {
|
|
1383
|
+
grant(ADMIN_PERMISSIONS);
|
|
1384
|
+
serveModel({ aiRemediationMode: ResourceAiRemediationMode.Disabled });
|
|
1385
|
+
updateByIdSpy.mockImplementation(async (): Promise<never> => {
|
|
1386
|
+
throw new HTTPErrorResponse(
|
|
1387
|
+
403,
|
|
1388
|
+
{ error: "You need one of these permissions." },
|
|
1389
|
+
{},
|
|
1390
|
+
);
|
|
1391
|
+
});
|
|
1392
|
+
openAgentPage();
|
|
1393
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1394
|
+
|
|
1395
|
+
await pickOption(
|
|
1396
|
+
dialog,
|
|
1397
|
+
/^Fixes/,
|
|
1398
|
+
RESOURCE_REMEDIATION_MODE_LABELS[
|
|
1399
|
+
ResourceAiRemediationMode.RequireApproval
|
|
1400
|
+
],
|
|
1401
|
+
);
|
|
1402
|
+
saveChangeModal(dialog);
|
|
1403
|
+
|
|
1404
|
+
expect(await findTestId("ai-access-save-error")).toHaveTextContent(
|
|
1405
|
+
"You need one of these permissions.",
|
|
1406
|
+
);
|
|
1407
|
+
expect(screen.getByRole("dialog")).toBe(dialog);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
test("the modal's read failing is said in the modal", async () => {
|
|
1411
|
+
getItemSpy.mockImplementation(async (): Promise<null> => {
|
|
1412
|
+
return null;
|
|
1413
|
+
});
|
|
1414
|
+
openAgentPage();
|
|
1415
|
+
fireEvent.click(await findTestId("ai-access-change-button"));
|
|
1416
|
+
|
|
1417
|
+
expect(
|
|
1418
|
+
await findText(/Could not read this Docker host's AI settings/),
|
|
1419
|
+
).toBeInTheDocument();
|
|
1420
|
+
});
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
describe("the Change modal: confirmations", () => {
|
|
1424
|
+
test("Bypass approval is confirmed before it is saved", async () => {
|
|
1425
|
+
grant(ADMIN_PERMISSIONS);
|
|
1426
|
+
openAgentPage();
|
|
1427
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1428
|
+
|
|
1429
|
+
await pickOption(
|
|
1430
|
+
dialog,
|
|
1431
|
+
/^Fixes/,
|
|
1432
|
+
RESOURCE_REMEDIATION_MODE_LABELS[
|
|
1433
|
+
ResourceAiRemediationMode.BypassApproval
|
|
1434
|
+
],
|
|
1435
|
+
);
|
|
1436
|
+
saveChangeModal(dialog);
|
|
1437
|
+
|
|
1438
|
+
const confirm: HTMLElement = await findDialogTitled(
|
|
1439
|
+
"Turn on Bypass approval?",
|
|
1440
|
+
);
|
|
1441
|
+
expect(confirm).toHaveTextContent("on this Docker host on its own");
|
|
1442
|
+
expect(updateByIdSpy).not.toHaveBeenCalled();
|
|
1443
|
+
|
|
1444
|
+
fireEvent.click(within(confirm).getByText("Confirm and save"));
|
|
1445
|
+
|
|
1446
|
+
expect(await waitForOneUpdate()).toEqual({
|
|
1447
|
+
aiRemediationMode: ResourceAiRemediationMode.BypassApproval,
|
|
1448
|
+
});
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
test("a broad entry is confirmed, and backing out sends nothing", async () => {
|
|
1452
|
+
grant(ADMIN_PERMISSIONS);
|
|
1453
|
+
serveModel({ aiRemediationMode: ResourceAiRemediationMode.Automatic });
|
|
1454
|
+
openAgentPage();
|
|
1455
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1456
|
+
|
|
1457
|
+
await setAllowlistText(dialog, "docker stop *");
|
|
1458
|
+
saveChangeModal(dialog);
|
|
1459
|
+
|
|
1460
|
+
const confirm: HTMLElement = await findDialogTitled(
|
|
1461
|
+
"Let riskier changes run without approval?",
|
|
1462
|
+
);
|
|
1463
|
+
expect(confirm).toHaveTextContent(
|
|
1464
|
+
'The allowlist entry "docker stop *" uses a * for the object a change touches',
|
|
1465
|
+
);
|
|
1466
|
+
|
|
1467
|
+
fireEvent.click(within(confirm).getByText("Cancel"));
|
|
1468
|
+
await waitFor(
|
|
1469
|
+
() => {
|
|
1470
|
+
expect(
|
|
1471
|
+
screen.queryByText("Let riskier changes run without approval?"),
|
|
1472
|
+
).not.toBeInTheDocument();
|
|
1473
|
+
},
|
|
1474
|
+
{ timeout: WAIT_TIMEOUT },
|
|
1475
|
+
);
|
|
1476
|
+
expect(updateByIdSpy).not.toHaveBeenCalled();
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
test("an entry that names its container saves without a question", async () => {
|
|
1480
|
+
grant(ADMIN_PERMISSIONS);
|
|
1481
|
+
serveModel({ aiRemediationMode: ResourceAiRemediationMode.Automatic });
|
|
1482
|
+
openAgentPage();
|
|
1483
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1484
|
+
|
|
1485
|
+
await setAllowlistText(dialog, "docker stop web");
|
|
1486
|
+
saveChangeModal(dialog);
|
|
1487
|
+
|
|
1488
|
+
expect(await waitForOneUpdate()).toEqual({
|
|
1489
|
+
aiCommandAllowlist: ["docker stop web"],
|
|
1490
|
+
});
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
test("nothing changed: the modal closes without a write", async () => {
|
|
1494
|
+
openAgentPage();
|
|
1495
|
+
const dialog: HTMLElement = await openChangeModal();
|
|
1496
|
+
|
|
1497
|
+
saveChangeModal(dialog);
|
|
1498
|
+
|
|
1499
|
+
await waitFor(
|
|
1500
|
+
() => {
|
|
1501
|
+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
1502
|
+
},
|
|
1503
|
+
{ timeout: WAIT_TIMEOUT },
|
|
1504
|
+
);
|
|
1505
|
+
expect(updateByIdSpy).not.toHaveBeenCalled();
|
|
1506
|
+
});
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
describe("Test connection", () => {
|
|
1510
|
+
test("runs the test for this resource and shows each command's result", async () => {
|
|
1511
|
+
serve(makeStatus(), {
|
|
1512
|
+
test: async (): Promise<HTTPResponse<JSONObject>> => {
|
|
1513
|
+
return new HTTPResponse<JSONObject>(
|
|
1514
|
+
200,
|
|
1515
|
+
{
|
|
1516
|
+
ok: false,
|
|
1517
|
+
message: "docker info failed.",
|
|
1518
|
+
results: [
|
|
1519
|
+
{
|
|
1520
|
+
command: "docker version",
|
|
1521
|
+
succeeded: true,
|
|
1522
|
+
exitCode: 0,
|
|
1523
|
+
output: "Server: 27.3.1",
|
|
1524
|
+
errorMessage: null,
|
|
1525
|
+
},
|
|
1526
|
+
{
|
|
1527
|
+
command: "docker info",
|
|
1528
|
+
succeeded: false,
|
|
1529
|
+
exitCode: 1,
|
|
1530
|
+
output: "",
|
|
1531
|
+
errorMessage: "permission denied",
|
|
1532
|
+
},
|
|
1533
|
+
],
|
|
1534
|
+
status: makeStatus({
|
|
1535
|
+
agent: makeAgent({ agentVersion: "14.2.0" }),
|
|
1536
|
+
}) as unknown as JSONObject,
|
|
1537
|
+
},
|
|
1538
|
+
{},
|
|
1539
|
+
);
|
|
1540
|
+
},
|
|
1541
|
+
});
|
|
1542
|
+
openAgentPage();
|
|
1543
|
+
|
|
1544
|
+
fireEvent.click(await findTestId("ai-agent-test-button"));
|
|
1545
|
+
|
|
1546
|
+
const results: HTMLElement = await findTestId("ai-agent-test-results");
|
|
1547
|
+
expect(results).toHaveTextContent("The connection is not working yet");
|
|
1548
|
+
expect(results).toHaveTextContent("docker info failed.");
|
|
1549
|
+
const rows: Array<HTMLElement> = within(results).getAllByTestId(
|
|
1550
|
+
"ai-agent-test-result-row",
|
|
1551
|
+
);
|
|
1552
|
+
expect(rows).toHaveLength(2);
|
|
1553
|
+
expect(rows[0]).toHaveTextContent("docker version");
|
|
1554
|
+
expect(rows[0]).toHaveTextContent("succeeded");
|
|
1555
|
+
expect(rows[0]).toHaveTextContent("Server: 27.3.1");
|
|
1556
|
+
expect(rows[1]).toHaveTextContent("failed (exit 1)");
|
|
1557
|
+
expect(rows[1]).toHaveTextContent("permission denied");
|
|
1558
|
+
expect(postsTo(TEST_ROUTE)[0]!["data"]).toEqual({
|
|
1559
|
+
resourceType: AiResourceType.DockerHost,
|
|
1560
|
+
resourceId: RESOURCE_ID,
|
|
1561
|
+
});
|
|
1562
|
+
// The status the test returned replaces the one on screen.
|
|
1563
|
+
await waitFor(
|
|
1564
|
+
() => {
|
|
1565
|
+
expect(screen.getByTestId("ai-agent-meta")).toHaveTextContent(
|
|
1566
|
+
"agent v14.2.0",
|
|
1567
|
+
);
|
|
1568
|
+
},
|
|
1569
|
+
{ timeout: WAIT_TIMEOUT },
|
|
1570
|
+
);
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
test("a refused test says what it needs, not what the server says about settings", async () => {
|
|
1574
|
+
serve(makeStatus(), {
|
|
1575
|
+
test: async (): Promise<HTTPErrorResponse> => {
|
|
1576
|
+
// The server's NotAuthorizedException, which OneUptime sends as 422.
|
|
1577
|
+
return new HTTPErrorResponse(
|
|
1578
|
+
ExceptionCode.NotAuthorizedException,
|
|
1579
|
+
{ error: "You cannot change this resource's AI access." },
|
|
1580
|
+
{},
|
|
1581
|
+
);
|
|
1582
|
+
},
|
|
1583
|
+
});
|
|
1584
|
+
openAgentPage();
|
|
1585
|
+
|
|
1586
|
+
fireEvent.click(await findTestId("ai-agent-test-button"));
|
|
1587
|
+
|
|
1588
|
+
const error: HTMLElement = await findTestId("ai-agent-test-error");
|
|
1589
|
+
expect(error).toHaveTextContent(
|
|
1590
|
+
"Testing the connection needs permission to edit this Docker host",
|
|
1591
|
+
);
|
|
1592
|
+
expect(error).toHaveTextContent(
|
|
1593
|
+
"Nothing on the Docker host or in its AI settings was changed.",
|
|
1594
|
+
);
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
test("another failure is shown as the server said it", async () => {
|
|
1598
|
+
serve(makeStatus(), {
|
|
1599
|
+
test: async (): Promise<HTTPErrorResponse> => {
|
|
1600
|
+
return new HTTPErrorResponse(
|
|
1601
|
+
429,
|
|
1602
|
+
{ error: "An AI access test is already running." },
|
|
1603
|
+
{},
|
|
1604
|
+
);
|
|
1605
|
+
},
|
|
1606
|
+
});
|
|
1607
|
+
openAgentPage();
|
|
1608
|
+
|
|
1609
|
+
fireEvent.click(await findTestId("ai-agent-test-button"));
|
|
1610
|
+
|
|
1611
|
+
expect(await findTestId("ai-agent-test-error")).toHaveTextContent(
|
|
1612
|
+
"An AI access test is already running.",
|
|
1613
|
+
);
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
test("is locked for a reader, with the reason", async () => {
|
|
1617
|
+
grant(READER_PERMISSIONS);
|
|
1618
|
+
openAgentPage();
|
|
1619
|
+
|
|
1620
|
+
expect(await findTestId("ai-agent-test-button")).toBeDisabled();
|
|
1621
|
+
expect(
|
|
1622
|
+
screen.getByTestId("ai-agent-test-permission-note"),
|
|
1623
|
+
).toHaveTextContent("Testing the connection needs permission to edit");
|
|
1624
|
+
});
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
describe("Reset agent", () => {
|
|
1628
|
+
test("is only offered to the admin set", async () => {
|
|
1629
|
+
openAgentPage();
|
|
1630
|
+
|
|
1631
|
+
expect(await findText("Docker AI agent")).toBeInTheDocument();
|
|
1632
|
+
expect(
|
|
1633
|
+
screen.queryByTestId("ai-agent-reset-button"),
|
|
1634
|
+
).not.toBeInTheDocument();
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
test("an admin resets the agent after a confirmation", async () => {
|
|
1638
|
+
grant(ADMIN_PERMISSIONS);
|
|
1639
|
+
openAgentPage();
|
|
1640
|
+
|
|
1641
|
+
fireEvent.click(await findTestId("ai-agent-reset-button"));
|
|
1642
|
+
const confirm: HTMLElement = await findDialogTitled("Reset the AI agent?");
|
|
1643
|
+
expect(confirm).toHaveTextContent(
|
|
1644
|
+
"This revokes the Docker AI agent's key.",
|
|
1645
|
+
);
|
|
1646
|
+
expect(postsTo(RESET_ROUTE)).toHaveLength(0);
|
|
1647
|
+
|
|
1648
|
+
fireEvent.click(within(confirm).getByText("Reset agent"));
|
|
1649
|
+
|
|
1650
|
+
expect(await findTestId("ai-agent-action-notice")).toHaveTextContent(
|
|
1651
|
+
"The Docker AI agent was reset. It reconnects on its own within a few minutes.",
|
|
1652
|
+
);
|
|
1653
|
+
expect(postsTo(RESET_ROUTE)).toHaveLength(1);
|
|
1654
|
+
expect(postsTo(RESET_ROUTE)[0]!["data"]).toEqual({
|
|
1655
|
+
resourceType: AiResourceType.DockerHost,
|
|
1656
|
+
resourceId: RESOURCE_ID,
|
|
1657
|
+
});
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
test("a refused reset stays in its dialog with the reason", async () => {
|
|
1661
|
+
grant(ADMIN_PERMISSIONS);
|
|
1662
|
+
serve(makeStatus(), {
|
|
1663
|
+
reset: async (): Promise<HTTPErrorResponse> => {
|
|
1664
|
+
return new HTTPErrorResponse(403, { error: "Not yours to reset." }, {});
|
|
1665
|
+
},
|
|
1666
|
+
});
|
|
1667
|
+
openAgentPage();
|
|
1668
|
+
|
|
1669
|
+
fireEvent.click(await findTestId("ai-agent-reset-button"));
|
|
1670
|
+
const confirm: HTMLElement = await findDialogTitled("Reset the AI agent?");
|
|
1671
|
+
fireEvent.click(within(confirm).getByText("Reset agent"));
|
|
1672
|
+
|
|
1673
|
+
expect(
|
|
1674
|
+
await within(confirm).findByText(
|
|
1675
|
+
"Not yours to reset.",
|
|
1676
|
+
{},
|
|
1677
|
+
{ timeout: WAIT_TIMEOUT },
|
|
1678
|
+
),
|
|
1679
|
+
).toBeInTheDocument();
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
test("nothing to reset before an agent registered", async () => {
|
|
1683
|
+
grant(ADMIN_PERMISSIONS);
|
|
1684
|
+
serve(notInstalledStatus());
|
|
1685
|
+
openAgentPage();
|
|
1686
|
+
|
|
1687
|
+
expect(await findTestId("ai-agent-status")).toHaveTextContent(
|
|
1688
|
+
"Not installed",
|
|
1689
|
+
);
|
|
1690
|
+
expect(
|
|
1691
|
+
screen.queryByTestId("ai-agent-reset-button"),
|
|
1692
|
+
).not.toBeInTheDocument();
|
|
1693
|
+
});
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
describe("every resource type", () => {
|
|
1697
|
+
test.each(ALL_AI_RESOURCE_TYPES)(
|
|
1698
|
+
"%s: the page asks about its own type and names its own agent",
|
|
1699
|
+
async (type: AiResourceType) => {
|
|
1700
|
+
const descriptor: ResourceAiAgentDescriptor =
|
|
1701
|
+
getResourceAiAgentDescriptor(type);
|
|
1702
|
+
serve({
|
|
1703
|
+
...notInstalledStatus(),
|
|
1704
|
+
resourceType: type,
|
|
1705
|
+
});
|
|
1706
|
+
openAgentPage(descriptor);
|
|
1707
|
+
|
|
1708
|
+
expect(await findText(descriptor.agentName)).toBeInTheDocument();
|
|
1709
|
+
expect(screen.getByTestId("ai-agent-page-heading")).toHaveTextContent(
|
|
1710
|
+
getResourceAiAgentPageSubtitle(descriptor),
|
|
1711
|
+
);
|
|
1712
|
+
expect(postsTo(STATUS_ROUTE)[0]!["data"]).toEqual({
|
|
1713
|
+
resourceType: type,
|
|
1714
|
+
resourceId: RESOURCE_ID,
|
|
1715
|
+
});
|
|
1716
|
+
expect(codeIn(await findTestId("ai-agent-install-command"))).toContain(
|
|
1717
|
+
`${getResourceAiAgentServiceName(type)}:`,
|
|
1718
|
+
);
|
|
1719
|
+
},
|
|
1720
|
+
);
|
|
1721
|
+
});
|