diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/VersionCheckEventBuilderTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/VersionCheckEventBuilderTests.cs index 3099f7ec..d38cbfde 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/VersionCheckEventBuilderTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/VersionCheckEventBuilderTests.cs @@ -246,5 +246,63 @@ namespace AutopilotMonitor.Agent.Core.Tests.Core Assert.NotNull(result.ParseError); Assert.False(File.Exists(_paths.CheckedMarker)); // still cleaned up } + + private void WriteDowngradeBlocked(string current = "1.0.961", string latest = "1.0.951", string trigger = "runtime_hash_mismatch") + { + var json = new JObject + { + ["outcome"] = "downgrade_blocked", + ["reason"] = "downgrade_blocked", + ["currentVersion"] = current, + ["latestVersion"] = latest, + ["triggerReason"] = trigger, + ["skippedAtUtc"] = DateTime.UtcNow.ToString("O"), + ["errorDetail"] = string.Empty, + }; + File.WriteAllText(_paths.SkippedMarker, json.ToString()); + } + + [Fact] + public void DowngradeBlockedMarker_FirstTime_EmitsWarningEvent() + { + WriteDowngradeBlocked(); + + var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths); + + Assert.NotNull(result.Event); + Assert.Equal(EventSeverity.Warning, result.Event.Severity); + Assert.Equal("downgrade_blocked", result.Event.Data["outcome"]); + Assert.Equal("1.0.951", result.Event.Data["latestVersion"]); + Assert.Equal("1.0.961", result.Event.Data["currentVersion"]); + Assert.Equal("runtime_hash_mismatch", result.Event.Data["triggerReason"]); + Assert.Contains("downgrade from 1.0.961 to 1.0.951", result.Event.Message); + Assert.False(result.Deduped); + Assert.False(File.Exists(_paths.SkippedMarker)); + } + + [Fact] + public void DowngradeBlocked_SameSessionSameLatest_IsDeduped() + { + WriteLastEmit(SessionA, "1.0.951", "downgrade_blocked"); + WriteDowngradeBlocked(); + + var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths); + + Assert.Null(result.Event); + Assert.True(result.Deduped); + Assert.Equal("downgrade_blocked", result.Outcome); + } + + [Fact] + public void DowngradeBlocked_DifferentSession_EmitsFresh() + { + WriteLastEmit(SessionA, "1.0.951", "downgrade_blocked"); + WriteDowngradeBlocked(); + + var result = VersionCheckEventBuilder.TryBuild(SessionB, TenantId, DateTime.UtcNow, _paths); + + Assert.NotNull(result.Event); + Assert.False(result.Deduped); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index 995cca7e..fec51331 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -78,7 +78,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core /// Receives the backend-provided ZIP SHA-256 hash so the updater can use it as the /// trusted-channel integrity source. /// - public Func RuntimeSelfUpdateTriggerAsync { get; set; } + /// + /// Host-provided runtime self-update trigger. Parameters: (backend ZIP hash, allowDowngrade). + /// The hash seeds 's trusted-channel verification; the flag decides + /// whether a strictly older version advertised by the backend may replace the running binary. + /// + public Func RuntimeSelfUpdateTriggerAsync { get; set; } public MonitoringService(AgentConfiguration configuration, AgentLogger logger, string agentVersion, string previousExitType = "first_run", string previousCrashException = null, @@ -578,13 +583,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core // Hand the ZIP hash from the current config to the updater as trusted-channel source. // Null is acceptable — SelfUpdater falls back to the version.json hash. var zipHash = _remoteConfigService?.CurrentConfig?.LatestAgentSha256; + // Forward-only gate: admin must opt-in per tenant for a downgrade to apply. + var allowDowngrade = _remoteConfigService?.CurrentConfig?.AllowAgentDowngrade ?? false; Task.Run(async () => { try { - _logger.Info("Self-update: triggering forced update due to runtime hash mismatch"); - await RuntimeSelfUpdateTriggerAsync(zipHash).ConfigureAwait(false); + _logger.Info($"Self-update: triggering forced update due to runtime hash mismatch (allowDowngrade={allowDowngrade})"); + await RuntimeSelfUpdateTriggerAsync(zipHash, allowDowngrade).ConfigureAwait(false); _logger.Warning("Self-update: returned without restart — update did not apply, agent continues with stale binary"); } catch (Exception ex) diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/VersionCheckEventBuilder.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/VersionCheckEventBuilder.cs index 1bcad5aa..07728e68 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/VersionCheckEventBuilder.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/VersionCheckEventBuilder.cs @@ -75,8 +75,33 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core } else if (File.Exists(paths.SkippedMarker)) { - result.Event = BuildSkippedEvent(paths.SkippedMarker, sessionId, tenantId, out var skipOutcome); + var skipEvent = BuildSkippedEvent(paths.SkippedMarker, sessionId, tenantId, out var skipOutcome); result.Outcome = skipOutcome; + + // Session-scoped dedup for downgrade_blocked: the runtime hash-mismatch path + // re-fires on every agent restart, and each restart would otherwise emit another + // Warning event for the same current→latest pair. Suppress duplicates within the + // same session when the same downgrade is advertised by the backend. + if (string.Equals(skipOutcome, "downgrade_blocked", StringComparison.Ordinal)) + { + var latestVersion = ExtractLatestVersion(skipEvent); + var lastEmit = TryReadLastEmit(paths.LastEmit); + if (lastEmit != null && + string.Equals(lastEmit.SessionId, sessionId, StringComparison.Ordinal) && + string.Equals(lastEmit.LatestVersion, latestVersion, StringComparison.Ordinal) && + string.Equals(lastEmit.Outcome, "downgrade_blocked", StringComparison.Ordinal)) + { + result.Deduped = true; + } + else + { + result.Event = skipEvent; + } + } + else + { + result.Event = skipEvent; + } } else if (File.Exists(paths.CheckedMarker)) { @@ -189,6 +214,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core var latestVersion = (string)marker["latestVersion"]; var skippedAtUtc = (string)marker["skippedAtUtc"] ?? "unknown"; var errorDetail = (string)marker["errorDetail"] ?? string.Empty; + var triggerReason = (string)marker["triggerReason"]; // Marker may already carry outcome (new SelfUpdater). For forward-compat with old markers // lying around across an upgrade, derive it from the reason. @@ -205,6 +231,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core }; if (latestVersion != null) data["latestVersion"] = latestVersion; + if (!string.IsNullOrEmpty(triggerReason)) + data["triggerReason"] = triggerReason; + + string message; + if (outcome == "check_failed") + message = $"Agent version check failed (reason={reason})"; + else if (outcome == "downgrade_blocked") + message = $"Agent self-update blocked: downgrade from {currentVersion} to {latestVersion ?? "unknown"} (trigger={triggerReason ?? "unknown"}, allowDowngrade=false)"; + else + message = $"Agent self-update skipped (reason={reason})"; return new EnrollmentEvent { @@ -213,9 +249,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core EventType = Constants.EventTypes.AgentVersionCheck, Severity = EventSeverity.Warning, Source = "Agent", - Message = outcome == "check_failed" - ? $"Agent version check failed (reason={reason})" - : $"Agent self-update skipped (reason={reason})", + Message = message, Data = data, ImmediateUpload = true }; diff --git a/src/Agent/AutopilotMonitor.Agent/Program.cs b/src/Agent/AutopilotMonitor.Agent/Program.cs index 81cb7380..a7399dff 100644 --- a/src/Agent/AutopilotMonitor.Agent/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent/Program.cs @@ -101,6 +101,7 @@ namespace AutopilotMonitor.Agent // Try to load backend hash from cached remote-config.json (from a previous run). // This gives the self-updater a second trust channel for integrity verification. + bool cachedAllowAgentDowngrade = false; try { var cachedConfigPath = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config\remote-config.json"); @@ -117,6 +118,10 @@ namespace AutopilotMonitor.Agent { SelfUpdater.Log("Self-update: no backend integrity hash in cached config — will use version.json hash as fallback"); } + + cachedAllowAgentDowngrade = cachedConfig?.AllowAgentDowngrade ?? false; + if (cachedAllowAgentDowngrade) + SelfUpdater.Log("Self-update: cached config has AllowAgentDowngrade=true (downgrades permitted)"); } else { @@ -128,7 +133,11 @@ namespace AutopilotMonitor.Agent SelfUpdater.Log($"Self-update: could not load cached config for backend hash: {ex.Message}"); } - SelfUpdater.CheckAndApplyUpdateAsync(GetAgentVersion(), agentDir, consoleMode).GetAwaiter().GetResult(); + SelfUpdater.CheckAndApplyUpdateAsync( + GetAgentVersion(), + agentDir, + consoleMode, + allowDowngrade: cachedAllowAgentDowngrade).GetAwaiter().GetResult(); // If we reach here, no update was applied — continue normal startup try @@ -329,7 +338,7 @@ namespace AutopilotMonitor.Agent // invokes SelfUpdater with forceUpdate=true and a longer download timeout // (60s, vs. 10s at startup) because monitoring is already running and we // know an update is required. - service.RuntimeSelfUpdateTriggerAsync = zipHash => + service.RuntimeSelfUpdateTriggerAsync = (zipHash, allowDowngrade) => { if (!string.IsNullOrEmpty(zipHash)) SelfUpdater.BackendExpectedSha256 = zipHash; @@ -340,7 +349,8 @@ namespace AutopilotMonitor.Agent consoleMode: consoleMode, forceUpdate: true, triggerReason: "runtime_hash_mismatch", - downloadTimeoutMsOverride: 60000); + downloadTimeoutMsOverride: 60000, + allowDowngrade: allowDowngrade); }; service.Start(); diff --git a/src/Agent/AutopilotMonitor.Agent/SelfUpdater.cs b/src/Agent/AutopilotMonitor.Agent/SelfUpdater.cs index c75708da..de4bd1eb 100644 --- a/src/Agent/AutopilotMonitor.Agent/SelfUpdater.cs +++ b/src/Agent/AutopilotMonitor.Agent/SelfUpdater.cs @@ -104,13 +104,20 @@ namespace AutopilotMonitor.Agent /// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running /// and we know an update is required. /// + /// + /// When false (default), a latest version strictly lower than + /// is rejected even when is true — prevents the production + /// version.json from silently overwriting a higher-versioned dev/pre-release agent via + /// the runtime hash-mismatch path. Set to true only for controlled rollback scenarios. + /// public static async Task CheckAndApplyUpdateAsync( string currentVersion, string agentDir, bool consoleMode, bool forceUpdate = false, string triggerReason = "startup", - int? downloadTimeoutMsOverride = null) + int? downloadTimeoutMsOverride = null, + bool allowDowngrade = false) { Action log = msg => { @@ -141,19 +148,54 @@ namespace AutopilotMonitor.Agent return; // Could not determine latest version — continue with current } - // Step 2: Compare versions (skipped when forceUpdate=true) - if (!forceUpdate && !IsNewerVersion(currentVersion, latestVersion)) - { - log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})"); - if (isStartupTrigger) - WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log); - return; - } + // Step 2: Version comparison. Three-way decision: + // cmp < 0 → latest < current = downgrade + // cmp == 0 → latest == current = same version (hash repair only, requires forceUpdate) + // cmp > 0 → latest > current = upgrade + var cmp = CompareVersions(currentVersion, latestVersion); - if (forceUpdate) - log($"Self-update: FORCE mode (trigger={triggerReason}) — current={currentVersion}, latest={latestVersion}, skipping version check"); - else + if (cmp < 0) + { + // Downgrade — forward-only policy blocks this by default even when forceUpdate=true. + // Protects dev/pre-release builds from being silently replaced by the production + // version advertised by the prod backend (runtime_hash_mismatch force path). + if (!allowDowngrade) + { + log($"Self-update: downgrade BLOCKED — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}, allowDowngrade=false"); + WriteDowngradeBlockedMarker(currentVersion, latestVersion, triggerReason, log); + return; + } + log($"Self-update: downgrade ALLOWED (admin override) — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}"); + } + else if (cmp == 0) + { + // Same version: only proceed when forceUpdate (e.g. runtime hash mismatch = binary repair). + if (!forceUpdate) + { + log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})"); + if (isStartupTrigger) + WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log); + return; + } + log($"Self-update: same-version REPAIR (trigger={triggerReason}) — reinstalling {currentVersion}"); + } + else if (cmp > 0) + { log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}"); + } + else + { + // cmp == int.MinValue sentinel from CompareVersions → unparsable version string on + // either side. Fall back to the pre-fix behaviour: proceed only when forceUpdate. + if (!forceUpdate) + { + log($"Self-update: cannot compare versions (current={currentVersion}, latest={latestVersion}) — skipping"); + if (isStartupTrigger) + WriteSkipMarker("version_compare_failed", currentVersion, latestVersion, "version parse failed", log); + return; + } + log($"Self-update: FORCE mode (trigger={triggerReason}) — current={currentVersion}, latest={latestVersion}, proceeding despite unparsable versions"); + } // Step 3: Download ZIP (timeout = downloadTimeoutMs) var zipPath = Path.Combine(Path.GetTempPath(), "AutopilotMonitor-Agent-Update.zip"); @@ -361,12 +403,25 @@ namespace AutopilotMonitor.Agent /// private static bool IsNewerVersion(string current, string latest) { - if (!Version.TryParse(StripVersionSuffix(current), out var currentVer)) - return false; - if (!Version.TryParse(StripVersionSuffix(latest), out var latestVer)) - return false; + return CompareVersions(current, latest) > 0; + } - return latestVer > currentVer; + /// + /// Compares two version strings. Returns negative when latest is older than current, + /// zero when equal, positive when latest is newer than current. Returns + /// when either side is not a parseable + /// (callers must treat this as "cannot compare" rather than an ordered result). + /// Strips SemVer suffixes (+metadata, -prerelease) before parsing because + /// System.Version cannot handle them. + /// + internal static int CompareVersions(string current, string latest) + { + if (!Version.TryParse(StripVersionSuffix(current), out var currentVer)) + return int.MinValue; + if (!Version.TryParse(StripVersionSuffix(latest), out var latestVer)) + return int.MinValue; + + return latestVer.CompareTo(currentVer); } private static string StripVersionSuffix(string version) @@ -650,6 +705,41 @@ namespace AutopilotMonitor.Agent } } + /// + /// Writes a downgrade-blocked marker so the next agent startup can emit an + /// agent_version_check event with outcome=downgrade_blocked. Uses the + /// existing skipped-marker channel so + /// picks it up alongside other skip reasons. Best-effort — never throws. + /// + private static void WriteDowngradeBlockedMarker(string currentVersion, string latestVersion, string triggerReason, Action log) + { + try + { + var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile); + var dir = Path.GetDirectoryName(markerPath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var payload = new JObject + { + ["outcome"] = "downgrade_blocked", + ["reason"] = "downgrade_blocked", + ["currentVersion"] = currentVersion ?? "unknown", + ["latestVersion"] = latestVersion ?? "unknown", + ["triggerReason"] = triggerReason ?? "unknown", + ["skippedAtUtc"] = DateTime.UtcNow.ToString("O"), + ["errorDetail"] = string.Empty + }; + + File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None)); + log($"Self-update: downgrade-blocked marker written (current={currentVersion}, latest={latestVersion}, trigger={triggerReason})"); + } + catch (Exception ex) + { + log($"Self-update: could not write downgrade-blocked marker — {ex.Message} (non-critical)"); + } + } + /// /// Writes a small JSON marker on the happy path (current version is already up to date) so /// the next agent startup can emit an agent_version_check event with outcome=up_to_date. diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 6edead7c..e12ce0f8 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -960 +963 diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index e8046f54..b3576b1b 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -555,6 +555,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "LatestAgentVersion", config.LatestAgentVersion ?? string.Empty }, { "LatestAgentSha256", config.LatestAgentSha256 ?? string.Empty }, { "LatestAgentExeSha256", config.LatestAgentExeSha256 ?? string.Empty }, + { "AllowAgentDowngrade", config.AllowAgentDowngrade }, { "LatestBootstrapScriptVersion", config.LatestBootstrapScriptVersion ?? string.Empty }, // Rate limiting per-role settings { "UserRateLimitRequestsPerMinute", config.UserRateLimitRequestsPerMinute }, @@ -604,6 +605,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage LatestAgentVersion = entity.GetString("LatestAgentVersion") ?? string.Empty, LatestAgentSha256 = entity.GetString("LatestAgentSha256") ?? string.Empty, LatestAgentExeSha256 = entity.GetString("LatestAgentExeSha256") ?? string.Empty, + AllowAgentDowngrade = entity.GetBoolean("AllowAgentDowngrade") ?? false, LatestBootstrapScriptVersion = entity.GetString("LatestBootstrapScriptVersion") ?? string.Empty, // Rate limiting per-role settings UserRateLimitRequestsPerMinute = entity.GetInt32("UserRateLimitRequestsPerMinute") ?? 120, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 0a042246..a9522439 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -136,7 +136,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new AgentConfigResponse { - ConfigVersion = 22, // StallProbe trace indices [2,3,4] + ModernDeployment EventID 100 downgrade + ConfigVersion = 23, // AllowAgentDowngrade forward-only self-update gate UploadIntervalSeconds = 10, SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, KeepLogFile = tenantConfig.KeepLogFile ?? false, @@ -164,6 +164,7 @@ namespace AutopilotMonitor.Functions.Functions.Config }, LatestAgentSha256 = adminConfig.LatestAgentSha256, LatestAgentExeSha256 = adminConfig.LatestAgentExeSha256, + AllowAgentDowngrade = adminConfig.AllowAgentDowngrade, NtpServer = string.IsNullOrEmpty(tenantConfig.NtpServer) ? "time.windows.com" : tenantConfig.NtpServer, EnableTimezoneAutoSet = tenantConfig.EnableTimezoneAutoSet ?? false, SendTraceEvents = tenantConfig.SendTraceEvents, diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index e94377ea..49df7e07 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -250,6 +250,15 @@ namespace AutopilotMonitor.Shared.Models /// public string LatestAgentExeSha256 { get; set; } = default!; + /// + /// When true, the agent's self-updater is allowed to install a version strictly lower + /// than the one it is currently running. Default: false (forward-only updates; prevents + /// dev builds from being silently downgraded to the production version.json via + /// the runtime_hash_mismatch force path). Set to true only for controlled rollback + /// scenarios — flip back to false immediately afterwards. + /// + public bool AllowAgentDowngrade { get; set; } = false; + /// /// Version string of the latest published bootstrap script (Install-AutopilotMonitor.ps1). /// Written by CI/CD pipeline and PS build scripts after bootstrap script upload. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs index 977e5f27..b78a875c 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs @@ -176,6 +176,14 @@ namespace AutopilotMonitor.Shared.Models /// public string LatestAgentExeSha256 { get; set; } = default!; + /// + /// When true, the self-updater will install a lower version if the backend advertises one. + /// Default: false (forward-only). Gated per tenant via the admin configuration. + /// Only honoured by the runtime force-update path (hash mismatch) — startup version + /// comparison never downgrades regardless of this flag. + /// + public bool AllowAgentDowngrade { get; set; } = false; + /// /// NTP server address for time check during enrollment. /// Default: "time.windows.com" diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx index d7ff7500..2846912e 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx @@ -30,6 +30,8 @@ interface AdminConfigContextValue { setMaintenanceBlockDurationHours: (value: number) => void; opsEventRetentionDays: number; setOpsEventRetentionDays: (value: number) => void; + allowAgentDowngrade: boolean; + setAllowAgentDowngrade: (value: boolean) => void; // Diagnostics log paths globalDiagPaths: DiagnosticsLogPath[]; @@ -101,6 +103,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) const [maxSessionWindowHours, setMaxSessionWindowHours] = useState(24); const [maintenanceBlockDurationHours, setMaintenanceBlockDurationHours] = useState(12); const [opsEventRetentionDays, setOpsEventRetentionDays] = useState(90); + const [allowAgentDowngrade, setAllowAgentDowngrade] = useState(false); // Diagnostics Log Paths state const [globalDiagPaths, setGlobalDiagPaths] = useState([]); @@ -150,6 +153,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setMaxSessionWindowHours(data.maxSessionWindowHours ?? 24); setMaintenanceBlockDurationHours(data.maintenanceBlockDurationHours ?? 12); setOpsEventRetentionDays(data.opsEventRetentionDays ?? 90); + setAllowAgentDowngrade(data.allowAgentDowngrade ?? false); try { setGlobalDiagPaths(data.diagnosticsGlobalLogPathsJson ? JSON.parse(data.diagnosticsGlobalLogPathsJson) : []); } catch { @@ -240,6 +244,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, + allowAgentDowngrade, }; const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, { @@ -266,7 +271,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) } finally { setSavingConfig(false); } - }, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, getAccessToken]); + }, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, allowAgentDowngrade, getAccessToken]); // Reset admin config const handleResetAdminConfig = useCallback(() => { @@ -277,6 +282,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setMaxSessionWindowHours(adminConfig.maxSessionWindowHours ?? 24); setMaintenanceBlockDurationHours(adminConfig.maintenanceBlockDurationHours ?? 12); setOpsEventRetentionDays(adminConfig.opsEventRetentionDays ?? 90); + setAllowAgentDowngrade(adminConfig.allowAgentDowngrade ?? false); try { setGlobalDiagPaths(adminConfig.diagnosticsGlobalLogPathsJson ? JSON.parse(adminConfig.diagnosticsGlobalLogPathsJson) : []); } catch { @@ -395,6 +401,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) maxSessionWindowHours, setMaxSessionWindowHours, maintenanceBlockDurationHours, setMaintenanceBlockDurationHours, opsEventRetentionDays, setOpsEventRetentionDays, + allowAgentDowngrade, setAllowAgentDowngrade, globalDiagPaths, setGlobalDiagPaths, savingDiagPaths, opsAlertRules, setOpsAlertRules, opsAlertTelegramEnabled, setOpsAlertTelegramEnabled, diff --git a/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx index 6a169819..8b4edbf1 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx @@ -12,6 +12,8 @@ interface AdminConfigSettingsSectionProps { setPlatformStatsBlobSasUrl: (value: string) => void; collectorIdleTimeoutMinutes: number; setCollectorIdleTimeoutMinutes: (value: number) => void; + allowAgentDowngrade: boolean; + setAllowAgentDowngrade: (value: boolean) => void; onSave: () => Promise; onReset: () => void; } @@ -26,6 +28,8 @@ export function AdminConfigSettingsSection({ setPlatformStatsBlobSasUrl, collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes, + allowAgentDowngrade, + setAllowAgentDowngrade, onSave, onReset, }: AdminConfigSettingsSectionProps) { @@ -108,6 +112,23 @@ export function AdminConfigSettingsSection({ +
+ +
+ {adminConfig && (
diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionGlobalSettings.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionGlobalSettings.tsx index 5d76743e..3423d892 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionGlobalSettings.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionGlobalSettings.tsx @@ -10,6 +10,7 @@ export function SectionGlobalSettings() { globalRateLimit, setGlobalRateLimit, platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl, collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes, + allowAgentDowngrade, setAllowAgentDowngrade, handleSaveAdminConfig, handleResetAdminConfig, } = useAdminConfig(); @@ -26,6 +27,8 @@ export function SectionGlobalSettings() { setPlatformStatsBlobSasUrl={setPlatformStatsBlobSasUrl} collectorIdleTimeoutMinutes={collectorIdleTimeoutMinutes} setCollectorIdleTimeoutMinutes={setCollectorIdleTimeoutMinutes} + allowAgentDowngrade={allowAgentDowngrade} + setAllowAgentDowngrade={setAllowAgentDowngrade} onSave={handleSaveAdminConfig} onReset={handleResetAdminConfig} /> diff --git a/src/Web/autopilot-monitor-web/types/adminConfig.ts b/src/Web/autopilot-monitor-web/types/adminConfig.ts index d8fcc624..d588c4b2 100644 --- a/src/Web/autopilot-monitor-web/types/adminConfig.ts +++ b/src/Web/autopilot-monitor-web/types/adminConfig.ts @@ -21,6 +21,7 @@ export interface AdminConfiguration { opsAlertTeamsWebhookUrl?: string; opsAlertSlackEnabled?: boolean; opsAlertSlackWebhookUrl?: string; + allowAgentDowngrade?: boolean; } export interface OpsAlertRule {