diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs index 27aabe8a..14749faa 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs @@ -35,6 +35,7 @@ namespace AutopilotMonitor.Functions.DataAccess services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 40c79a58..75073bf9 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -515,7 +515,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled }, { "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc }, { "MaxDiagnosticsDownloadSizeMB", config.MaxDiagnosticsDownloadSizeMB }, - { "DiagnosticsDownloadTimeoutSeconds", config.DiagnosticsDownloadTimeoutSeconds } + { "DiagnosticsDownloadTimeoutSeconds", config.DiagnosticsDownloadTimeoutSeconds }, + { "OpsEventRetentionDays", config.OpsEventRetentionDays } }; return entity; @@ -539,7 +540,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true, VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc"), MaxDiagnosticsDownloadSizeMB = entity.GetInt32("MaxDiagnosticsDownloadSizeMB") ?? 500, - DiagnosticsDownloadTimeoutSeconds = entity.GetInt32("DiagnosticsDownloadTimeoutSeconds") ?? 120 + DiagnosticsDownloadTimeoutSeconds = entity.GetInt32("DiagnosticsDownloadTimeoutSeconds") ?? 120, + OpsEventRetentionDays = entity.GetInt32("OpsEventRetentionDays") ?? 90 }; } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs new file mode 100644 index 00000000..94c03691 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of . + /// PartitionKey = Category (Consent, Maintenance, Security, Tenant, Agent). + /// RowKey = reverse-tick for newest-first ordering. + /// + public class TableOpsEventRepository : IOpsEventRepository + { + private readonly TableClient _table; + private readonly ILogger _logger; + + public TableOpsEventRepository( + TableStorageService storage, + ILogger logger) + { + _logger = logger; + _table = storage.GetTableClient(Constants.TableNames.OpsEvents); + } + + public async Task SaveOpsEventAsync(OpsEventEntry entry) + { + var pk = entry.Category; + var rk = $"{DateTime.MaxValue.Ticks - DateTime.UtcNow.Ticks:D19}"; + + var entity = new TableEntity(pk, rk) + { + ["EventType"] = Truncate(entry.EventType, 64), + ["Severity"] = Truncate(entry.Severity, 16), + ["TenantId"] = Truncate(entry.TenantId, 36), + ["UserId"] = Truncate(entry.UserId, 128), + ["Message"] = Truncate(entry.Message, 512), + ["Details"] = Truncate(entry.Details, 4096), + ["Timestamp"] = entry.Timestamp, + }; + + await _table.UpsertEntityAsync(entity); + } + + public async Task> GetOpsEventsAsync(int maxResults = 200) + { + var result = new List(); + + // Query all partitions, sort by Timestamp descending client-side + // (Azure Table Storage doesn't support cross-partition ordering) + await foreach (var entity in _table.QueryAsync(maxPerPage: maxResults)) + { + result.Add(MapToEntry(entity)); + if (result.Count >= maxResults) + break; + } + + // Sort newest-first across categories + result.Sort((a, b) => b.Timestamp.CompareTo(a.Timestamp)); + return result; + } + + public async Task> GetOpsEventsByCategoryAsync(string category, int maxResults = 100) + { + var result = new List(); + + await foreach (var entity in _table.QueryAsync(e => e.PartitionKey == category, maxPerPage: maxResults)) + { + result.Add(MapToEntry(entity)); + if (result.Count >= maxResults) + break; + } + + return result; + } + + public async Task DeleteOpsEventsOlderThanAsync(DateTime cutoff) + { + var deleted = 0; + + await foreach (var entity in _table.QueryAsync()) + { + var timestamp = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.MinValue; + if (timestamp < cutoff) + { + try + { + await _table.DeleteEntityAsync(entity.PartitionKey, entity.RowKey); + deleted++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete ops event {PK}/{RK}", entity.PartitionKey, entity.RowKey); + } + } + } + + return deleted; + } + + private static OpsEventEntry MapToEntry(TableEntity entity) + { + return new OpsEventEntry + { + Id = $"{entity.PartitionKey}_{entity.RowKey}", + Category = entity.PartitionKey, + EventType = entity.GetString("EventType") ?? string.Empty, + Severity = entity.GetString("Severity") ?? OpsEventSeverity.Info, + TenantId = entity.GetString("TenantId"), + UserId = entity.GetString("UserId"), + Message = entity.GetString("Message") ?? string.Empty, + Details = entity.GetString("Details"), + Timestamp = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.MinValue, + }; + } + + private static string? Truncate(string? value, int maxLength) + { + if (value == null) return null; + return value.Length <= maxLength ? value : value.Substring(0, maxLength); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs index 604265c7..a7100932 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs @@ -16,6 +16,7 @@ public class AutopilotDeviceValidationConsentFunction private readonly IConfiguration _configuration; private readonly GraphTokenService _graphTokenService; private readonly TelemetryClient _telemetryClient; + private readonly OpsEventService _opsEventService; /// /// Known redirect URIs registered in the Entra ID app registration for the admin consent flow. @@ -32,12 +33,14 @@ public class AutopilotDeviceValidationConsentFunction ILogger logger, IConfiguration configuration, GraphTokenService graphTokenService, - TelemetryClient telemetryClient) + TelemetryClient telemetryClient, + OpsEventService opsEventService) { _logger = logger; _configuration = configuration; _graphTokenService = graphTokenService; _telemetryClient = telemetryClient; + _opsEventService = opsEventService; } [Function("GetAutopilotDeviceValidationConsentUrl")] @@ -83,6 +86,9 @@ public class AutopilotDeviceValidationConsentFunction ["RedirectUri"] = redirectUri, ["RedirectPath"] = redirectPath, }); + + await _opsEventService.RecordConsentRedirectUriMismatchAsync( + requestCtx.TargetTenantId, requestCtx.UserPrincipalName, redirectUri, redirectPath); } var consentUrl = @@ -102,6 +108,9 @@ public class AutopilotDeviceValidationConsentFunction ["RedirectUri"] = redirectUri, }); + await _opsEventService.RecordConsentFlowStartedAsync( + requestCtx.TargetTenantId, requestCtx.UserPrincipalName, redirectUri); + var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new { @@ -175,6 +184,9 @@ public class AutopilotDeviceValidationConsentFunction ["ErrorDescription"] = errorDescription.Length > 500 ? errorDescription[..500] : errorDescription, }); + await _opsEventService.RecordConsentFlowFailedAsync( + requestCtx.TargetTenantId, requestCtx.UserPrincipalName, errorCode, errorDescription); + return req.CreateResponse(HttpStatusCode.OK); } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs index 657cb9c9..d2d05ab7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs @@ -17,15 +17,18 @@ namespace AutopilotMonitor.Functions.Functions.Admin private readonly ILogger _logger; private readonly BlockedDeviceService _blockedDeviceService; private readonly IMaintenanceRepository _maintenanceRepo; + private readonly OpsEventService _opsEventService; public DeviceBlockFunction( ILogger logger, BlockedDeviceService blockedDeviceService, - IMaintenanceRepository maintenanceRepo) + IMaintenanceRepository maintenanceRepo, + OpsEventService opsEventService) { _logger = logger; _blockedDeviceService = blockedDeviceService; _maintenanceRepo = maintenanceRepo; + _opsEventService = opsEventService; } /// GET /api/devices/blocked?tenantId={tenantId} — list all active blocks for a tenant @@ -109,6 +112,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin } ); + await _opsEventService.RecordDeviceBlockedAsync(tenantId, serialNumber, reason ?? "No reason", userIdentifier); + var isKill = action == "Kill"; _logger.LogWarning( "Global Admin {User} {Action} device {SerialNumber} in tenant {TenantId} for {Hours}h. Reason: {Reason}", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs new file mode 100644 index 00000000..96e17297 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs @@ -0,0 +1,73 @@ +using System.Net; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Admin +{ + /// + /// Global Admin endpoint: returns operational events from the OpsEvents table. + /// Supports optional category filter via query parameter. + /// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware. + /// + public class GetOpsEventsFunction + { + private readonly ILogger _logger; + private readonly IOpsEventRepository _repository; + + public GetOpsEventsFunction( + ILogger logger, + IOpsEventRepository repository) + { + _logger = logger; + _repository = repository; + } + + [Function("GetOpsEvents")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/ops-events")] HttpRequestData req) + { + try + { + var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); + var category = query["category"]; + var maxResultsStr = query["maxResults"]; + var maxResults = 200; + if (int.TryParse(maxResultsStr, out var parsed) && parsed > 0 && parsed <= 1000) + maxResults = parsed; + + List events; + if (!string.IsNullOrWhiteSpace(category)) + { + events = await _repository.GetOpsEventsByCategoryAsync(category, maxResults); + } + else + { + events = await _repository.GetOpsEventsAsync(maxResults); + } + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new + { + success = true, + count = events.Count, + events + }); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching ops events"); + + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new + { + success = false, + message = "Internal server error" + }); + return errorResponse; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs index 118cd53d..34e80837 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; - +using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared; using Microsoft.AspNetCore.Authorization; @@ -19,13 +19,16 @@ public class TenantOffboardFunction { private readonly ILogger _logger; private readonly IMaintenanceRepository _maintenanceRepo; + private readonly OpsEventService _opsEventService; public TenantOffboardFunction( ILogger logger, - IMaintenanceRepository maintenanceRepo) + IMaintenanceRepository maintenanceRepo, + OpsEventService opsEventService) { _logger = logger; _maintenanceRepo = maintenanceRepo; + _opsEventService = opsEventService; } /// @@ -84,6 +87,8 @@ public class TenantOffboardFunction upn!, details); + await _opsEventService.RecordTenantOffboardedAsync(requestCtx.TargetTenantId, upn!, deletedCounts); + var okResponse = req.CreateResponse(HttpStatusCode.OK); await okResponse.WriteAsJsonAsync(result); return okResponse; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/VersionBlockFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/VersionBlockFunction.cs index f209d6b6..5a1e7151 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/VersionBlockFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/VersionBlockFunction.cs @@ -17,15 +17,18 @@ namespace AutopilotMonitor.Functions.Functions.Admin private readonly ILogger _logger; private readonly BlockedVersionService _blockedVersionService; private readonly IMaintenanceRepository _maintenanceRepo; + private readonly OpsEventService _opsEventService; public VersionBlockFunction( ILogger logger, BlockedVersionService blockedVersionService, - IMaintenanceRepository maintenanceRepo) + IMaintenanceRepository maintenanceRepo, + OpsEventService opsEventService) { _logger = logger; _blockedVersionService = blockedVersionService; _maintenanceRepo = maintenanceRepo; + _opsEventService = opsEventService; } /// GET /api/versions/blocked — list all active version block rules @@ -105,6 +108,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin } ); + await _opsEventService.RecordVersionBlockedAsync(versionPattern, userIdentifier); + _logger.LogWarning( "Global Admin {User} added version {Action} rule: Pattern={Pattern}, Reason={Reason}", userIdentifier, normalizedAction, versionPattern, reason); diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 7153cbd9..3b761a23 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -127,6 +127,7 @@ builder.Services.AddSingleton(); builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 7563627f..eb25eb94 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -255,6 +255,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "global/notifications", EndpointPolicy.GlobalAdminOnly), new("POST", "global/notifications/dismiss-all", EndpointPolicy.GlobalAdminOnly), new("POST", "global/notifications/{notificationId}/dismiss", EndpointPolicy.GlobalAdminOnly), + new("GET", "global/ops-events", EndpointPolicy.GlobalAdminOnly), }; /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs index c9acdacf..6df4985a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -463,6 +463,91 @@ namespace AutopilotMonitor.Functions.Services _logger.LogError(ex, "Distress report cleanup failed"); } } + + /// + /// Verifies that agent binaries and bootstrap script are available on blob storage. + /// Records an OpsEvent for each missing item so Global Admins see it in the dashboard. + /// + private async Task CheckAgentBlobStorageAsync() + { + _logger.LogInformation("Checking agent blob storage availability..."); + + var zipUrl = $"{AutopilotMonitor.Shared.Constants.AgentBlobBaseUrl}/{AutopilotMonitor.Shared.Constants.AgentZipFileName}"; + var ps1Url = $"{AutopilotMonitor.Shared.Constants.AgentBlobBaseUrl}/Install-AutopilotMonitor.ps1"; + + try + { + var client = _httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(15); + + var zipRequest = new HttpRequestMessage(HttpMethod.Head, zipUrl); + var ps1Request = new HttpRequestMessage(HttpMethod.Head, ps1Url); + + var results = await Task.WhenAll( + client.SendAsync(zipRequest), + client.SendAsync(ps1Request) + ); + + var zipResponse = results[0]; + var ps1Response = results[1]; + + if (!zipResponse.IsSuccessStatusCode) + { + _logger.LogError("Agent ZIP not available on blob storage: HTTP {StatusCode}", (int)zipResponse.StatusCode); + await _opsEventService.RecordBlobStorageMissingAsync("Agent ZIP (AutopilotMonitor-Agent.zip)", (int)zipResponse.StatusCode); + } + + if (!ps1Response.IsSuccessStatusCode) + { + _logger.LogError("Bootstrap script not available on blob storage: HTTP {StatusCode}", (int)ps1Response.StatusCode); + await _opsEventService.RecordBlobStorageMissingAsync("Bootstrap script (Install-AutopilotMonitor.ps1)", (int)ps1Response.StatusCode); + } + + if (zipResponse.IsSuccessStatusCode && ps1Response.IsSuccessStatusCode) + { + _logger.LogInformation("Agent blob storage check passed: all binaries available"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Agent blob storage check failed — storage unreachable"); + await _opsEventService.RecordBlobStorageUnreachableAsync(ex.Message); + } + } + + /// + /// Removes operational events older than the configured retention period. + /// Retention is controlled by AdminConfiguration.OpsEventRetentionDays (default: 90). + /// + private async Task CleanupOldOpsEventsAsync() + { + try + { + var adminConfig = await _adminConfigurationService.GetConfigurationAsync(); + var retentionDays = adminConfig.OpsEventRetentionDays; + + if (retentionDays <= 0) + { + _logger.LogInformation("OpsEvents cleanup disabled (OpsEventRetentionDays = 0)"); + return; + } + + var cutoff = DateTime.UtcNow.AddDays(-retentionDays); + _logger.LogInformation("Starting OpsEvents cleanup (retention: {Days} days, cutoff: {Cutoff:yyyy-MM-dd})", retentionDays, cutoff); + + var deleted = await _opsEventRepo.DeleteOpsEventsOlderThanAsync(cutoff); + _logger.LogInformation("OpsEvents cleanup complete: {Deleted} events deleted", deleted); + + if (deleted > 0) + { + await _opsEventService.RecordOpsEventCleanupAsync(deleted, retentionDays); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "OpsEvents cleanup failed"); + } + } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs index cd73b938..d3200503 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs @@ -51,6 +51,9 @@ namespace AutopilotMonitor.Functions.Services private readonly TenantAdminsService _tenantAdminsService; private readonly IUserUsageRepository _userUsageRepo; private readonly IDistressReportRepository _distressReportRepo; + private readonly IOpsEventRepository _opsEventRepo; + private readonly OpsEventService _opsEventService; + private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private const string PlatformStatsAliasFileName = "platform-stats.json"; @@ -68,6 +71,9 @@ namespace AutopilotMonitor.Functions.Services TenantAdminsService tenantAdminsService, IUserUsageRepository userUsageRepo, IDistressReportRepository distressReportRepo, + IOpsEventRepository opsEventRepo, + OpsEventService opsEventService, + IHttpClientFactory httpClientFactory, ILogger logger) { _maintenanceRepo = maintenanceRepo; @@ -80,6 +86,9 @@ namespace AutopilotMonitor.Functions.Services _tenantAdminsService = tenantAdminsService; _userUsageRepo = userUsageRepo; _distressReportRepo = distressReportRepo; + _opsEventRepo = opsEventRepo; + _opsEventService = opsEventService; + _httpClientFactory = httpClientFactory; _logger = logger; } @@ -98,6 +107,8 @@ namespace AutopilotMonitor.Functions.Services await AggregateMetricsWithCatchUpAsync(); await CleanupOldDataAsync(); await CleanupOldDistressReportsAsync(); + await CleanupOldOpsEventsAsync(); + await CheckAgentBlobStorageAsync(); await RecomputePlatformStatsAsync(); // Backfill and repair tasks run only via manual trigger (RunManualAsync) @@ -107,10 +118,12 @@ namespace AutopilotMonitor.Functions.Services maintenanceStart.Stop(); _logger.LogInformation($"Daily maintenance completed in {maintenanceStart.ElapsedMilliseconds}ms"); + await _opsEventService.RecordMaintenanceCompletedAsync((int)maintenanceStart.ElapsedMilliseconds, "Timer"); } catch (Exception ex) { _logger.LogError(ex, "Daily maintenance failed"); + await _opsEventService.RecordMaintenanceFailedAsync(ex.Message, "Timer"); } } @@ -160,6 +173,7 @@ namespace AutopilotMonitor.Functions.Services result.Success = true; _logger.LogInformation($"Manual maintenance completed in {maintenanceStart.ElapsedMilliseconds}ms"); + await _opsEventService.RecordMaintenanceCompletedAsync(result.DurationMs, triggeredBy); return result; } catch (Exception ex) @@ -169,6 +183,7 @@ namespace AutopilotMonitor.Functions.Services result.Error = ex.Message; maintenanceStart.Stop(); result.DurationMs = (int)maintenanceStart.ElapsedMilliseconds; + await _opsEventService.RecordMaintenanceFailedAsync(ex.Message, triggeredBy); return result; } } @@ -231,6 +246,7 @@ namespace AutopilotMonitor.Functions.Services }); _logger.LogInformation($"Tenant {tenantId}: Marked {sessionCount} stalled sessions as timed out (timeout: {timeoutHours}h)"); + await _opsEventService.RecordSessionTimeoutsAsync(tenantId, sessionCount, timeoutHours); } catch (Exception ex) { @@ -325,6 +341,7 @@ namespace AutopilotMonitor.Functions.Services }); _logger.LogInformation($"Tenant {tenantId}: Blocked {blockedCount} excessive data sender device(s) (window: {adminConfig.MaxSessionWindowHours}h, block: {blockDurationHours}h)"); + await _opsEventService.RecordExcessiveDataBlockedAsync(tenantId, blockedCount, adminConfig.MaxSessionWindowHours); } totalBlocked += blockedCount; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs new file mode 100644 index 00000000..a4bf9a80 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs @@ -0,0 +1,129 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Service for recording operational events into the OpsEvents table. + /// Provides typed helper methods for each event category so callers + /// don't need to construct OpsEventEntry manually. + /// All writes are fire-and-forget safe — failures are logged but never thrown. + /// + public class OpsEventService + { + private readonly IOpsEventRepository _repository; + private readonly ILogger _logger; + + public OpsEventService(IOpsEventRepository repository, ILogger logger) + { + _repository = repository; + _logger = logger; + } + + // ── Consent ──────────────────────────────────────────────────────────── + + public Task RecordConsentFlowStartedAsync(string tenantId, string userId, string redirectUri) + => WriteAsync(OpsEventCategory.Consent, "ConsentFlowStarted", OpsEventSeverity.Info, + $"Admin consent flow started by {userId}", + tenantId, userId, new { redirectUri }); + + public Task RecordConsentFlowFailedAsync(string tenantId, string userId, string error, string errorDescription) + => WriteAsync(OpsEventCategory.Consent, "ConsentFlowFailed", OpsEventSeverity.Error, + $"Admin consent failed: {error}", + tenantId, userId, new { error, errorDescription }); + + public Task RecordConsentRedirectUriMismatchAsync(string tenantId, string userId, string redirectUri, string redirectPath) + => WriteAsync(OpsEventCategory.Consent, "ConsentRedirectUriMismatch", OpsEventSeverity.Critical, + $"Redirect URI path '{redirectPath}' not in registered paths — consent will fail with AADSTS50011", + tenantId, userId, new { redirectUri, redirectPath }); + + // ── Maintenance ──────────────────────────────────────────────────────── + + public Task RecordMaintenanceCompletedAsync(int durationMs, string triggeredBy) + => WriteAsync(OpsEventCategory.Maintenance, "MaintenanceCompleted", OpsEventSeverity.Info, + $"Maintenance completed in {durationMs}ms (triggered by {triggeredBy})", + null, triggeredBy, new { durationMs }); + + public Task RecordMaintenanceFailedAsync(string error, string triggeredBy) + => WriteAsync(OpsEventCategory.Maintenance, "MaintenanceFailed", OpsEventSeverity.Error, + $"Maintenance failed: {error}", + null, triggeredBy, new { error }); + + public Task RecordOpsEventCleanupAsync(int deletedCount, int retentionDays) + => WriteAsync(OpsEventCategory.Maintenance, "OpsEventCleanup", OpsEventSeverity.Info, + $"Cleaned up {deletedCount} ops events older than {retentionDays} days", + null, "System.Maintenance", new { deletedCount, retentionDays }); + + // ── Security ─────────────────────────────────────────────────────────── + + public Task RecordDeviceBlockedAsync(string tenantId, string serialNumber, string reason, string blockedBy) + => WriteAsync(OpsEventCategory.Security, "DeviceBlocked", OpsEventSeverity.Warning, + $"Device {serialNumber} blocked: {reason}", + tenantId, blockedBy, new { serialNumber, reason }); + + public Task RecordExcessiveDataBlockedAsync(string tenantId, int devicesBlocked, int windowHours) + => WriteAsync(OpsEventCategory.Security, "ExcessiveDataBlocked", OpsEventSeverity.Warning, + $"{devicesBlocked} device(s) auto-blocked for excessive data (>{windowHours}h window)", + tenantId, "System.Maintenance", new { devicesBlocked, windowHours }); + + public Task RecordVersionBlockedAsync(string pattern, string blockedBy) + => WriteAsync(OpsEventCategory.Security, "VersionBlocked", OpsEventSeverity.Warning, + $"Agent version pattern '{pattern}' blocked", + null, blockedBy, new { pattern }); + + // ── Tenant ───────────────────────────────────────────────────────────── + + public Task RecordTenantOffboardedAsync(string tenantId, string performedBy, Dictionary deletedCounts) + => WriteAsync(OpsEventCategory.Tenant, "TenantOffboarded", OpsEventSeverity.Warning, + $"Tenant {tenantId} offboarded — all data deleted", + tenantId, performedBy, deletedCounts); + + // ── Agent ────────────────────────────────────────────────────────────── + + public Task RecordSessionTimeoutsAsync(string tenantId, int sessionCount, int timeoutHours) + => WriteAsync(OpsEventCategory.Agent, "SessionTimeouts", OpsEventSeverity.Info, + $"{sessionCount} session(s) timed out after {timeoutHours}h", + tenantId, "System.Maintenance", new { sessionCount, timeoutHours }); + + public Task RecordBlobStorageMissingAsync(string missingItem, int statusCode) + => WriteAsync(OpsEventCategory.Agent, "BlobStorageMissing", OpsEventSeverity.Critical, + $"Agent blob storage check failed: {missingItem} is missing or unreachable (HTTP {statusCode})", + null, "System.Maintenance", new { missingItem, statusCode }); + + public Task RecordBlobStorageUnreachableAsync(string error) + => WriteAsync(OpsEventCategory.Agent, "BlobStorageUnreachable", OpsEventSeverity.Critical, + $"Agent blob storage unreachable: {error}", + null, "System.Maintenance", new { error }); + + // ── Core write method ────────────────────────────────────────────────── + + private async Task WriteAsync(string category, string eventType, string severity, + string message, string? tenantId, string? userId, object? details) + { + try + { + var entry = new OpsEventEntry + { + Category = category, + EventType = eventType, + Severity = severity, + TenantId = tenantId, + UserId = userId, + Message = message, + Details = details != null ? JsonSerializer.Serialize(details) : null, + Timestamp = DateTime.UtcNow, + }; + + await _repository.SaveOpsEventAsync(entry); + } + catch (Exception ex) + { + // Never throw from ops event recording — it must not break the calling flow + _logger.LogWarning(ex, "Failed to record ops event {Category}/{EventType}", category, eventType); + } + } + } +} diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 3ddf9e54..f3691e59 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -201,7 +201,35 @@ export function registerAdminTools(server: McpServer): void { } ); - // Tool 17: list_session_reports + // Tool 17: get_ops_events + server.tool( + 'get_ops_events', + 'Get operational events for platform monitoring. Shows consent flow results, maintenance runs, security blocks, ' + + 'tenant offboards, agent timeouts, and blob storage health. Global Admin only. ' + + 'Use category filter to narrow results (Consent, Maintenance, Security, Tenant, Agent).', + { + category: z.string().optional().describe('Filter by category: Consent, Maintenance, Security, Tenant, Agent'), + maxResults: z.coerce.number().min(1).max(500).optional().default(200).describe('Max events to return (default: 200)'), + }, + READ_ONLY, + async (args) => { + try { + const params: Record = {}; + if (args.category) params.category = args.category; + if (args.maxResults) params.maxResults = args.maxResults; + const data = await apiFetch(`/api/global/ops-events${buildQuery(params)}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('403')) { + return { isError: true, content: [{ type: 'text' as const, text: 'Access denied. This tool requires Global Admin permissions.' }] }; + } + throw error; + } + } + ); + + // Tool 18: list_session_reports server.tool( 'list_session_reports', 'List session reports submitted by tenant admins. Reports contain user comments, screenshots, and agent logs for troubleshooting. ' + diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 72ab2675..fd71329f 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -289,6 +289,9 @@ namespace AutopilotMonitor.Shared // User usage tracking (per-user, per-day, per-endpoint) public const string UserUsageLog = "UserUsageLog"; + // Operational events (vital infrastructure signals for Global Admin Ops dashboard) + public const string OpsEvents = "OpsEvents"; + /// /// Returns all table names for initialization /// @@ -326,7 +329,8 @@ namespace AutopilotMonitor.Shared DeviceSnapshot, CveIndex, DistressReports, - UserUsageLog + UserUsageLog, + OpsEvents }; } } diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs new file mode 100644 index 00000000..c66b8231 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for operational events (OpsEvents table). + /// Stores vital infrastructure events visible to Global Admins in the Ops dashboard. + /// + public interface IOpsEventRepository + { + Task SaveOpsEventAsync(OpsEventEntry entry); + Task> GetOpsEventsAsync(int maxResults = 200); + Task> GetOpsEventsByCategoryAsync(string category, int maxResults = 100); + Task DeleteOpsEventsOlderThanAsync(DateTime cutoff); + } + + /// + /// Categories for operational events. + /// + public static class OpsEventCategory + { + public const string Consent = "Consent"; + public const string Maintenance = "Maintenance"; + public const string Security = "Security"; + public const string Tenant = "Tenant"; + public const string Agent = "Agent"; + } + + /// + /// Severity levels for operational events. + /// + public static class OpsEventSeverity + { + public const string Info = "Info"; + public const string Warning = "Warning"; + public const string Error = "Error"; + public const string Critical = "Critical"; + } + + public class OpsEventEntry + { + public string Id { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string EventType { get; set; } = string.Empty; + public string Severity { get; set; } = OpsEventSeverity.Info; + public string? TenantId { get; set; } + public string? UserId { get; set; } + public string Message { get; set; } = string.Empty; + public string? Details { get; set; } + public DateTime Timestamp { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index 9ae743c5..2e78cf83 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -90,6 +90,13 @@ namespace AutopilotMonitor.Shared.Models /// public int MaintenanceBlockDurationHours { get; set; } = 12; + /// + /// Retention period in days for operational events in the OpsEvents table. + /// Events older than this are deleted by the periodic maintenance job. + /// 0 = no cleanup (not recommended). Default: 90 days. + /// + public int OpsEventRetentionDays { get; set; } = 90; + // ===== FEEDBACK SETTINGS ===== /// diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx index 105ec1b7..0afddaa2 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx @@ -16,6 +16,7 @@ export interface AdminConfiguration { collectorIdleTimeoutMinutes?: number; maxSessionWindowHours?: number; maintenanceBlockDurationHours?: number; + opsEventRetentionDays?: number; diagnosticsGlobalLogPathsJson?: string; customSettings?: string; nvdApiKey?: string; @@ -42,6 +43,8 @@ interface AdminConfigContextValue { setMaxSessionWindowHours: (value: number) => void; maintenanceBlockDurationHours: number; setMaintenanceBlockDurationHours: (value: number) => void; + opsEventRetentionDays: number; + setOpsEventRetentionDays: (value: number) => void; // Diagnostics log paths globalDiagPaths: DiagnosticsLogPath[]; @@ -94,6 +97,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) const [collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes] = useState(15); const [maxSessionWindowHours, setMaxSessionWindowHours] = useState(24); const [maintenanceBlockDurationHours, setMaintenanceBlockDurationHours] = useState(12); + const [opsEventRetentionDays, setOpsEventRetentionDays] = useState(90); // Diagnostics Log Paths state const [globalDiagPaths, setGlobalDiagPaths] = useState([]); @@ -132,6 +136,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setCollectorIdleTimeoutMinutes(data.collectorIdleTimeoutMinutes ?? 15); setMaxSessionWindowHours(data.maxSessionWindowHours ?? 24); setMaintenanceBlockDurationHours(data.maintenanceBlockDurationHours ?? 12); + setOpsEventRetentionDays(data.opsEventRetentionDays ?? 90); try { setGlobalDiagPaths(data.diagnosticsGlobalLogPathsJson ? JSON.parse(data.diagnosticsGlobalLogPathsJson) : []); } catch { @@ -209,6 +214,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, + opsEventRetentionDays, }; const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, { @@ -235,7 +241,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) } finally { setSavingConfig(false); } - }, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, getAccessToken]); + }, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, getAccessToken]); // Reset admin config const handleResetAdminConfig = useCallback(() => { @@ -245,6 +251,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setCollectorIdleTimeoutMinutes(adminConfig.collectorIdleTimeoutMinutes ?? 15); setMaxSessionWindowHours(adminConfig.maxSessionWindowHours ?? 24); setMaintenanceBlockDurationHours(adminConfig.maintenanceBlockDurationHours ?? 12); + setOpsEventRetentionDays(adminConfig.opsEventRetentionDays ?? 90); try { setGlobalDiagPaths(adminConfig.diagnosticsGlobalLogPathsJson ? JSON.parse(adminConfig.diagnosticsGlobalLogPathsJson) : []); } catch { @@ -298,6 +305,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes, maxSessionWindowHours, setMaxSessionWindowHours, maintenanceBlockDurationHours, setMaintenanceBlockDurationHours, + opsEventRetentionDays, setOpsEventRetentionDays, globalDiagPaths, setGlobalDiagPaths, savingDiagPaths, tenants, setTenants, loadingTenants, fetchTenants, previewApproved, setPreviewApproved, diff --git a/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx index d2f2042b..b2693b1d 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx @@ -10,6 +10,7 @@ interface AdminConfiguration { collectorIdleTimeoutMinutes?: number; maxSessionWindowHours?: number; maintenanceBlockDurationHours?: number; + opsEventRetentionDays?: number; diagnosticsGlobalLogPathsJson?: string; customSettings?: string; } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx new file mode 100644 index 00000000..6eea72a3 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx @@ -0,0 +1,431 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { api } from "@/lib/api"; +import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; + +interface OpsEvent { + id: string; + category: string; + eventType: string; + severity: string; + tenantId: string | null; + userId: string | null; + message: string; + details: string | null; + timestamp: string; +} + +interface OpsEventsSectionProps { + getAccessToken: () => Promise; + setError: (error: string | null) => void; + opsEventRetentionDays: number; + setOpsEventRetentionDays: (value: number) => void; + onSaveConfig: () => Promise; + savingConfig: boolean; +} + +const SEVERITY_STYLES: Record = { + Info: { badge: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300", row: "" }, + Warning: { badge: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300", row: "" }, + Error: { badge: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300", row: "bg-red-50/30 dark:bg-red-900/10" }, + Critical: { badge: "bg-red-200 text-red-900 dark:bg-red-800/60 dark:text-red-200 font-bold", row: "bg-red-50/50 dark:bg-red-900/20" }, +}; + +const CATEGORY_STYLES: Record = { + Consent: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300", + Maintenance: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300", + Security: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300", + Tenant: "bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300", + Agent: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300", +}; + +const ALL_CATEGORIES = ["Consent", "Maintenance", "Security", "Tenant", "Agent"]; + +function SeverityBadge({ severity }: { severity: string }) { + const style = SEVERITY_STYLES[severity] ?? SEVERITY_STYLES.Info; + return ( + + {severity} + + ); +} + +function CategoryBadge({ category }: { category: string }) { + const style = CATEGORY_STYLES[category] ?? "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"; + return ( + + {category} + + ); +} + +function CopyButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(value).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + return ( + + ); +} + +export function OpsEventsSection({ + getAccessToken, + setError, + opsEventRetentionDays, + setOpsEventRetentionDays, + onSaveConfig, + savingConfig, +}: OpsEventsSectionProps) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedEvent, setSelectedEvent] = useState(null); + const [categoryFilter, setCategoryFilter] = useState(""); + const [currentPage, setCurrentPage] = useState(0); + const pageSize = 20; + + useEffect(() => { + fetchEvents(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [categoryFilter]); + + const fetchEvents = async () => { + try { + setLoading(true); + const res = await authenticatedFetch( + api.opsEvents.list(categoryFilter || undefined), + getAccessToken + ); + if (res.status === 404) { + setEvents([]); + return; + } + if (!res.ok) throw new Error(`Failed to load ops events: ${res.statusText}`); + const data = await res.json(); + setEvents(data.events ?? []); + setCurrentPage(0); + } catch (err) { + if (err instanceof TokenExpiredError) { + console.error("Session expired while loading ops events"); + } + setError(err instanceof Error ? err.message : "Failed to load ops events"); + } finally { + setLoading(false); + } + }; + + // Summary stats + const categoryCounts = events.reduce>((acc, e) => { + acc[e.category] = (acc[e.category] ?? 0) + 1; + return acc; + }, {}); + + const severityCounts = events.reduce>((acc, e) => { + acc[e.severity] = (acc[e.severity] ?? 0) + 1; + return acc; + }, {}); + + const errorCount = (severityCounts["Error"] ?? 0) + (severityCounts["Critical"] ?? 0); + + return ( +
+ {/* Header */} +
+
+
+ + + +
+

