added raw event timeline and block management for agent versions

This commit is contained in:
Oliver Kieselbach
2026-03-07 08:33:05 +01:00
parent 2cfee523b1
commit 1baff84df0
14 changed files with 924 additions and 8 deletions
@@ -93,7 +93,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
}
_spool = new EventSpool(_configuration.SpoolDirectory);
_apiClient = new BackendApiClient(_configuration.ApiBaseUrl, _configuration, _logger);
_apiClient = new BackendApiClient(_configuration.ApiBaseUrl, _configuration, _logger, _agentVersion);
_emergencyReporter = new EmergencyReporter(_apiClient, _configuration.SessionId, _configuration.TenantId, agentVersion, _logger);
_cleanupService = new CleanupService(_configuration, _logger);
_diagnosticsService = new DiagnosticsPackageService(_configuration, _logger, _apiClient);
@@ -31,18 +31,20 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
private readonly NetworkMetrics _networkMetrics = new NetworkMetrics();
private readonly string _bootstrapToken;
private readonly bool _useBootstrapTokenAuth;
private readonly string _agentVersion;
/// <summary>
/// Exposes the network metrics counters for AgentSelfMetricsCollector to read.
/// </summary>
public NetworkMetrics NetworkMetrics => _networkMetrics;
public BackendApiClient(string baseUrl, AgentConfiguration configuration = null, Logging.AgentLogger logger = null)
public BackendApiClient(string baseUrl, AgentConfiguration configuration = null, Logging.AgentLogger logger = null, string agentVersion = null)
{
_baseUrl = baseUrl.TrimEnd('/');
_logger = logger;
_bootstrapToken = configuration?.BootstrapToken;
_useBootstrapTokenAuth = configuration?.UseBootstrapTokenAuth ?? false;
_agentVersion = agentVersion;
// Find MDM certificate for client authentication if enabled
if (configuration?.UseClientCertAuth == true)
@@ -395,6 +397,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
request.Headers.Add("X-Device-SerialNumber", _serialNumber);
_logger?.Verbose($" X-Device-SerialNumber: {_serialNumber}");
}
// Add agent version for version-based block/kill management
if (!string.IsNullOrEmpty(_agentVersion))
{
request.Headers.Add("X-Agent-Version", _agentVersion);
_logger?.Verbose($" X-Agent-Version: {_agentVersion}");
}
}
public void Dispose()
@@ -51,7 +51,7 @@ namespace AutopilotMonitor.Agent
Console.WriteLine();
}
using (var apiClient = new BackendApiClient(config.ApiBaseUrl, config, logger))
using (var apiClient = new BackendApiClient(config.ApiBaseUrl, config, logger, agentVersion))
using (var remoteConfigService = new RemoteConfigService(apiClient, config.TenantId, logger))
{
// Step 1: Fetch remote config (gather rules + settings)
@@ -1 +1 @@
294
299
@@ -0,0 +1,188 @@
using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Functions.Admin
{
public class VersionBlockFunction
{
private readonly ILogger<VersionBlockFunction> _logger;
private readonly BlockedVersionService _blockedVersionService;
private readonly GalacticAdminService _galacticAdminService;
public VersionBlockFunction(
ILogger<VersionBlockFunction> logger,
BlockedVersionService blockedVersionService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_blockedVersionService = blockedVersionService;
_galacticAdminService = galacticAdminService;
}
/// <summary>GET /api/versions/blocked — list all active version block rules</summary>
[Function("GetBlockedVersions")]
public async Task<HttpResponseData> GetBlockedVersions(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "versions/blocked")] HttpRequestData req)
{
try
{
if (!TenantHelper.IsAuthenticated(req))
return await UnauthorizedAsync(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
if (!await _galacticAdminService.IsGalacticAdminAsync(userIdentifier))
return await ForbiddenAsync(req);
var rules = await _blockedVersionService.GetBlockedVersionsAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, rules });
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting blocked versions");
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { error = "Internal server error" });
return response;
}
}
/// <summary>POST /api/versions/block — add a version block rule</summary>
[Function("BlockVersion")]
public async Task<HttpResponseData> BlockVersion(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "versions/block")] HttpRequestData req)
{
try
{
if (!TenantHelper.IsAuthenticated(req))
return await UnauthorizedAsync(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
if (!await _galacticAdminService.IsGalacticAdminAsync(userIdentifier))
return await ForbiddenAsync(req);
string body;
using (var reader = new System.IO.StreamReader(req.Body))
body = await reader.ReadToEndAsync();
JObject json;
try { json = JObject.Parse(body); }
catch { return await BadRequestAsync(req, "Invalid JSON body"); }
var versionPattern = json["versionPattern"]?.ToString();
var action = json["action"]?.ToString() ?? "Block";
var reason = json["reason"]?.ToString();
if (string.IsNullOrEmpty(versionPattern))
return await BadRequestAsync(req, "versionPattern is required");
if (!string.Equals(action, "Block", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(action, "Kill", StringComparison.OrdinalIgnoreCase))
return await BadRequestAsync(req, "action must be 'Block' or 'Kill'");
try
{
await _blockedVersionService.BlockVersionAsync(versionPattern, action, userIdentifier, reason);
}
catch (ArgumentException ex)
{
return await BadRequestAsync(req, ex.Message);
}
var normalizedAction = string.Equals(action, "Kill", StringComparison.OrdinalIgnoreCase) ? "Kill" : "Block";
_logger.LogWarning(
"Galactic Admin {User} added version {Action} rule: Pattern={Pattern}, Reason={Reason}",
userIdentifier, normalizedAction, versionPattern, reason);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
message = $"Version pattern '{versionPattern}' set to {normalizedAction}.",
versionPattern,
action = normalizedAction
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding version block rule");
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { error = "Internal server error" });
return response;
}
}
/// <summary>DELETE /api/versions/block/{encodedPattern} — remove a version block rule</summary>
[Function("UnblockVersion")]
public async Task<HttpResponseData> UnblockVersion(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "versions/block/{encodedPattern}")] HttpRequestData req,
string encodedPattern)
{
try
{
if (!TenantHelper.IsAuthenticated(req))
return await UnauthorizedAsync(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
if (!await _galacticAdminService.IsGalacticAdminAsync(userIdentifier))
return await ForbiddenAsync(req);
var versionPattern = Uri.UnescapeDataString(encodedPattern ?? string.Empty);
if (string.IsNullOrEmpty(versionPattern))
return await BadRequestAsync(req, "versionPattern is required");
await _blockedVersionService.UnblockVersionAsync(versionPattern);
_logger.LogInformation(
"Galactic Admin {User} removed version block rule: Pattern={Pattern}",
userIdentifier, versionPattern);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, message = $"Version pattern '{versionPattern}' unblocked." });
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error removing version block rule");
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { error = "Internal server error" });
return response;
}
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private static async Task<HttpResponseData> UnauthorizedAsync(HttpRequestData req)
{
var r = req.CreateResponse(HttpStatusCode.Unauthorized);
await r.WriteAsJsonAsync(new { success = false, message = "Authentication required." });
return r;
}
private static async Task<HttpResponseData> ForbiddenAsync(HttpRequestData req)
{
var r = req.CreateResponse(HttpStatusCode.Forbidden);
await r.WriteAsJsonAsync(new { success = false, message = "Access denied. Only Galactic Admins can manage version blocks." });
return r;
}
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, string message)
{
var r = req.CreateResponse(HttpStatusCode.BadRequest);
await r.WriteAsJsonAsync(new { success = false, message });
return r;
}
}
}
@@ -61,6 +61,41 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
}
}
// --- Version block check (global, applies to all tenants) ---
var agentVersionHeader = req.Headers.Contains("X-Agent-Version")
? req.Headers.GetValues("X-Agent-Version").FirstOrDefault()
: null;
if (!string.IsNullOrEmpty(agentVersionHeader))
{
var (isVersionBlocked, versionAction, matchedPattern) = await _blockedVersionService.IsVersionBlockedAsync(agentVersionHeader);
if (isVersionBlocked)
{
var isVersionKill = string.Equals(versionAction, "Kill", StringComparison.OrdinalIgnoreCase);
_logger.LogWarning(
"Version {Action} for agent: TenantId={TenantId}, AgentVersion={AgentVersion}, MatchedPattern={Pattern}",
isVersionKill ? "KILL" : "BLOCK", tenantId, agentVersionHeader, matchedPattern);
var versionBlockedResponse = req.CreateResponse(System.Net.HttpStatusCode.OK);
await versionBlockedResponse.WriteAsJsonAsync(new IngestEventsResponse
{
Success = false,
DeviceBlocked = true,
DeviceKillSignal = isVersionKill,
Message = isVersionKill
? $"Agent version {agentVersionHeader} has been issued a remote kill signal (pattern: {matchedPattern})."
: $"Agent version {agentVersionHeader} is blocked by administrator (pattern: {matchedPattern}).",
ProcessedAt = DateTime.UtcNow
});
return new IngestEventsOutput
{
HttpResponse = versionBlockedResponse,
SignalRMessages = Array.Empty<SignalRMessageAction>()
};
}
}
// --- Parse NDJSON+gzip request body (only after security is cleared) ---
var request = await ParseNdjsonGzipRequest(req.Body, tenantId);
@@ -20,6 +20,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
private readonly AnalyzeRuleService _analyzeRuleService;
private readonly TeamsNotificationService _teamsNotificationService;
private readonly BlockedDeviceService _blockedDeviceService;
private readonly BlockedVersionService _blockedVersionService;
private readonly BootstrapSessionService _bootstrapSessionService;
public IngestEventsFunction(
@@ -32,6 +33,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
AnalyzeRuleService analyzeRuleService,
TeamsNotificationService teamsNotificationService,
BlockedDeviceService blockedDeviceService,
BlockedVersionService blockedVersionService,
BootstrapSessionService bootstrapSessionService)
{
_logger = logger;
@@ -43,6 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_analyzeRuleService = analyzeRuleService;
_teamsNotificationService = teamsNotificationService;
_blockedDeviceService = blockedDeviceService;
_blockedVersionService = blockedVersionService;
_bootstrapSessionService = bootstrapSessionService;
}
@@ -112,6 +112,7 @@ builder.Services.AddSingleton<ImeLogPatternService>();
builder.Services.AddHttpClient<GitHubRuleRepository>();
builder.Services.AddSingleton<MaintenanceService>();
builder.Services.AddSingleton<BlockedDeviceService>();
builder.Services.AddSingleton<BlockedVersionService>();
builder.Services.AddSingleton<SessionReportService>();
builder.Services.AddSingleton<BootstrapSessionService>();
@@ -0,0 +1,321 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Shared;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Manages version-based block/kill rules for catching old or rogue agent versions.
/// Patterns:
/// "1.*" — matches all agents with major version 1
/// "1.0.*" — matches all agents with major.minor 1.0
/// "1.0.30" — matches all agents with version &lt;= 1.0.30
/// "=1.0.30" — matches exactly version 1.0.30
/// Global rules (apply to all tenants). Uses Azure Table Storage + in-memory cache.
/// </summary>
public class BlockedVersionService
{
private readonly TableStorageService _storageService;
private readonly ILogger<BlockedVersionService> _logger;
// In-memory cache of all version block rules
private readonly ConcurrentDictionary<string, VersionBlockCacheEntry> _cache = new(StringComparer.OrdinalIgnoreCase);
private volatile bool _loaded;
public BlockedVersionService(TableStorageService storageService, ILogger<BlockedVersionService> logger)
{
_storageService = storageService;
_logger = logger;
}
/// <summary>
/// Checks whether a given agent version matches any active block/kill rule.
/// Returns the most severe action (Kill > Block).
/// </summary>
public async Task<(bool isBlocked, string action, string? matchedPattern)> IsVersionBlockedAsync(string agentVersion)
{
if (string.IsNullOrEmpty(agentVersion))
return (false, "Block", null);
if (!_loaded)
await LoadBlockListAsync();
string? matchedAction = null;
string? matchedPattern = null;
foreach (var kvp in _cache)
{
if (VersionMatchesPattern(agentVersion, kvp.Key))
{
// Kill takes priority over Block
if (matchedAction == null || string.Equals(kvp.Value.Action, "Kill", StringComparison.OrdinalIgnoreCase))
{
matchedAction = kvp.Value.Action;
matchedPattern = kvp.Key;
}
// If we already found a Kill, no need to check more
if (string.Equals(matchedAction, "Kill", StringComparison.OrdinalIgnoreCase))
break;
}
}
return matchedAction != null
? (true, matchedAction, matchedPattern)
: (false, "Block", null);
}
/// <summary>
/// Adds or updates a version block rule.
/// </summary>
public async Task BlockVersionAsync(string versionPattern, string action, string createdByEmail, string? reason = null)
{
// Normalize
versionPattern = versionPattern.Trim();
action = string.Equals(action, "Kill", StringComparison.OrdinalIgnoreCase) ? "Kill" : "Block";
if (!IsValidPattern(versionPattern))
throw new ArgumentException($"Invalid version pattern: '{versionPattern}'. Use formats like '1.*', '1.0.*', '1.0.30' (<=), or '=1.0.30' (exact).");
var entity = new TableEntity("global", EncodeRowKey(versionPattern))
{
["VersionPattern"] = versionPattern,
["Action"] = action,
["CreatedByEmail"] = createdByEmail ?? string.Empty,
["CreatedAt"] = DateTime.UtcNow,
["Reason"] = reason ?? string.Empty
};
var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedVersions);
await tableClient.UpsertEntityAsync(entity);
// Update cache
_cache[versionPattern] = new VersionBlockCacheEntry { Action = action };
_logger.LogWarning(
"Version {Action} rule added: Pattern={Pattern}, CreatedBy={CreatedBy}, Reason={Reason}",
action, versionPattern, createdByEmail, reason);
}
/// <summary>
/// Removes a version block rule.
/// </summary>
public async Task UnblockVersionAsync(string versionPattern)
{
versionPattern = versionPattern.Trim();
var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedVersions);
try
{
await tableClient.DeleteEntityAsync("global", EncodeRowKey(versionPattern));
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Already removed
}
_cache.TryRemove(versionPattern, out _);
_logger.LogInformation("Version block rule removed: Pattern={Pattern}", versionPattern);
}
/// <summary>
/// Returns all active version block rules.
/// </summary>
public async Task<List<BlockedVersionEntry>> GetBlockedVersionsAsync()
{
var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedVersions);
var result = new List<BlockedVersionEntry>();
await foreach (var entity in tableClient.QueryAsync<TableEntity>(e => e.PartitionKey == "global"))
{
result.Add(new BlockedVersionEntry
{
VersionPattern = entity.GetString("VersionPattern") ?? DecodeRowKey(entity.RowKey),
Action = entity.GetString("Action") ?? "Block",
CreatedByEmail = entity.GetString("CreatedByEmail") ?? string.Empty,
CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.MinValue,
Reason = entity.GetString("Reason")
});
}
return result;
}
// -----------------------------------------------------------------------
// Version matching logic
// -----------------------------------------------------------------------
/// <summary>
/// Checks if an agent version matches a block pattern.
/// - "1.*" → agent version starts with "1." (major match)
/// - "1.0.*" → agent version starts with "1.0." (major.minor match)
/// - "1.0.30" → agent version parsed as semver, matches if agentVersion &lt;= pattern
/// - "=1.0.30" → exact match, only version 1.0.30
/// </summary>
internal static bool VersionMatchesPattern(string agentVersion, string pattern)
{
if (string.IsNullOrEmpty(agentVersion) || string.IsNullOrEmpty(pattern))
return false;
// Exact match: "=1.0.30" matches only that specific version
if (pattern.StartsWith("="))
{
var exactVersion = pattern.Substring(1);
if (TryParseVersion(agentVersion, out var agentExact) && TryParseVersion(exactVersion, out var patternExact))
return CompareVersionParts(agentExact!, patternExact!) == 0;
return string.Equals(agentVersion, exactVersion, StringComparison.OrdinalIgnoreCase);
}
// Wildcard patterns: prefix match
if (pattern.EndsWith(".*"))
{
var prefix = pattern.Substring(0, pattern.Length - 1); // "1." or "1.0."
return agentVersion.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
// Version ceiling: <= comparison
if (TryParseVersion(agentVersion, out var agentParts) && TryParseVersion(pattern, out var patternParts))
{
return CompareVersionParts(agentParts!, patternParts!) <= 0;
}
// Fallback: exact string match
return string.Equals(agentVersion, pattern, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Parses a version string like "1.0.30" into integer components.
/// Ignores any suffix after a dash or plus (e.g. "1.0.30-beta").
/// </summary>
private static bool TryParseVersion(string version, out int[]? parts)
{
parts = null;
if (string.IsNullOrEmpty(version)) return false;
// Strip pre-release suffix (e.g. "1.0.30-beta+build123")
var dashIndex = version.IndexOf('-');
if (dashIndex >= 0) version = version.Substring(0, dashIndex);
var plusIndex = version.IndexOf('+');
if (plusIndex >= 0) version = version.Substring(0, plusIndex);
var segments = version.Split('.');
var parsed = new List<int>();
foreach (var seg in segments)
{
if (int.TryParse(seg, out var n))
parsed.Add(n);
else
return false;
}
if (parsed.Count == 0) return false;
parts = parsed.ToArray();
return true;
}
/// <summary>
/// Compares two version part arrays. Returns &lt;0 if a &lt; b, 0 if equal, &gt;0 if a &gt; b.
/// Missing parts are treated as 0 (e.g. "1.0" == "1.0.0").
/// </summary>
private static int CompareVersionParts(int[] a, int[] b)
{
var maxLen = Math.Max(a.Length, b.Length);
for (int i = 0; i < maxLen; i++)
{
var av = i < a.Length ? a[i] : 0;
var bv = i < b.Length ? b[i] : 0;
if (av != bv) return av.CompareTo(bv);
}
return 0;
}
/// <summary>
/// Validates that a pattern is well-formed.
/// Valid: "1.*", "1.0.*", "1.0.30", "=1.0.30", etc.
/// </summary>
internal static bool IsValidPattern(string pattern)
{
if (string.IsNullOrWhiteSpace(pattern)) return false;
// Exact match prefix
if (pattern.StartsWith("="))
return TryParseVersion(pattern.Substring(1), out _);
// Wildcard patterns
if (pattern.EndsWith(".*"))
{
var prefix = pattern.Substring(0, pattern.Length - 2);
// prefix must be a valid partial version (e.g. "1" or "1.0")
return TryParseVersion(prefix, out _);
}
// Version ceiling
return TryParseVersion(pattern, out _);
}
// -----------------------------------------------------------------------
// Cache loading
// -----------------------------------------------------------------------
private async Task LoadBlockListAsync()
{
_loaded = true; // Mark before async to prevent parallel loads
try
{
var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedVersions);
await foreach (var entity in tableClient.QueryAsync<TableEntity>(e => e.PartitionKey == "global"))
{
var pattern = entity.GetString("VersionPattern") ?? DecodeRowKey(entity.RowKey);
_cache[pattern] = new VersionBlockCacheEntry
{
Action = entity.GetString("Action") ?? "Block"
};
}
_logger.LogDebug("Loaded {Count} version block rules", _cache.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load version block rules");
_loaded = false;
}
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private class VersionBlockCacheEntry
{
public string Action { get; set; } = "Block";
}
private static string EncodeRowKey(string pattern)
=> Uri.EscapeDataString(pattern);
private static string DecodeRowKey(string encoded)
=> Uri.UnescapeDataString(encoded);
}
/// <summary>
/// Represents a version block rule returned by the API.
/// </summary>
public class BlockedVersionEntry
{
public string VersionPattern { get; set; } = string.Empty;
public string Action { get; set; } = "Block";
public string CreatedByEmail { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public string? Reason { get; set; }
}
}
@@ -76,6 +76,8 @@ namespace AutopilotMonitor.Shared
public const string ReportAgentError = "/api/agent/error";
public const string BlockDevice = "/api/devices/block";
public const string GetBlockedDevices = "/api/devices/blocked";
public const string BlockVersion = "/api/versions/block";
public const string GetBlockedVersions = "/api/versions/blocked";
public const string ImeLogPatterns = "/api/rules/ime-log-patterns";
public const string ReseedFromGitHub = "/api/rules/reseed-from-github";
public const string ValidateBootstrapCode = "/api/bootstrap/validate/{code}";
@@ -179,6 +181,9 @@ namespace AutopilotMonitor.Shared
// Device blocking
public const string BlockedDevices = "BlockedDevices";
// Version-based blocking
public const string BlockedVersions = "BlockedVersions";
// Session reports (feedback from Tenant Admins)
public const string SessionReports = "SessionReports";
@@ -209,6 +214,7 @@ namespace AutopilotMonitor.Shared
PreviewWhitelist,
PreviewConfig,
BlockedDevices,
BlockedVersions,
SessionReports,
BootstrapSessions
};
@@ -0,0 +1,286 @@
"use client";
import { useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface BlockedVersion {
versionPattern: string;
action: string;
createdByEmail: string;
createdAt: string;
reason?: string;
}
interface VersionBlockSectionProps {
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
}
export function VersionBlockSection({
getAccessToken,
setError,
setSuccessMessage,
}: VersionBlockSectionProps) {
const [versionPattern, setVersionPattern] = useState("");
const [versionAction, setVersionAction] = useState<"Block" | "Kill">("Block");
const [versionReason, setVersionReason] = useState("");
const [addingRule, setAddingRule] = useState(false);
const [blockedVersions, setBlockedVersions] = useState<BlockedVersion[]>([]);
const [loadingVersions, setLoadingVersions] = useState(false);
const [removingPattern, setRemovingPattern] = useState<string | null>(null);
const fetchBlockedVersions = async () => {
try {
setLoadingVersions(true);
const response = await authenticatedFetch(`${API_BASE_URL}/api/versions/blocked`, getAccessToken);
if (!response.ok) throw new Error(`Failed to load version blocks: ${response.statusText}`);
const data = await response.json();
setBlockedVersions(data.rules ?? []);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while loading version blocks");
}
setError(err instanceof Error ? err.message : "Failed to load version blocks");
} finally {
setLoadingVersions(false);
}
};
const handleAddRule = async () => {
if (!versionPattern.trim()) return;
if (versionAction === "Kill" && !confirm(
`VERSION KILL: This will send a remote kill signal to ALL agents matching pattern "${versionPattern.trim()}". This cannot be undone. Continue?`
)) return;
try {
setAddingRule(true);
setError(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/versions/block`, getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
versionPattern: versionPattern.trim(),
action: versionAction,
reason: versionReason.trim() || undefined,
}),
});
const result = await response.json();
if (!response.ok) throw new Error(result.message || response.statusText);
setSuccessMessage(
versionAction === "Kill"
? `Version pattern "${versionPattern.trim()}" set to kill.`
: `Version pattern "${versionPattern.trim()}" blocked.`
);
setTimeout(() => setSuccessMessage(null), 4000);
setVersionPattern("");
setVersionReason("");
setVersionAction("Block");
if (blockedVersions.length > 0) await fetchBlockedVersions();
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while adding version block");
}
setError(err instanceof Error ? err.message : "Failed to add version block rule");
} finally {
setAddingRule(false);
}
};
const handleRemoveRule = async (pattern: string) => {
try {
setRemovingPattern(pattern);
setError(null);
const response = await authenticatedFetch(
`${API_BASE_URL}/api/versions/block/${encodeURIComponent(pattern)}`,
getAccessToken,
{ method: "DELETE" }
);
const result = await response.json();
if (!response.ok) throw new Error(result.message || response.statusText);
setSuccessMessage(`Version pattern "${pattern}" removed.`);
setTimeout(() => setSuccessMessage(null), 3000);
setBlockedVersions((prev) => prev.filter((v) => v.versionPattern !== pattern));
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while removing version block");
}
setError(err instanceof Error ? err.message : "Failed to remove version block rule");
} finally {
setRemovingPattern(null);
}
};
const getPatternDescription = (pattern: string) => {
if (pattern.startsWith("=")) return `exact version ${pattern.substring(1)}`;
if (pattern.endsWith(".*")) return `all ${pattern.replace(".*", ".x")} versions`;
return `all versions ≤ ${pattern}`;
};
return (
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-800 border-2 border-amber-300 dark:border-amber-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-amber-200 dark:border-amber-700 bg-gradient-to-r from-amber-100 to-orange-100 dark:from-amber-900/40 dark:to-orange-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-amber-600 dark:text-amber-400" 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>
<div>
<h2 className="text-xl font-semibold text-amber-900 dark:text-amber-100">Version Block Management</h2>
<p className="text-sm text-amber-600 dark:text-amber-300 mt-1">Block or kill agents by version pattern. Useful for catching old agents that are still running.</p>
</div>
</div>
</div>
<div className="p-6 space-y-6">
{/* Pattern help */}
<div className="border border-amber-200 dark:border-amber-700 rounded-lg p-4 bg-amber-50/50 dark:bg-amber-900/10">
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100 mb-2">Pattern Syntax</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs text-amber-800 dark:text-amber-200">
<div className="flex items-start gap-2">
<code className="bg-amber-100 dark:bg-amber-900/40 px-1.5 py-0.5 rounded font-mono whitespace-nowrap">1.*</code>
<span>All agents with major version 1</span>
</div>
<div className="flex items-start gap-2">
<code className="bg-amber-100 dark:bg-amber-900/40 px-1.5 py-0.5 rounded font-mono whitespace-nowrap">1.0.*</code>
<span>All agents with version 1.0.x</span>
</div>
<div className="flex items-start gap-2">
<code className="bg-amber-100 dark:bg-amber-900/40 px-1.5 py-0.5 rounded font-mono whitespace-nowrap">1.0.30</code>
<span>All agents with version ≤ 1.0.30</span>
</div>
<div className="flex items-start gap-2">
<code className="bg-amber-100 dark:bg-amber-900/40 px-1.5 py-0.5 rounded font-mono whitespace-nowrap">=1.0.30</code>
<span>Exactly version 1.0.30 only</span>
</div>
</div>
</div>
{/* Add rule form */}
<div>
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100 mb-3">Add Version Block Rule</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Version Pattern</label>
<input
type="text"
placeholder="e.g. 1.0.30 or 1.0.*"
value={versionPattern}
onChange={(e) => setVersionPattern(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500 font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Action</label>
<select
value={versionAction}
onChange={(e) => setVersionAction(e.target.value as "Block" | "Kill")}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500"
>
<option value="Block">Block (stop uploads)</option>
<option value="Kill">Kill (remote shutdown)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
<input
type="text"
placeholder="e.g. Deprecated version"
value={versionReason}
onChange={(e) => setVersionReason(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500"
/>
</div>
</div>
{versionAction === "Kill" && (
<div className="mt-3 p-3 bg-red-100 dark:bg-red-900/40 border border-red-300 dark:border-red-700 rounded-lg">
<p className="text-xs text-red-800 dark:text-red-200 font-medium">
All agents matching this version pattern will execute self-destruct on their next upload attempt. This is irreversible.
</p>
</div>
)}
{versionPattern.trim() && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
This will match: <strong>{getPatternDescription(versionPattern.trim())}</strong>
</p>
)}
<button
onClick={handleAddRule}
disabled={addingRule || !versionPattern.trim()}
className={`mt-4 px-4 py-2 text-white rounded-lg text-sm font-medium disabled:opacity-50 flex items-center space-x-2 ${
versionAction === "Kill"
? "bg-red-800 hover:bg-red-900 dark:bg-red-700 dark:hover:bg-red-800"
: "bg-amber-600 hover:bg-amber-700"
}`}
>
{addingRule ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>{versionAction === "Kill" ? "Adding Kill Rule..." : "Adding Block Rule..."}</span></>
) : (
<span>{versionAction === "Kill" ? "Add Kill Rule" : "Add Block Rule"}</span>
)}
</button>
</div>
{/* Active rules list */}
<div className="border-t border-amber-200 dark:border-amber-700 pt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100">Active Version Block Rules</h3>
<button
onClick={fetchBlockedVersions}
disabled={loadingVersions}
className="px-4 py-2 bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200 border border-amber-300 dark:border-amber-600 rounded-lg text-sm font-medium hover:bg-amber-200 disabled:opacity-50"
>
{loadingVersions ? "Loading..." : "Load Rules"}
</button>
</div>
{blockedVersions.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-amber-800 dark:text-amber-200 uppercase bg-amber-100 dark:bg-amber-900/30">
<tr>
<th className="px-3 py-2">Pattern</th>
<th className="px-3 py-2">Matches</th>
<th className="px-3 py-2">Type</th>
<th className="px-3 py-2">Created</th>
<th className="px-3 py-2">Created By</th>
<th className="px-3 py-2">Reason</th>
<th className="px-3 py-2">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-amber-100 dark:divide-amber-900/30">
{blockedVersions.map((v) => (
<tr key={v.versionPattern} className="bg-white dark:bg-gray-800">
<td className="px-3 py-2 font-mono font-medium text-gray-900 dark:text-gray-100">{v.versionPattern}</td>
<td className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">{getPatternDescription(v.versionPattern)}</td>
<td className="px-3 py-2">
{v.action === "Kill" ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-700 text-white">Kill</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-orange-200 dark:bg-orange-800 text-orange-800 dark:text-orange-200">Block</span>
)}
</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{new Date(v.createdAt).toLocaleString()}</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{v.createdByEmail}</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{v.reason || "\u2014"}</td>
<td className="px-3 py-2">
<button
onClick={() => handleRemoveRule(v.versionPattern)}
disabled={removingPattern === v.versionPattern}
className="px-3 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50"
>
{removingPattern === v.versionPattern ? "Removing..." : "Remove"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : loadingVersions ? null : (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">Click &quot;Load Rules&quot; to see active version block rules.</p>
)}
</div>
</div>
</div>
);
}
@@ -10,6 +10,7 @@ import { TenantManagementSection, TenantConfiguration } from "./components/Tenan
import { MaintenanceSection } from "./components/MaintenanceSection";
import { DiagnosticsLogPathsSection, DiagnosticsLogPath } from "./components/DiagnosticsLogPathsSection";
import { DeviceBlockSection } from "./components/DeviceBlockSection";
import { VersionBlockSection } from "./components/VersionBlockSection";
import { SessionExportSection } from "./components/SessionExportSection";
import { AdminConfigSettingsSection } from "./components/AdminConfigSettingsSection";
import { SessionReportsSection } from "./components/SessionReportsSection";
@@ -367,6 +368,13 @@ export default function AdminConfigurationPage() {
setError={setError}
setSuccessMessage={setSuccessMessage}
/>
{/* Version Block Management */}
<VersionBlockSection
getAccessToken={getAccessToken}
setError={setError}
setSuccessMessage={setSuccessMessage}
/>
</div>
</main>
</div>
@@ -54,6 +54,12 @@ export default function EventTimeline({
showScriptOutput,
}: EventTimelineProps) {
const [searchQuery, setSearchQuery] = useState("");
const [rawMode, setRawMode] = useState(false);
const sortedBySequence = useMemo(
() => [...events].filter(e => severityFilters.has(e.severity)).sort((a, b) => a.sequence - b.sequence),
[events, severityFilters]
);
const matchesSearch = useMemo(() => {
if (!searchQuery.trim()) return null;
@@ -117,7 +123,15 @@ export default function EventTimeline({
);
})}
<span className="text-xs text-gray-400">({filteredEvents.length}/{events.length})</span>
<div className="flex gap-1.5 ml-auto">
<div className="flex gap-1.5 ml-auto items-center">
{isGalacticAdmin && (
<button
onClick={() => setRawMode(!rawMode)}
className={`text-xs hover:underline mr-1 ${rawMode ? 'text-purple-700 font-semibold' : 'text-gray-400 hover:text-gray-600'}`}
>
{rawMode ? '← Timeline' : 'Raw'}
</button>
)}
<button
onClick={expandAll}
title="Expand All"
@@ -142,8 +156,17 @@ export default function EventTimeline({
</div>
</div>
{/* Timeline — split for WhiteGlove sessions, single card otherwise */}
{isWhiteGloveSession ? (
{/* Raw mode — compact flat list by sequence (galactic only) */}
{rawMode && isGalacticAdmin ? (
<div className="bg-white shadow rounded-lg p-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Raw Events ({sortedBySequence.length})</h2>
<div className="divide-y divide-gray-100">
{sortedBySequence.map((ev) => (
<RawEventRow key={ev.eventId || `${ev.sessionId}-${ev.sequence}`} event={ev} />
))}
</div>
</div>
) : isWhiteGloveSession ? (
<>
{/* Pre-Provisioning Part */}
<div className="bg-white shadow rounded-lg p-6">
@@ -509,6 +532,42 @@ function EventRow({ event, isGalacticAdmin, showScriptOutput }: { event: Enrollm
);
}
function RawEventRow({ event }: { event: EnrollmentEvent }) {
const [expanded, setExpanded] = useState(false);
const detailData = useMemo(() => normalizeEventDataForDisplay(event.data), [event.data]);
const hasDetails = detailData && Object.keys(detailData).length > 0;
const sevColor: Record<string, string> = {
Debug: "text-gray-400",
Info: "text-blue-600",
Warning: "text-yellow-600",
Error: "text-red-600",
Critical: "text-red-800 font-semibold",
};
return (
<div className="py-1.5 text-xs font-mono">
<div className="flex items-start gap-2">
<span className="text-gray-400 w-8 text-right flex-shrink-0">{event.sequence}</span>
<span className="text-gray-500 flex-shrink-0">{new Date(event.timestamp).toLocaleTimeString()}</span>
<span className={`flex-shrink-0 w-12 ${sevColor[event.severity] || "text-gray-500"}`}>{event.severity.substring(0, 4)}</span>
<span className="text-gray-900 font-medium flex-shrink-0">{event.eventType}</span>
<span className="text-gray-500 truncate flex-1 min-w-0">{event.message}</span>
{hasDetails && (
<button onClick={() => setExpanded(!expanded)} className="text-gray-400 hover:text-blue-600 flex-shrink-0 ml-1">
{expanded ? '−' : '+'}
</button>
)}
</div>
{expanded && hasDetails && (
<div className="ml-10 mt-1 p-2 bg-gray-900 rounded text-[11px] text-gray-100 overflow-x-auto max-h-60 overflow-y-auto">
<pre>{JSON.stringify(detailData, null, 2)}</pre>
</div>
)}
</div>
);
}
function SeverityBadge({ severity }: { severity: string }) {
const colors = {
Info: "bg-blue-100 text-blue-800",
File diff suppressed because one or more lines are too long