Merge pull request #38 from okieselbach/claude/add-ops-monitoring-table-HkmIZ

This commit is contained in:
Oliver Kieselbach
2026-04-02 19:18:56 +02:00
committed by GitHub
22 changed files with 1032 additions and 13 deletions
@@ -35,6 +35,7 @@ namespace AutopilotMonitor.Functions.DataAccess
services.AddSingleton<IDeviceSecurityRepository, TableDeviceSecurityRepository>();
services.AddSingleton<IUserUsageRepository, TableUserUsageRepository>();
services.AddSingleton<IDistressReportRepository, TableDistressReportRepository>();
services.AddSingleton<IOpsEventRepository, TableOpsEventRepository>();
return services;
}
@@ -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
};
}
@@ -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
{
/// <summary>
/// Table Storage implementation of <see cref="IOpsEventRepository"/>.
/// PartitionKey = Category (Consent, Maintenance, Security, Tenant, Agent).
/// RowKey = reverse-tick for newest-first ordering.
/// </summary>
public class TableOpsEventRepository : IOpsEventRepository
{
private readonly TableClient _table;
private readonly ILogger<TableOpsEventRepository> _logger;
public TableOpsEventRepository(
TableStorageService storage,
ILogger<TableOpsEventRepository> 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<List<OpsEventEntry>> GetOpsEventsAsync(int maxResults = 200)
{
var result = new List<OpsEventEntry>();
// 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<TableEntity>(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<List<OpsEventEntry>> GetOpsEventsByCategoryAsync(string category, int maxResults = 100)
{
var result = new List<OpsEventEntry>();
await foreach (var entity in _table.QueryAsync<TableEntity>(e => e.PartitionKey == category, maxPerPage: maxResults))
{
result.Add(MapToEntry(entity));
if (result.Count >= maxResults)
break;
}
return result;
}
public async Task<int> DeleteOpsEventsOlderThanAsync(DateTime cutoff)
{
var deleted = 0;
await foreach (var entity in _table.QueryAsync<TableEntity>())
{
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);
}
}
}
@@ -16,6 +16,7 @@ public class AutopilotDeviceValidationConsentFunction
private readonly IConfiguration _configuration;
private readonly GraphTokenService _graphTokenService;
private readonly TelemetryClient _telemetryClient;
private readonly OpsEventService _opsEventService;
/// <summary>
/// Known redirect URIs registered in the Entra ID app registration for the admin consent flow.
@@ -32,12 +33,14 @@ public class AutopilotDeviceValidationConsentFunction
ILogger<AutopilotDeviceValidationConsentFunction> 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);
}
@@ -17,15 +17,18 @@ namespace AutopilotMonitor.Functions.Functions.Admin
private readonly ILogger<DeviceBlockFunction> _logger;
private readonly BlockedDeviceService _blockedDeviceService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly OpsEventService _opsEventService;
public DeviceBlockFunction(
ILogger<DeviceBlockFunction> logger,
BlockedDeviceService blockedDeviceService,
IMaintenanceRepository maintenanceRepo)
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEventService)
{
_logger = logger;
_blockedDeviceService = blockedDeviceService;
_maintenanceRepo = maintenanceRepo;
_opsEventService = opsEventService;
}
/// <summary>GET /api/devices/blocked?tenantId={tenantId} — list all active blocks for a tenant</summary>
@@ -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}",
@@ -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
{
/// <summary>
/// Global Admin endpoint: returns operational events from the OpsEvents table.
/// Supports optional category filter via query parameter.
/// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
/// </summary>
public class GetOpsEventsFunction
{
private readonly ILogger<GetOpsEventsFunction> _logger;
private readonly IOpsEventRepository _repository;
public GetOpsEventsFunction(
ILogger<GetOpsEventsFunction> logger,
IOpsEventRepository repository)
{
_logger = logger;
_repository = repository;
}
[Function("GetOpsEvents")]
public async Task<HttpResponseData> 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<OpsEventEntry> 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;
}
}
}
}
@@ -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<TenantOffboardFunction> _logger;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly OpsEventService _opsEventService;
public TenantOffboardFunction(
ILogger<TenantOffboardFunction> logger,
IMaintenanceRepository maintenanceRepo)
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEventService)
{
_logger = logger;
_maintenanceRepo = maintenanceRepo;
_opsEventService = opsEventService;
}
/// <summary>
@@ -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;
@@ -17,15 +17,18 @@ namespace AutopilotMonitor.Functions.Functions.Admin
private readonly ILogger<VersionBlockFunction> _logger;
private readonly BlockedVersionService _blockedVersionService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly OpsEventService _opsEventService;
public VersionBlockFunction(
ILogger<VersionBlockFunction> logger,
BlockedVersionService blockedVersionService,
IMaintenanceRepository maintenanceRepo)
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEventService)
{
_logger = logger;
_blockedVersionService = blockedVersionService;
_maintenanceRepo = maintenanceRepo;
_opsEventService = opsEventService;
}
/// <summary>GET /api/versions/blocked — list all active version block rules</summary>
@@ -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);
@@ -127,6 +127,7 @@ builder.Services.AddSingleton<ImeLogPatternService>();
builder.Services.AddHttpClient<GitHubRuleRepository>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddSingleton<MaintenanceService>();
builder.Services.AddSingleton<OpsEventService>();
builder.Services.AddSingleton<BlockedDeviceService>();
builder.Services.AddSingleton<BlockedVersionService>();
builder.Services.AddSingleton<BlobStorageService>();
@@ -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),
};
/// <summary>
@@ -463,6 +463,91 @@ namespace AutopilotMonitor.Functions.Services
_logger.LogError(ex, "Distress report cleanup failed");
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// Removes operational events older than the configured retention period.
/// Retention is controlled by AdminConfiguration.OpsEventRetentionDays (default: 90).
/// </summary>
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");
}
}
}
}
@@ -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<MaintenanceService> _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<MaintenanceService> 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;
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class OpsEventService
{
private readonly IOpsEventRepository _repository;
private readonly ILogger<OpsEventService> _logger;
public OpsEventService(IOpsEventRepository repository, ILogger<OpsEventService> 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<string, int> 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);
}
}
}
}
@@ -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<string, string | number | undefined> = {};
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. ' +
@@ -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";
/// <summary>
/// Returns all table names for initialization
/// </summary>
@@ -326,7 +329,8 @@ namespace AutopilotMonitor.Shared
DeviceSnapshot,
CveIndex,
DistressReports,
UserUsageLog
UserUsageLog,
OpsEvents
};
}
}
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// Repository for operational events (OpsEvents table).
/// Stores vital infrastructure events visible to Global Admins in the Ops dashboard.
/// </summary>
public interface IOpsEventRepository
{
Task SaveOpsEventAsync(OpsEventEntry entry);
Task<List<OpsEventEntry>> GetOpsEventsAsync(int maxResults = 200);
Task<List<OpsEventEntry>> GetOpsEventsByCategoryAsync(string category, int maxResults = 100);
Task<int> DeleteOpsEventsOlderThanAsync(DateTime cutoff);
}
/// <summary>
/// Categories for operational events.
/// </summary>
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";
}
/// <summary>
/// Severity levels for operational events.
/// </summary>
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; }
}
}
@@ -90,6 +90,13 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public int MaintenanceBlockDurationHours { get; set; } = 12;
/// <summary>
/// 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.
/// </summary>
public int OpsEventRetentionDays { get; set; } = 90;
// ===== FEEDBACK SETTINGS =====
/// <summary>
@@ -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<DiagnosticsLogPath[]>([]);
@@ -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,
@@ -10,6 +10,7 @@ interface AdminConfiguration {
collectorIdleTimeoutMinutes?: number;
maxSessionWindowHours?: number;
maintenanceBlockDurationHours?: number;
opsEventRetentionDays?: number;
diagnosticsGlobalLogPathsJson?: string;
customSettings?: string;
}
@@ -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<string | null>;
setError: (error: string | null) => void;
opsEventRetentionDays: number;
setOpsEventRetentionDays: (value: number) => void;
onSaveConfig: () => Promise<void>;
savingConfig: boolean;
}
const SEVERITY_STYLES: Record<string, { badge: string; row: string }> = {
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<string, string> = {
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 (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${style.badge}`}>
{severity}
</span>
);
}
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 (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${style}`}>
{category}
</span>
);
}
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 (
<button
onClick={handleCopy}
title="Copy to clipboard"
className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
export function OpsEventsSection({
getAccessToken,
setError,
opsEventRetentionDays,
setOpsEventRetentionDays,
onSaveConfig,
savingConfig,
}: OpsEventsSectionProps) {
const [events, setEvents] = useState<OpsEvent[]>([]);
const [loading, setLoading] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<OpsEvent | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string>("");
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<Record<string, number>>((acc, e) => {
acc[e.category] = (acc[e.category] ?? 0) + 1;
return acc;
}, {});
const severityCounts = events.reduce<Record<string, number>>((acc, e) => {
acc[e.severity] = (acc[e.severity] ?? 0) + 1;
return acc;
}, {});
const errorCount = (severityCounts["Error"] ?? 0) + (severityCounts["Critical"] ?? 0);
return (
<div className="bg-gradient-to-br from-slate-50 to-gray-50 dark:from-gray-800 dark:to-gray-800 border-2 border-slate-300 dark:border-slate-700 rounded-lg shadow-lg">
{/* Header */}
<div className="p-6 border-b border-slate-200 dark:border-slate-700 bg-gradient-to-r from-slate-100 to-gray-100 dark:from-slate-900/40 dark:to-gray-900/40">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">Operational Events</h2>
<p className="text-sm text-slate-600 dark:text-slate-300 mt-1">
Vital infrastructure events across consent, maintenance, security, and tenant lifecycle.
</p>
</div>
</div>
<button
onClick={fetchEvents}
disabled={loading}
className="px-3 py-1.5 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 transition-colors"
>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
</div>
{/* Summary Cards */}
{!loading && events.length > 0 && (
<div className="p-4 border-b border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-gray-800/50">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-white dark:bg-gray-700 rounded-lg p-3 border border-gray-200 dark:border-gray-600">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">{events.length}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Total Events</div>
</div>
<div className={`bg-white dark:bg-gray-700 rounded-lg p-3 border ${errorCount > 0 ? 'border-red-300 dark:border-red-600' : 'border-gray-200 dark:border-gray-600'}`}>
<div className={`text-2xl font-bold ${errorCount > 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100'}`}>
{errorCount}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Errors / Critical</div>
</div>
<div className="col-span-2 bg-white dark:bg-gray-700 rounded-lg p-3 border border-gray-200 dark:border-gray-600">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">By Category</div>
<div className="flex flex-wrap gap-1.5">
{Object.entries(categoryCounts)
.sort(([, a], [, b]) => b - a)
.map(([cat, count]) => (
<span key={cat} className="text-xs">
<CategoryBadge category={cat} /> <span className="text-gray-500 dark:text-gray-400 ml-0.5">{count}</span>
</span>
))}
</div>
</div>
</div>
</div>
)}
{/* Retention Config */}
<div className="px-6 pt-4 pb-2">
<div className="flex items-center gap-3 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-3">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
Retention:
</label>
<input
type="number"
min="1"
max="365"
value={opsEventRetentionDays}
onChange={(e) => 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"
/>
<span className="text-sm text-gray-500 dark:text-gray-400">days</span>
<button
onClick={onSaveConfig}
disabled={savingConfig}
className="ml-auto px-3 py-1 text-xs font-medium rounded-md bg-slate-600 text-white hover:bg-slate-700 disabled:opacity-50 transition-colors"
>
{savingConfig ? "Saving..." : "Save"}
</button>
</div>
</div>
{/* Category Filter */}
<div className="px-6 pt-4 pb-2 flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Filter:</span>
<button
onClick={() => setCategoryFilter("")}
className={`px-2.5 py-1 text-xs font-medium rounded-md border transition-colors ${
categoryFilter === ""
? "bg-slate-700 text-white border-slate-700 dark:bg-slate-500 dark:border-slate-500"
: "border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
}`}
>
All
</button>
{ALL_CATEGORIES.map(cat => (
<button
key={cat}
onClick={() => setCategoryFilter(cat)}
className={`px-2.5 py-1 text-xs font-medium rounded-md border transition-colors ${
categoryFilter === cat
? "bg-slate-700 text-white border-slate-700 dark:bg-slate-500 dark:border-slate-500"
: "border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
}`}
>
{cat}
</button>
))}
</div>
{/* Events Table */}
<div className="p-6 pt-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-slate-600"></div>
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading ops events...</span>
</div>
) : events.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm">No operational events recorded yet.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Time</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Severity</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Event</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tenant</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{events.slice(currentPage * pageSize, (currentPage + 1) * pageSize).map((evt, idx) => {
const rowStyle = SEVERITY_STYLES[evt.severity]?.row ?? "";
return (
<tr
key={evt.id || `${evt.timestamp}-${idx}`}
onClick={() => setSelectedEvent(evt)}
className={`hover:bg-slate-50 dark:hover:bg-slate-900/20 cursor-pointer transition-colors ${rowStyle}`}
>
<td className="px-3 py-2.5 text-sm text-gray-900 dark:text-gray-100 whitespace-nowrap">
{new Date(evt.timestamp).toLocaleString()}
</td>
<td className="px-3 py-2.5 text-sm">
<SeverityBadge severity={evt.severity} />
</td>
<td className="px-3 py-2.5 text-sm">
<CategoryBadge category={evt.category} />
</td>
<td className="px-3 py-2.5 text-sm font-mono text-gray-700 dark:text-gray-300 whitespace-nowrap">
{evt.eventType}
</td>
<td className="px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 max-w-[300px] truncate">
{evt.message}
</td>
<td className="px-3 py-2.5 text-sm font-mono text-gray-700 dark:text-gray-300">
{evt.tenantId ? (evt.tenantId.length > 8 ? `${evt.tenantId.slice(0, 8)}...` : evt.tenantId) : <span className="text-gray-300 dark:text-gray-600">-</span>}
</td>
</tr>
);
})}
</tbody>
</table>
{/* Pagination */}
{events.length > pageSize && (
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
<span className="text-xs text-gray-500 dark:text-gray-400">
{currentPage * pageSize + 1}&ndash;{Math.min((currentPage + 1) * pageSize, events.length)} of {events.length}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(p => p - 1)}
disabled={currentPage === 0}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-xs text-gray-500 dark:text-gray-400">
{currentPage + 1} / {Math.ceil(events.length / pageSize)}
</span>
<button
onClick={() => setCurrentPage(p => p + 1)}
disabled={(currentPage + 1) * pageSize >= events.length}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* Detail Modal */}
{selectedEvent && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={() => setSelectedEvent(null)}
>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto"
onClick={e => e.stopPropagation()}
>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Event Details</h3>
<div className="flex gap-1.5">
<SeverityBadge severity={selectedEvent.severity} />
<CategoryBadge category={selectedEvent.category} />
</div>
</div>
<dl className="space-y-3 text-sm">
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Event Type</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedEvent.eventType}</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Timestamp</dt>
<dd className="text-gray-900 dark:text-gray-100 mt-0.5">
{new Date(selectedEvent.timestamp).toLocaleString()}
</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Message</dt>
<dd className="text-gray-900 dark:text-gray-100 mt-0.5">{selectedEvent.message}</dd>
</div>
{selectedEvent.tenantId && (
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Tenant ID</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
{selectedEvent.tenantId}
<CopyButton value={selectedEvent.tenantId} />
</dd>
</div>
)}
{selectedEvent.userId && (
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">User</dt>
<dd className="text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
{selectedEvent.userId}
<CopyButton value={selectedEvent.userId} />
</dd>
</div>
)}
{selectedEvent.details && (
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Details</dt>
<dd className="text-gray-900 dark:text-gray-100 mt-0.5 text-xs bg-gray-50 dark:bg-gray-700 rounded p-2 font-mono break-all whitespace-pre-wrap">
{(() => {
try {
return JSON.stringify(JSON.parse(selectedEvent.details!), null, 2);
} catch {
return selectedEvent.details;
}
})()}
</dd>
</div>
)}
</dl>
<div className="mt-6 flex justify-end">
<button
onClick={() => setSelectedEvent(null)}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
>
Close
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<header className="bg-white dark:bg-gray-800 shadow dark:shadow-gray-700">
<div className="py-6 px-4 sm:px-6 lg:px-8">
<h1 className="text-2xl font-normal text-gray-900 dark:text-white">Ops</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">Platform maintenance operations</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">Platform operations, monitoring, and maintenance</p>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
<AdminNotifications />
<OpsEventsSection
getAccessToken={getAccessToken}
setError={setError}
opsEventRetentionDays={opsEventRetentionDays}
setOpsEventRetentionDays={setOpsEventRetentionDays}
onSaveConfig={handleSaveAdminConfig}
savingConfig={savingConfig}
/>
<MaintenanceTriggerSection
getAccessToken={getAccessToken}
setError={setError}
+6
View File
@@ -179,6 +179,12 @@ export const api = {
list: () => `${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`,