Operational Events

+

+ Vital infrastructure events across consent, maintenance, security, and tenant lifecycle. +

+
+
+ +
+
+ + {/* Summary Cards */} + {!loading && events.length > 0 && ( +
+
+
+
{events.length}
+
Total Events
+
+
0 ? 'border-red-300 dark:border-red-600' : 'border-gray-200 dark:border-gray-600'}`}> +
0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100'}`}> + {errorCount} +
+
Errors / Critical
+
+
+
By Category
+
+ {Object.entries(categoryCounts) + .sort(([, a], [, b]) => b - a) + .map(([cat, count]) => ( + + {count} + + ))} +
+
+
+
+ )} + + {/* Retention Config */} +
+
+ + setOpsEventRetentionDays(parseInt(e.target.value) || 90)} + className="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-slate-500" + /> + days + +
+
+ + {/* Category Filter */} +
+ Filter: + + {ALL_CATEGORIES.map(cat => ( + + ))} +
+ + {/* Events Table */} +
+ {loading ? ( +
+
+ Loading ops events... +
+ ) : events.length === 0 ? ( +
+ + + +

No operational events recorded yet.

+
+ ) : ( +
+ + + + + + + + + + + + + {events.slice(currentPage * pageSize, (currentPage + 1) * pageSize).map((evt, idx) => { + const rowStyle = SEVERITY_STYLES[evt.severity]?.row ?? ""; + return ( + setSelectedEvent(evt)} + className={`hover:bg-slate-50 dark:hover:bg-slate-900/20 cursor-pointer transition-colors ${rowStyle}`} + > + + + + + + + + ); + })} + +
TimeSeverityCategoryEventMessageTenant
+ {new Date(evt.timestamp).toLocaleString()} + + + + + + {evt.eventType} + + {evt.message} + + {evt.tenantId ? (evt.tenantId.length > 8 ? `${evt.tenantId.slice(0, 8)}...` : evt.tenantId) : -} +
+ + {/* Pagination */} + {events.length > pageSize && ( +
+ + {currentPage * pageSize + 1}–{Math.min((currentPage + 1) * pageSize, events.length)} of {events.length} + +
+ + + {currentPage + 1} / {Math.ceil(events.length / pageSize)} + + +
+
+ )} +
+ )} +
+ + {/* Detail Modal */} + {selectedEvent && ( +
setSelectedEvent(null)} + > +
e.stopPropagation()} + > +
+
+

