feat: added LatestVersions api and bootstrapper version handling and hints (badge, docs)

This commit is contained in:
Oliver Kieselbach
2026-04-09 19:50:13 +02:00
parent 3f7fcbc684
commit e9fcb85797
18 changed files with 752 additions and 36 deletions
+23 -4
View File
@@ -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
@@ -1 +1 @@
901
903
@@ -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;
/// <summary>
/// Tests for <see cref="LatestVersionsService"/> — the 12h-cached reader for the
/// public <c>version.json</c> blob. Verifies cache hit/miss, forceRefresh bypass,
/// and failure fallback without hammering the blob on every call.
/// </summary>
public class LatestVersionsServiceTests
{
private sealed class StubHandler : HttpMessageHandler
{
public int CallCount { get; private set; }
public Func<int, HttpResponseMessage> Responder { get; set; } = _ =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"version\":\"1.0.706\",\"sha256\":\"abc\",\"bootstrapVersion\":\"1.1\"}")
};
protected override Task<HttpResponseMessage> 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<LatestVersionsService>.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);
}
}
@@ -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,
@@ -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
{
/// <summary>
/// Returns the latest published agent + bootstrap script versions.
/// Cached in-memory for 12h; supports <c>?refresh=true</c> to bypass the cache.
/// Authenticated users only (read-only metadata).
/// </summary>
public class GetLatestVersionsFunction
{
private readonly ILogger<GetLatestVersionsFunction> _logger;
private readonly ILatestVersionsService _latestVersionsService;
public GetLatestVersionsFunction(
ILogger<GetLatestVersionsFunction> logger,
ILatestVersionsService latestVersionsService)
{
_logger = logger;
_latestVersionsService = latestVersionsService;
}
[Function("GetLatestVersions")]
public async Task<HttpResponseData> 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;
}
}
}
}
@@ -113,6 +113,7 @@ builder.Services.AddHostedService<TableInitializerService>(); // Initialize all
builder.Services.AddTableStorageDataAccess();
builder.Services.AddSingleton<TenantConfigurationService>();
builder.Services.AddSingleton<AdminConfigurationService>();
builder.Services.AddSingleton<ILatestVersionsService, LatestVersionsService>();
builder.Services.AddSingleton<RateLimitService>();
builder.Services.AddSingleton<DistressRateLimitService>();
builder.Services.AddSingleton<UsageMetricsService>();
@@ -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),
@@ -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
{
/// <summary>
/// Exposes the latest published agent/bootstrap versions as read from the
/// public <c>version.json</c> blob. Caches the result in memory for 12h
/// (short TTL on transient failures). Admin endpoint can force-refresh.
/// </summary>
public interface ILatestVersionsService
{
Task<LatestVersions?> 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<LatestVersionsService> _logger;
public LatestVersionsService(
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
ILogger<LatestVersionsService> logger)
{
_httpClientFactory = httpClientFactory;
_cache = cache;
_logger = logger;
}
public async Task<LatestVersions?> 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;
}
}
}
}
@@ -250,6 +250,13 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string LatestAgentExeSha256 { get; set; } = default!;
/// <summary>
/// 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.
/// </summary>
public string LatestBootstrapScriptVersion { get; set; } = default!;
/// <summary>
/// Whether vulnerability correlation is globally enabled.
/// When false, agents still collect inventory but backend skips correlation.
@@ -17,6 +17,40 @@ export function MaintenanceTriggerSection({
}: MaintenanceTriggerSectionProps) {
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
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({
</p>
</div>
</div>
<div className="bg-white dark:bg-gray-700 border border-purple-300 dark:border-purple-600 rounded-lg p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<p className="text-sm font-semibold text-purple-900 dark:text-purple-100 mb-1">
Refresh Latest Agent / Bootstrap Version Cache
</p>
<p className="text-xs text-gray-600 dark:text-gray-400">
Bypasses the 12h in-memory cache and re-fetches <code className="text-xs bg-purple-50 dark:bg-gray-800 px-1 py-0.5 rounded">version.json</code> from blob storage.
Use this immediately after publishing a new agent or bootstrap script build to see the new version in session badges.
</p>
</div>
<button
onClick={handleRefreshLatestVersions}
disabled={refreshingVersions}
className="flex-shrink-0 px-4 py-2 bg-white dark:bg-gray-600 border border-purple-400 dark:border-purple-500 text-purple-700 dark:text-purple-200 rounded-lg hover:bg-purple-50 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all text-sm font-medium flex items-center space-x-2"
>
{refreshingVersions ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-600"></div>
<span>Reloading...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Reload Now</span>
</>
)}
</button>
</div>
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleTriggerMaintenance}
@@ -6,6 +6,7 @@ import Link from "next/link";
interface AgentVersionInfo {
version?: string;
url?: string;
bootstrapVersion?: string;
}
const AGENT_VERSION_URL =
@@ -102,33 +103,61 @@ export function SectionAgent() {
</p>
</div>
<div className="mb-6 p-5 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center gap-3 mb-2">
<svg className="w-5 h-5 text-blue-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span className="font-semibold text-blue-900">Latest Agent Version</span>
</div>
{loading ? (
<div className="flex items-center gap-2 text-sm text-blue-700">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-5 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center gap-3 mb-2">
<svg className="w-5 h-5 text-blue-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
Loading version info...
<span className="font-semibold text-blue-900">Latest Agent Version</span>
</div>
) : versionInfo?.version ? (
<div className="flex items-center gap-3">
<span className="inline-flex items-center px-3 py-1.5 bg-blue-100 text-blue-800 text-lg font-mono font-bold rounded-md">
v{versionInfo.version}
</span>
<Link href="/docs/agent-changelog" className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
View Changelog
</Link>
{loading ? (
<div className="flex items-center gap-2 text-sm text-blue-700">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading version info...
</div>
) : versionInfo?.version ? (
<div className="flex items-center gap-3">
<span className="inline-flex items-center px-3 py-1.5 bg-blue-100 text-blue-800 text-lg font-mono font-bold rounded-md">
v{versionInfo.version}
</span>
<Link href="/docs/agent-changelog" className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
View Changelog
</Link>
</div>
) : (
<p className="text-sm text-blue-700">Version information is currently unavailable.</p>
)}
</div>
<div className="p-5 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center gap-3 mb-2">
<svg className="w-5 h-5 text-blue-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span className="font-semibold text-blue-900">Latest Bootstrapper Script Version</span>
</div>
) : (
<p className="text-sm text-blue-700">Version information is currently unavailable.</p>
)}
{loading ? (
<div className="flex items-center gap-2 text-sm text-blue-700">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading version info...
</div>
) : versionInfo?.bootstrapVersion ? (
<div className="flex items-center gap-3">
<span className="inline-flex items-center px-3 py-1.5 bg-blue-100 text-blue-800 text-lg font-mono font-bold rounded-md">
v{versionInfo.bootstrapVersion}
</span>
</div>
) : (
<p className="text-sm text-blue-700">Version information is currently unavailable.</p>
)}
</div>
</div>
<div className="mb-6">
@@ -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<Record<number, boolean>>({});
const [showOobeModal, setShowOobeModal] = useState(false);
@@ -333,9 +334,27 @@ export default function DeviceDetailsCard({ events }: { events: EnrollmentEvent[
<DetailRow label="Boot Time" value={estimatedBootTime.toLocaleString([], { dateStyle: "short", timeStyle: "medium" })} />
)}
{uptimeUntilEnrollment && <DetailRow label="Uptime until enrollment starts" value={uptimeUntilEnrollment} />}
{agentStarted?.agentVersion && typeof agentStarted.agentVersion === 'string' && (
<DetailRow label="Monitor Agent Version" value={agentStarted.agentVersion.replace(/\+([0-9a-f]{7})[0-9a-f]+$/, '+$1')} />
)}
{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 (
<div className="flex justify-between text-xs py-0.5">
<span className="text-gray-500">Monitor Agent Version</span>
<span className="text-gray-900 font-mono ml-2 text-right break-all flex items-center justify-end gap-1" title={displayVersion}>
<span>{displayVersion}</span>
{isOutdated && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-800 border border-amber-200"
title={`latest: v${latestAgentVersion}`}
>
outdated
</span>
)}
</span>
</div>
);
})()}
{imeVersion && <DetailRow label="IME Agent Version" value={imeVersion.version ?? imeVersion.agentVersion ?? "Unknown"} />}
{(deviceLocation?.country || deviceLocation?.Country) && (
<DetailRow label="Country" value={deviceLocation.country ?? deviceLocation.Country} />
@@ -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 && <div id="section-device-details"><DeviceDetailsCard events={events} /></div>}
{!isGatherRulesSession && <div id="section-device-details"><DeviceDetailsCard events={events} latestAgentVersion={latestAgentVersion} /></div>}
{/* 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}
/>
</div>
)}
@@ -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}
/>
))}
</div>
@@ -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}
</span>
{item.bootstrapVersion && (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium bg-indigo-100 text-indigo-700"
title="Detected Autopilot-Monitor bootstrap script"
>
bootstrap v{item.bootstrapVersion}
</span>
)}
{item.bootstrapVersion && latestBootstrapVersion && compareVersions(item.bootstrapVersion, latestBootstrapVersion) < 0 && (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium bg-amber-100 text-amber-800 border border-amber-200"
title={`latest: v${latestBootstrapVersion}`}
>
outdated
</span>
)}
{item.runContext && (
<span className="text-xs text-gray-500">{item.runContext}</span>
)}
@@ -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");
});
});
+2
View File
@@ -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) ──────────────────────────────────────────
@@ -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(/\+.*$/, "");
}
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "./api";
import { authenticatedFetch } from "./authenticatedFetch";
type GetAccessToken = (forceRefresh?: boolean) => Promise<string | null>;
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<LatestVersionsResponse | null>(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,
};
}