mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Merge pull request #38 from okieselbach/claude/add-ops-monitoring-table-HkmIZ
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+127
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -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}–{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}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user