Event Details

+
+ + +
+
+ +
+
+
Event Type
+
{selectedEvent.eventType}
+
+
+
Timestamp
+
+ {new Date(selectedEvent.timestamp).toLocaleString()} +
+
+
+
Message
+
{selectedEvent.message}
+
+ {selectedEvent.tenantId && ( +
+
Tenant ID
+
+ {selectedEvent.tenantId} + +
+
+ )} + {selectedEvent.userId && ( +
+
User
+
+ {selectedEvent.userId} + +
+
+ )} + {selectedEvent.details && ( +
+
Details
+
+ {(() => { + try { + return JSON.stringify(JSON.parse(selectedEvent.details!), null, 2); + } catch { + return selectedEvent.details; + } + })()} +
+
+ )} +
+ +
+ +
+
+
+
+ )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/page.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/page.tsx index bc0532b7..568c8a7f 100644 --- a/src/Web/autopilot-monitor-web/app/admin/ops/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/ops/page.tsx @@ -2,21 +2,38 @@ import { useAdminConfig } from "../AdminConfigContext"; import { MaintenanceTriggerSection } from "../components/MaintenanceTriggerSection"; +import { OpsEventsSection } from "../components/OpsEventsSection"; import { AdminNotifications } from "../AdminNotifications"; export default function OpsPage() { - const { getAccessToken, setError, setSuccessMessage } = useAdminConfig(); + const { + getAccessToken, + setError, + setSuccessMessage, + opsEventRetentionDays, + setOpsEventRetentionDays, + handleSaveAdminConfig, + savingConfig, + } = useAdminConfig(); return (

Ops

-

Platform maintenance operations

+

Platform operations, monitoring, and maintenance

-
+
+ `${API_BASE_URL}/api/global/distress-reports`, }, + // ── Ops Events ─────────────────────────────────────────────────────────── + opsEvents: { + list: (category?: string, maxResults?: number) => + `${API_BASE_URL}/api/global/ops-events${qs({ category, maxResults: maxResults?.toString() })}`, + }, + // ── Notifications ───────────────────────────────────────────────────────── notifications: { list: () => `${API_BASE_URL}/api/global/notifications`,