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