From e9fcb857971e9a8b4ed05cc86223605bae2b14a0 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 9 Apr 2026 19:50:13 +0200 Subject: [PATCH] feat: added LatestVersions api and bootstrapper version handling and hints (badge, docs) --- .github/workflows/build-agent.yml | 27 ++- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../LatestVersionsServiceTests.cs | 172 ++++++++++++++++++ .../TableStorage/TableConfigRepository.cs | 2 + .../Config/GetLatestVersionsFunction.cs | 75 ++++++++ .../AutopilotMonitor.Functions/Program.cs | 1 + .../Security/EndpointAccessPolicyCatalog.cs | 1 + .../Services/LatestVersionsService.cs | 96 ++++++++++ .../Models/Config/AdminConfiguration.cs | 7 + .../components/MaintenanceTriggerSection.tsx | 66 +++++++ .../app/docs/sections/SectionAgent.tsx | 77 +++++--- .../components/DeviceDetailsCard.tsx | 27 ++- .../app/sessions/[sessionId]/page.tsx | 5 +- .../components/ScriptExecutions.tsx | 25 ++- .../lib/__tests__/bootstrapVersion.test.ts | 92 ++++++++++ src/Web/autopilot-monitor-web/lib/api.ts | 2 + .../lib/bootstrapVersion.ts | 49 +++++ .../lib/useLatestVersions.ts | 62 +++++++ 18 files changed, 752 insertions(+), 36 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/LatestVersionsServiceTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Config/GetLatestVersionsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/LatestVersionsService.cs create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/bootstrapVersion.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/bootstrapVersion.ts create mode 100644 src/Web/autopilot-monitor-web/lib/useLatestVersions.ts diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml index c3d831e0..ebabb988 100644 --- a/.github/workflows/build-agent.yml +++ b/.github/workflows/build-agent.yml @@ -155,9 +155,22 @@ jobs: $exeSha256Hash = (Get-FileHash "$stagingDir/AutopilotMonitor.Agent.exe" -Algorithm SHA256).Hash.ToLowerInvariant() Write-Host "EXE SHA-256: $exeSha256Hash" - # Enrich version.json with SHA-256 hash (MSBuild only writes version, we add the hash post-ZIP) + # Parse bootstrap script version from Install-AutopilotMonitor.ps1 + $bootstrapVersion = $null + $bootstrapContent = Get-Content "scripts/Bootstrap/Install-AutopilotMonitor.ps1" -Raw + if ($bootstrapContent -match '\$ScriptVersion\s*=\s*"([\d\.]+)"') { + $bootstrapVersion = $Matches[1] + Write-Host "Bootstrap script version: $bootstrapVersion" + } else { + Write-Warning "Could not parse bootstrap `$ScriptVersion from Install-AutopilotMonitor.ps1" + } + + # Enrich version.json with SHA-256 hash + bootstrap version (MSBuild only writes version, we add the rest post-ZIP) $versionObj = Get-Content "$agentOutput/version.json" -Raw | ConvertFrom-Json $versionObj | Add-Member -NotePropertyName "sha256" -NotePropertyValue $sha256Hash -Force + if ($bootstrapVersion) { + $versionObj | Add-Member -NotePropertyName "bootstrapVersion" -NotePropertyValue $bootstrapVersion -Force + } $versionObj | ConvertTo-Json -Compress | Set-Content "version.json" -Encoding UTF8 Write-Host "version.json content: $(Get-Content version.json)" @@ -165,6 +178,7 @@ jobs: "deploy_zip=$deployZip" >> $env:GITHUB_OUTPUT "sha256=$sha256Hash" >> $env:GITHUB_OUTPUT "exe_sha256=$exeSha256Hash" >> $env:GITHUB_OUTPUT + "bootstrap_version=$bootstrapVersion" >> $env:GITHUB_OUTPUT - name: Upload to Azure Blob Storage if: inputs.upload_blob @@ -221,18 +235,23 @@ jobs: $sha256 = "${{ steps.artifact.outputs.sha256 }}" $exeSha256 = "${{ steps.artifact.outputs.exe_sha256 }}" $agentVersion = "${{ steps.version.outputs.version }}" + $bootstrapVersion = "${{ steps.artifact.outputs.bootstrap_version }}" $tableUrl = "https://autopilotmonitor.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')?$tableSasToken" - $tableBody = @{ + $tableBodyHash = @{ LatestAgentVersion = $agentVersion LatestAgentSha256 = $sha256 LatestAgentExeSha256 = $exeSha256 - } | ConvertTo-Json + } + if ($bootstrapVersion) { + $tableBodyHash["LatestBootstrapScriptVersion"] = $bootstrapVersion + } + $tableBody = $tableBodyHash | ConvertTo-Json Invoke-RestMethod -Uri $tableUrl -Method Merge -Headers @{ "Content-Type" = "application/json" "Accept" = "application/json;odata=nometadata" "If-Match" = "*" } -Body $tableBody - Write-Host "AdminConfiguration updated: version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256" + Write-Host "AdminConfiguration updated: version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256, bootstrapVersion=$bootstrapVersion" } - name: Upload build artifact diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 17ec7611..71fb6fcc 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -901 +903 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/LatestVersionsServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/LatestVersionsServiceTests.cs new file mode 100644 index 00000000..877ac161 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/LatestVersionsServiceTests.cs @@ -0,0 +1,172 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Services; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for — the 12h-cached reader for the +/// public version.json blob. Verifies cache hit/miss, forceRefresh bypass, +/// and failure fallback without hammering the blob on every call. +/// +public class LatestVersionsServiceTests +{ + private sealed class StubHandler : HttpMessageHandler + { + public int CallCount { get; private set; } + public Func Responder { get; set; } = _ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"version\":\"1.0.706\",\"sha256\":\"abc\",\"bootstrapVersion\":\"1.1\"}") + }; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + return Task.FromResult(Responder(CallCount)); + } + } + + private sealed class StubHttpClientFactory : IHttpClientFactory + { + private readonly StubHandler _handler; + public StubHttpClientFactory(StubHandler handler) => _handler = handler; + public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false); + } + + private static (LatestVersionsService svc, StubHandler handler, IMemoryCache cache) Create() + { + var handler = new StubHandler(); + var factory = new StubHttpClientFactory(handler); + var cache = new MemoryCache(new MemoryCacheOptions()); + var svc = new LatestVersionsService(factory, cache, NullLogger.Instance); + return (svc, handler, cache); + } + + [Fact] + public async Task GetAsync_FirstCall_FetchesFromBlob() + { + var (svc, handler, _) = Create(); + + var result = await svc.GetAsync(forceRefresh: false, CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal("1.0.706", result!.AgentVersion); + Assert.Equal("1.1", result.BootstrapVersion); + Assert.Equal("abc", result.AgentSha256); + Assert.False(result.FromCache); + Assert.Equal(1, handler.CallCount); + } + + [Fact] + public async Task GetAsync_SecondCall_ReturnsCacheWithoutBlobCall() + { + var (svc, handler, _) = Create(); + + await svc.GetAsync(forceRefresh: false, CancellationToken.None); + var second = await svc.GetAsync(forceRefresh: false, CancellationToken.None); + + Assert.NotNull(second); + Assert.True(second!.FromCache); + Assert.Equal(1, handler.CallCount); // NOT incremented + } + + [Fact] + public async Task GetAsync_ForceRefresh_BypassesCache() + { + var (svc, handler, _) = Create(); + + await svc.GetAsync(forceRefresh: false, CancellationToken.None); + await svc.GetAsync(forceRefresh: true, CancellationToken.None); + + Assert.Equal(2, handler.CallCount); + } + + [Fact] + public async Task GetAsync_ForceRefresh_UpdatesCacheWithFreshValue() + { + var (svc, handler, _) = Create(); + + handler.Responder = _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"version\":\"1.0.706\",\"bootstrapVersion\":\"1.1\"}") + }; + await svc.GetAsync(forceRefresh: false, CancellationToken.None); + + // Switch to new payload and force refresh + handler.Responder = _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"version\":\"1.0.800\",\"bootstrapVersion\":\"1.2\"}") + }; + var refreshed = await svc.GetAsync(forceRefresh: true, CancellationToken.None); + + Assert.Equal("1.0.800", refreshed!.AgentVersion); + Assert.Equal("1.2", refreshed.BootstrapVersion); + + // Subsequent non-force call should read the updated cache + var cached = await svc.GetAsync(forceRefresh: false, CancellationToken.None); + Assert.Equal("1.0.800", cached!.AgentVersion); + Assert.True(cached.FromCache); + Assert.Equal(2, handler.CallCount); + } + + [Fact] + public async Task GetAsync_HttpFailure_ReturnsNullWithoutThrow() + { + var (svc, handler, _) = Create(); + handler.Responder = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError); + + var result = await svc.GetAsync(forceRefresh: false, CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task GetAsync_HttpFailure_IsShortCached_AvoidsHammering() + { + var (svc, handler, _) = Create(); + handler.Responder = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError); + + await svc.GetAsync(forceRefresh: false, CancellationToken.None); + await svc.GetAsync(forceRefresh: false, CancellationToken.None); + await svc.GetAsync(forceRefresh: false, CancellationToken.None); + + // Short-TTL null marker prevents repeat blob calls + Assert.Equal(1, handler.CallCount); + } + + [Fact] + public async Task GetAsync_MalformedJson_ReturnsNull() + { + var (svc, handler, _) = Create(); + handler.Responder = _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("not json") + }; + + var result = await svc.GetAsync(forceRefresh: false, CancellationToken.None); + + Assert.Null(result); + } + + [Fact] + public async Task GetAsync_PartialJson_MissingBootstrapVersion_StillReturnsAgentVersion() + { + var (svc, handler, _) = Create(); + handler.Responder = _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"version\":\"1.0.706\"}") + }; + + var result = await svc.GetAsync(forceRefresh: false, CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal("1.0.706", result!.AgentVersion); + Assert.Null(result.BootstrapVersion); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index d782a6b6..794f4ff2 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -531,6 +531,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "LatestAgentVersion", config.LatestAgentVersion ?? string.Empty }, { "LatestAgentSha256", config.LatestAgentSha256 ?? string.Empty }, { "LatestAgentExeSha256", config.LatestAgentExeSha256 ?? string.Empty }, + { "LatestBootstrapScriptVersion", config.LatestBootstrapScriptVersion ?? string.Empty }, // Rate limiting per-role settings { "UserRateLimitRequestsPerMinute", config.UserRateLimitRequestsPerMinute }, { "GlobalAdminRateLimitRequestsPerMinute", config.GlobalAdminRateLimitRequestsPerMinute }, @@ -579,6 +580,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage LatestAgentVersion = entity.GetString("LatestAgentVersion") ?? string.Empty, LatestAgentSha256 = entity.GetString("LatestAgentSha256") ?? string.Empty, LatestAgentExeSha256 = entity.GetString("LatestAgentExeSha256") ?? string.Empty, + LatestBootstrapScriptVersion = entity.GetString("LatestBootstrapScriptVersion") ?? string.Empty, // Rate limiting per-role settings UserRateLimitRequestsPerMinute = entity.GetInt32("UserRateLimitRequestsPerMinute") ?? 120, GlobalAdminRateLimitRequestsPerMinute = entity.GetInt32("GlobalAdminRateLimitRequestsPerMinute") ?? 600, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetLatestVersionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetLatestVersionsFunction.cs new file mode 100644 index 00000000..3c8fc089 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetLatestVersionsFunction.cs @@ -0,0 +1,75 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using System.Web; +using AutopilotMonitor.Functions.Services; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Config +{ + /// + /// Returns the latest published agent + bootstrap script versions. + /// Cached in-memory for 12h; supports ?refresh=true to bypass the cache. + /// Authenticated users only (read-only metadata). + /// + public class GetLatestVersionsFunction + { + private readonly ILogger _logger; + private readonly ILatestVersionsService _latestVersionsService; + + public GetLatestVersionsFunction( + ILogger logger, + ILatestVersionsService latestVersionsService) + { + _logger = logger; + _latestVersionsService = latestVersionsService; + } + + [Function("GetLatestVersions")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/latest-versions")] HttpRequestData req) + { + try + { + var query = HttpUtility.ParseQueryString(req.Url.Query); + var refreshRaw = query["refresh"]; + var forceRefresh = !string.IsNullOrEmpty(refreshRaw) + && (refreshRaw.Equals("true", StringComparison.OrdinalIgnoreCase) || refreshRaw == "1"); + + var versions = await _latestVersionsService.GetAsync(forceRefresh, req.FunctionContext.CancellationToken); + + var response = req.CreateResponse(HttpStatusCode.OK); + + // Force-refresh responses MUST NOT be browser-cached. + // Normal cached responses may be stored for up to 1h (aligns with 12h backend cache). + if (forceRefresh) + { + response.Headers.Add("Cache-Control", "no-store"); + } + else + { + response.Headers.Add("Cache-Control", "public, max-age=3600"); + } + + await response.WriteAsJsonAsync(new + { + latestAgentVersion = versions?.AgentVersion, + latestBootstrapScriptVersion = versions?.BootstrapVersion, + latestAgentSha256 = versions?.AgentSha256, + fetchedAtUtc = versions?.FetchedAtUtc, + source = versions?.FromCache == true ? "cache" : "blob" + }); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting latest versions"); + var response = req.CreateResponse(HttpStatusCode.InternalServerError); + await response.WriteAsJsonAsync(new { error = "Internal server error" }); + return response; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index b5638762..2e765806 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -113,6 +113,7 @@ builder.Services.AddHostedService(); // Initialize all builder.Services.AddTableStorageDataAccess(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 9181eac8..138c89b7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -124,6 +124,7 @@ public static class EndpointAccessPolicyCatalog new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUser), new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser), new("POST", "feedback", EndpointPolicy.AuthenticatedUser), + new("GET", "config/latest-versions", EndpointPolicy.AuthenticatedUser), // ── MemberRead (Admin + Operator, later + Viewer) ─────────────── new("GET", "raw/sessions", EndpointPolicy.MemberRead), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/LatestVersionsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/LatestVersionsService.cs new file mode 100644 index 00000000..9e00d489 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/LatestVersionsService.cs @@ -0,0 +1,96 @@ +using System; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Exposes the latest published agent/bootstrap versions as read from the + /// public version.json blob. Caches the result in memory for 12h + /// (short TTL on transient failures). Admin endpoint can force-refresh. + /// + public interface ILatestVersionsService + { + Task GetAsync(bool forceRefresh, CancellationToken ct); + } + + public sealed record LatestVersions( + string? AgentVersion, + string? BootstrapVersion, + string? AgentSha256, + DateTimeOffset FetchedAtUtc, + bool FromCache); + + public sealed class LatestVersionsService : ILatestVersionsService + { + public const string VersionJsonUrl = "https://autopilotmonitor.blob.core.windows.net/agent/version.json"; + private const string CacheKey = "latest-versions"; + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(12); + private static readonly TimeSpan FailureCacheDuration = TimeSpan.FromMinutes(5); + + private readonly IHttpClientFactory _httpClientFactory; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public LatestVersionsService( + IHttpClientFactory httpClientFactory, + IMemoryCache cache, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _cache = cache; + _logger = logger; + } + + public async Task GetAsync(bool forceRefresh, CancellationToken ct) + { + if (!forceRefresh && _cache.TryGetValue(CacheKey, out LatestVersions? cached)) + { + if (cached != null) + { + return cached with { FromCache = true }; + } + // Cached "null" (transient failure marker) — return null without another blob call + return null; + } + + try + { + using var client = _httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(10); + using var response = await client.GetAsync(VersionJsonUrl, ct); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + string? version = root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + string? bootstrapVersion = root.TryGetProperty("bootstrapVersion", out var bv) && bv.ValueKind == JsonValueKind.String ? bv.GetString() : null; + string? sha256 = root.TryGetProperty("sha256", out var sh) && sh.ValueKind == JsonValueKind.String ? sh.GetString() : null; + + var result = new LatestVersions( + AgentVersion: version, + BootstrapVersion: bootstrapVersion, + AgentSha256: sha256, + FetchedAtUtc: DateTimeOffset.UtcNow, + FromCache: false); + + _cache.Set(CacheKey, result, CacheDuration); + _logger.LogInformation("LatestVersions refreshed: agent={AgentVersion}, bootstrap={BootstrapVersion}", version, bootstrapVersion); + return result; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch {Url}; caching null for {Minutes}m", VersionJsonUrl, FailureCacheDuration.TotalMinutes); + // Cache failure briefly to avoid hammering blob on repeated calls + _cache.Set(CacheKey, (LatestVersions?)null, FailureCacheDuration); + return null; + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index 498c2a81..e94377ea 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -250,6 +250,13 @@ namespace AutopilotMonitor.Shared.Models /// public string LatestAgentExeSha256 { get; set; } = default!; + /// + /// Version string of the latest published bootstrap script (Install-AutopilotMonitor.ps1). + /// Written by CI/CD pipeline and PS build scripts after bootstrap script upload. + /// Used by the web UI to flag outdated bootstrap scripts in session views. + /// + public string LatestBootstrapScriptVersion { get; set; } = default!; + /// /// Whether vulnerability correlation is globally enabled. /// When false, agents still collect inventory but backend skips correlation. diff --git a/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx index 264dfee9..72215fe2 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx @@ -17,6 +17,40 @@ export function MaintenanceTriggerSection({ }: MaintenanceTriggerSectionProps) { const [triggeringMaintenance, setTriggeringMaintenance] = useState(false); const [maintenanceDate, setMaintenanceDate] = useState(""); + const [refreshingVersions, setRefreshingVersions] = useState(false); + + const handleRefreshLatestVersions = async () => { + try { + setRefreshingVersions(true); + setError(null); + setSuccessMessage(null); + + const response = await authenticatedFetch(api.config.latestVersions({ refresh: true }), getAccessToken, { + method: "GET", + }); + + if (!response.ok) { + throw new Error(`Failed to refresh latest versions: ${response.statusText}`); + } + + const data = await response.json(); + const agentVer = data.latestAgentVersion ?? "unknown"; + const bootstrapVer = data.latestBootstrapScriptVersion ?? "unknown"; + const fetchedAt = data.fetchedAtUtc ? new Date(data.fetchedAtUtc).toISOString().replace("T", " ").substring(0, 19) + " UTC" : "now"; + setSuccessMessage(`Version cache refreshed: agent=${agentVer}, bootstrap=${bootstrapVer} (fetched ${fetchedAt})`); + + setTimeout(() => setSuccessMessage(null), 8000); + } catch (err) { + if (err instanceof TokenExpiredError) { + console.error("Session expired while refreshing versions"); + } else { + console.error("Error refreshing latest versions:", err); + } + setError(err instanceof Error ? err.message : "Failed to refresh latest versions"); + } finally { + setRefreshingVersions(false); + } + }; const handleTriggerMaintenance = async () => { try { @@ -128,6 +162,38 @@ export function MaintenanceTriggerSection({

