mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
added raw event timeline and block management for agent versions
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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 <= 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 <= 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 <0 if a < b, 0 if equal, >0 if a > 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
|
||||
};
|
||||
|
||||
+286
@@ -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 "Load Rules" 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>
|
||||
|
||||
+62
-3
@@ -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
Reference in New Issue
Block a user