mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat: added LatestVersions api and bootstrapper version handling and hints (badge, docs)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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">
|
||||
|
||||
+23
-4
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user