+
+
+
+

+ Refresh Latest Agent / Bootstrap Version Cache +

+

+ Bypasses the 12h in-memory cache and re-fetches version.json from blob storage. + Use this immediately after publishing a new agent or bootstrap script build to see the new version in session badges. +

+
+ +
+
-
-
- - - - Latest Agent Version -
- {loading ? ( -
- - - +
+
+
+ + - Loading version info... + Latest Agent Version
- ) : versionInfo?.version ? ( -
- - v{versionInfo.version} - - - View Changelog - + {loading ? ( +
+ + + + + Loading version info... +
+ ) : versionInfo?.version ? ( +
+ + v{versionInfo.version} + + + View Changelog + +
+ ) : ( +

Version information is currently unavailable.

+ )} +
+ +
+
+ + + + Latest Bootstrapper Script Version
- ) : ( -

Version information is currently unavailable.

- )} + {loading ? ( +
+ + + + + Loading version info... +
+ ) : versionInfo?.bootstrapVersion ? ( +
+ + v{versionInfo.bootstrapVersion} + +
+ ) : ( +

Version information is currently unavailable.

+ )} +
diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx index 9e242c9e..d5a973a8 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx @@ -3,8 +3,9 @@ import { useState, useMemo } from "react"; import { EnrollmentEvent } from "@/types"; import OobeConfigModal from "./OobeConfigModal"; +import { compareVersions, stripGitHashSuffix } from "@/lib/bootstrapVersion"; -export default function DeviceDetailsCard({ events }: { events: EnrollmentEvent[] }) { +export default function DeviceDetailsCard({ events, latestAgentVersion }: { events: EnrollmentEvent[]; latestAgentVersion?: string | null }) { const [expanded, setExpanded] = useState(false); const [showIpv6, setShowIpv6] = useState>({}); const [showOobeModal, setShowOobeModal] = useState(false); @@ -333,9 +334,27 @@ export default function DeviceDetailsCard({ events }: { events: EnrollmentEvent[ )} {uptimeUntilEnrollment && } - {agentStarted?.agentVersion && typeof agentStarted.agentVersion === 'string' && ( - - )} + {agentStarted?.agentVersion && typeof agentStarted.agentVersion === 'string' && (() => { + const displayVersion = agentStarted.agentVersion.replace(/\+([0-9a-f]{7})[0-9a-f]+$/, '+$1'); + const installedCleaned = stripGitHashSuffix(agentStarted.agentVersion); + const isOutdated = !!latestAgentVersion && compareVersions(installedCleaned, latestAgentVersion) < 0; + return ( +
+ Monitor Agent Version + + {displayVersion} + {isOutdated && ( + + outdated + + )} + +
+ ); + })()} {imeVersion && } {(deviceLocation?.country || deviceLocation?.Country) && ( diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index 438574b3..577ef85e 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -13,6 +13,7 @@ import PerformanceChart from '../../../components/PerformanceChart'; import DownloadProgress from '../../../components/DownloadProgress'; import InstallProgress from '../../../components/InstallProgress'; import ScriptExecutions from '../../../components/ScriptExecutions'; +import { useLatestVersions } from '@/lib/useLatestVersions'; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; @@ -80,6 +81,7 @@ export default function SessionDetailPage() { const { tenantId } = useTenant(); const { getAccessToken, user } = useAuth(); const { addNotification } = useNotifications(); + const { latestAgentVersion, latestBootstrapVersion } = useLatestVersions(getAccessToken); const { analysisResults, @@ -962,7 +964,7 @@ export default function SessionDetailPage() { )} {/* Device Details Card (from enrollment tracker events) */} - {!isGatherRulesSession &&
} + {!isGatherRulesSession &&
} {/* Phase Timeline */} {!isGatherRulesSession && session && ( @@ -1036,6 +1038,7 @@ export default function SessionDetailPage() { e => e.eventType === "script_completed" || e.eventType === "script_failed" )} showScriptOutput={showScriptOutput} + latestBootstrapVersion={latestBootstrapVersion} />
)} diff --git a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx index 9374875d..eabeadba 100644 --- a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx +++ b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import { compareVersions, extractBootstrapVersion } from "@/lib/bootstrapVersion"; interface ScriptEvent { timestamp: string; @@ -11,6 +12,7 @@ interface ScriptEvent { interface ScriptExecutionsProps { events: ScriptEvent[]; showScriptOutput?: boolean; + latestBootstrapVersion?: string | null; } interface ScriptItem { @@ -26,9 +28,10 @@ interface ScriptItem { state: "Success" | "Failed"; timestamp: string; firstSeenIndex: number; + bootstrapVersion?: string | null; // Parsed from stdout if this is the Autopilot-Monitor bootstrap script } -export default function ScriptExecutions({ events, showScriptOutput }: ScriptExecutionsProps) { +export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion }: ScriptExecutionsProps) { const scripts = useMemo(() => { if (events.length === 0) return []; @@ -68,6 +71,7 @@ export default function ScriptExecutions({ events, showScriptOutput }: ScriptExe state: isSuccess ? "Success" : "Failed", timestamp: evt.timestamp, firstSeenIndex: items.length, + bootstrapVersion: scriptType === "platform" ? extractBootstrapVersion(d.stdout) : null, }); } @@ -129,6 +133,7 @@ export default function ScriptExecutions({ events, showScriptOutput }: ScriptExe key={`${item.policyId}-${item.scriptPart ?? ""}-${item.firstSeenIndex}`} item={item} showScriptOutput={showScriptOutput} + latestBootstrapVersion={latestBootstrapVersion} /> ))}
@@ -144,7 +149,7 @@ function getIntuneScriptUrl(policyId: string, scriptType: string): string | null return `https://intune.microsoft.com/#view/Microsoft_Intune_DeviceSettings/ConfigureWMPolicyMenuBlade/~/overview/policyId/${policyId}/policyType/0`; } -function ScriptItemRow({ item, showScriptOutput }: { item: ScriptItem; showScriptOutput?: boolean }) { +function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { item: ScriptItem; showScriptOutput?: boolean; latestBootstrapVersion?: string | null }) { const [showDetails, setShowDetails] = useState(false); const containerClass = item.state === "Failed" @@ -200,6 +205,22 @@ function ScriptItemRow({ item, showScriptOutput }: { item: ScriptItem; showScrip }`}> {item.scriptType} + {item.bootstrapVersion && ( + + bootstrap v{item.bootstrapVersion} + + )} + {item.bootstrapVersion && latestBootstrapVersion && compareVersions(item.bootstrapVersion, latestBootstrapVersion) < 0 && ( + + outdated + + )} {item.runContext && ( {item.runContext} )} diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/bootstrapVersion.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/bootstrapVersion.test.ts new file mode 100644 index 00000000..5f520a35 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/bootstrapVersion.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { + BOOTSTRAP_VERSION_RE, + compareVersions, + extractBootstrapVersion, + stripGitHashSuffix, +} from "../bootstrapVersion"; + +describe("extractBootstrapVersion", () => { + it("extracts v1.1 from a single-line stdout", () => { + expect(extractBootstrapVersion("Bootstrap script version: v1.1")).toBe("1.1"); + }); + + it("extracts v1.0.706 (3-segment)", () => { + expect(extractBootstrapVersion("Bootstrap script version: v1.0.706")).toBe("1.0.706"); + }); + + it("extracts v1.2.3.4 (4-segment)", () => { + expect(extractBootstrapVersion("Bootstrap script version: v1.2.3.4")).toBe("1.2.3.4"); + }); + + it("extracts from multi-line stdout", () => { + const stdout = [ + "[2026-04-09 12:00:00] Starting bootstrap", + "[2026-04-09 12:00:01] Bootstrap script version: v1.1", + "[2026-04-09 12:00:02] Downloading agent...", + ].join("\n"); + expect(extractBootstrapVersion(stdout)).toBe("1.1"); + }); + + it("tolerates multiple spaces after colon", () => { + expect(extractBootstrapVersion("Bootstrap script version: v2.0")).toBe("2.0"); + }); + + it("returns null when marker is absent", () => { + expect(extractBootstrapVersion("Hello World")).toBeNull(); + }); + + it("returns null on empty / null / undefined input", () => { + expect(extractBootstrapVersion("")).toBeNull(); + expect(extractBootstrapVersion(null)).toBeNull(); + expect(extractBootstrapVersion(undefined)).toBeNull(); + }); + + it("regex only matches our specific marker line, not arbitrary 'version: v' tokens", () => { + expect(BOOTSTRAP_VERSION_RE.test("some version: v1.0")).toBe(false); + expect(BOOTSTRAP_VERSION_RE.test("Script version: v1.0")).toBe(false); + }); + + it("does not match a v with no digits", () => { + expect(extractBootstrapVersion("Bootstrap script version: vX.Y")).toBeNull(); + }); +}); + +describe("compareVersions", () => { + it("equal versions return 0", () => { + expect(compareVersions("1.1", "1.1")).toBe(0); + expect(compareVersions("1.0.706", "1.0.706")).toBe(0); + }); + + it("pads missing segments with zero so 1.1 === 1.1.0", () => { + expect(compareVersions("1.1", "1.1.0")).toBe(0); + expect(compareVersions("1.1.0.0", "1.1")).toBe(0); + }); + + it("returns negative when a < b", () => { + expect(compareVersions("1.0", "1.1")).toBeLessThan(0); + expect(compareVersions("1.0.706", "1.0.707")).toBeLessThan(0); + expect(compareVersions("1.1", "2.0")).toBeLessThan(0); + }); + + it("returns positive when a > b", () => { + expect(compareVersions("1.1", "1.0")).toBeGreaterThan(0); + expect(compareVersions("2.0", "1.99.99")).toBeGreaterThan(0); + }); + + it("treats non-numeric segments as 0", () => { + expect(compareVersions("1.foo", "1.0")).toBe(0); + }); +}); + +describe("stripGitHashSuffix", () => { + it("removes +hash suffix", () => { + expect(stripGitHashSuffix("1.0.706+abc1234")).toBe("1.0.706"); + expect(stripGitHashSuffix("1.0.706+abc1234def5678")).toBe("1.0.706"); + }); + + it("leaves clean versions untouched", () => { + expect(stripGitHashSuffix("1.0.706")).toBe("1.0.706"); + expect(stripGitHashSuffix("1.1")).toBe("1.1"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index b47db2ff..e51bce9c 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -62,6 +62,8 @@ export const api = { `${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-failure`, testNotification: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/test-notification`, + latestVersions: (opts?: { refresh?: boolean }) => + `${API_BASE_URL}/api/config/latest-versions${qs({ refresh: opts?.refresh ? "true" : undefined })}`, }, // ── Global Config (global admin) ────────────────────────────────────────── diff --git a/src/Web/autopilot-monitor-web/lib/bootstrapVersion.ts b/src/Web/autopilot-monitor-web/lib/bootstrapVersion.ts new file mode 100644 index 00000000..ea88f649 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/bootstrapVersion.ts @@ -0,0 +1,49 @@ +/** + * Helpers for detecting the Autopilot-Monitor bootstrap script version + * from captured Intune Platform Script stdout, and for comparing + * simple dotted version strings. + * + * The bootstrap script writes a deterministic marker line: + * "Bootstrap script version: v1.1" + * (see scripts/Bootstrap/Install-AutopilotMonitor.ps1) + */ + +export const BOOTSTRAP_VERSION_RE = /Bootstrap script version:\s*v(\d+(?:\.\d+){1,3})/; + +/** + * Extracts the bootstrap script version from a Platform Script stdout blob. + * Returns null if the marker line is absent. + */ +export function extractBootstrapVersion(stdout?: string | null): string | null { + if (!stdout) return null; + const m = stdout.match(BOOTSTRAP_VERSION_RE); + return m ? m[1] : null; +} + +/** + * Compares two dotted version strings (e.g. "1.0.706" vs "1.1"). + * Segments are parsed as integers and missing segments are treated as 0, + * so "1.1" and "1.1.0" compare equal. Non-numeric segments are treated as 0 + * (ignored) — this is intentional, we only care about "newer vs older" for badge purposes. + * + * @returns negative if a < b, 0 if equal, positive if a > b. + */ +export function compareVersions(a: string, b: string): number { + const pa = a.split(".").map(s => parseInt(s, 10) || 0); + const pb = b.split(".").map(s => parseInt(s, 10) || 0); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const av = pa[i] ?? 0; + const bv = pb[i] ?? 0; + if (av !== bv) return av - bv; + } + return 0; +} + +/** + * Strips the +{gitHash} suffix from an agent version string (e.g. "1.0.706+abc1234" → "1.0.706"). + * Leaves clean versions untouched. + */ +export function stripGitHashSuffix(version: string): string { + return version.replace(/\+.*$/, ""); +} diff --git a/src/Web/autopilot-monitor-web/lib/useLatestVersions.ts b/src/Web/autopilot-monitor-web/lib/useLatestVersions.ts new file mode 100644 index 00000000..d47d8bd0 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/useLatestVersions.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "./api"; +import { authenticatedFetch } from "./authenticatedFetch"; + +type GetAccessToken = (forceRefresh?: boolean) => Promise; + +export interface LatestVersionsResponse { + latestAgentVersion?: string | null; + latestBootstrapScriptVersion?: string | null; + latestAgentSha256?: string | null; + fetchedAtUtc?: string | null; + source?: "cache" | "blob" | null; +} + +export interface UseLatestVersionsResult { + latestAgentVersion: string | null; + latestBootstrapVersion: string | null; + loading: boolean; +} + +/** + * Fetches latest published agent/bootstrap versions from the backend + * once per mount. The backend caches the upstream blob for 12h, and + * the browser Cache-Control allows 1h of HTTP caching, so this is cheap. + * + * Silently swallows all errors — on failure, returns nulls so callers + * can gracefully hide "outdated" badges. + */ +export function useLatestVersions(getAccessToken: GetAccessToken): UseLatestVersionsResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const res = await authenticatedFetch(api.config.latestVersions(), getAccessToken, { method: "GET" }); + if (!res.ok) return; + const json = (await res.json()) as LatestVersionsResponse; + if (!cancelled) setData(json); + } catch { + // swallow — badges just won't render + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { cancelled = true; }; + // getAccessToken identity typically stable from MSAL context; intentionally omit from deps + // to avoid refetching on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + latestAgentVersion: data?.latestAgentVersion ?? null, + latestBootstrapVersion: data?.latestBootstrapScriptVersion ?? null, + loading, + }; +}