Central table creation logic

app install metrics
platform stats
global gather and analyze rule editng
Dark mode (needs improvement)
UI enhancments
This commit is contained in:
Oliver Kieselbach
2026-02-07 19:23:20 +01:00
parent 994324f833
commit f358d8ebd9
33 changed files with 2824 additions and 828 deletions
@@ -16,11 +16,13 @@ namespace AutopilotMonitor.Functions.Functions
{
private readonly ILogger<AnalyzeRulesFunction> _logger;
private readonly AnalyzeRuleService _ruleService;
private readonly GalacticAdminService _galacticAdminService;
public AnalyzeRulesFunction(ILogger<AnalyzeRulesFunction> logger, AnalyzeRuleService ruleService)
public AnalyzeRulesFunction(ILogger<AnalyzeRulesFunction> logger, AnalyzeRuleService ruleService, GalacticAdminService galacticAdminService)
{
_logger = logger;
_ruleService = ruleService;
_galacticAdminService = galacticAdminService;
}
[Function("GetAnalyzeRules")]
@@ -95,11 +97,32 @@ namespace AutopilotMonitor.Functions.Functions
}
rule.RuleId = ruleId;
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
return response;
// Galactic Admins can edit built-in rules globally (affects all tenants)
var globalEdit = req.Url.Query.Contains("global=true", StringComparison.OrdinalIgnoreCase);
if (globalEdit)
{
var upn = TenantHelper.GetUserIdentifier(req);
var isGalactic = await _galacticAdminService.IsGalacticAdminAsync(upn);
if (!isGalactic)
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { success = false, message = "Galactic Admin privileges required for global rule edits" });
return forbidden;
}
var success = await _ruleService.UpdateGlobalRuleAsync(rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Global rule updated" : "Failed to update global rule" });
return response;
}
else
{
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
return response;
}
}
[Function("DeleteAnalyzeRule")]
@@ -57,6 +57,9 @@ namespace AutopilotMonitor.Functions.Functions
// Step 3: Clean up old data based on retention policies
await CleanupOldDataAsync();
// Step 4: Recompute platform-wide stats for landing page
await RecomputePlatformStatsAsync();
maintenanceStart.Stop();
_logger.LogInformation($"Daily maintenance completed in {maintenanceStart.ElapsedMilliseconds}ms");
}
@@ -97,6 +100,10 @@ namespace AutopilotMonitor.Functions.Functions
result.DataCleanupExecuted = true;
}
// Step 4: Recompute platform-wide stats
await RecomputePlatformStatsAsync();
result.PlatformStatsRecomputed = true;
maintenanceStart.Stop();
result.DurationMs = (int)maintenanceStart.ElapsedMilliseconds;
result.Success = true;
@@ -360,16 +367,26 @@ namespace AutopilotMonitor.Functions.Functions
continue;
}
// Delete each session and its events
// Delete each session and all associated data
int sessionCount = 0;
int eventCount = 0;
int ruleResultCount = 0;
int appSummaryCount = 0;
foreach (var session in oldSessions)
{
// Delete all events for this session (no date check needed!)
// Delete all events for this session
var deletedEvents = await _storageService.DeleteSessionEventsAsync(session.TenantId, session.SessionId);
eventCount += deletedEvents;
// Delete all rule results for this session
var deletedRuleResults = await _storageService.DeleteSessionRuleResultsAsync(session.TenantId, session.SessionId);
ruleResultCount += deletedRuleResults;
// Delete all app install summaries for this session
var deletedAppSummaries = await _storageService.DeleteSessionAppInstallSummariesAsync(session.TenantId, session.SessionId);
appSummaryCount += deletedAppSummaries;
// Delete the session itself
await _storageService.DeleteSessionAsync(session.TenantId, session.SessionId);
sessionCount++;
@@ -389,11 +406,13 @@ namespace AutopilotMonitor.Functions.Functions
{
{ "SessionsDeleted", sessionCount.ToString() },
{ "EventsDeleted", eventCount.ToString() },
{ "RuleResultsDeleted", ruleResultCount.ToString() },
{ "AppSummariesDeleted", appSummaryCount.ToString() },
{ "RetentionDays", retentionDays.ToString() },
{ "CutoffDate", cutoffDate.ToString("yyyy-MM-dd") }
});
_logger.LogInformation($"Tenant {tenantId}: Deleted {sessionCount} sessions and {eventCount} events (retention: {retentionDays} days)");
_logger.LogInformation($"Tenant {tenantId}: Deleted {sessionCount} sessions, {eventCount} events, {ruleResultCount} rule results, {appSummaryCount} app summaries (retention: {retentionDays} days)");
}
catch (Exception ex)
{
@@ -410,6 +429,70 @@ namespace AutopilotMonitor.Functions.Functions
}
}
/// <summary>
/// Recomputes platform-wide stats from all tables.
/// Used on the public landing page (no auth required).
/// </summary>
private async Task RecomputePlatformStatsAsync()
{
_logger.LogInformation("Recomputing platform stats...");
var sw = Stopwatch.StartNew();
try
{
var tenantIds = await _storageService.GetAllTenantIdsAsync();
long totalEnrollments = 0;
long successfulEnrollments = 0;
long totalEvents = 0;
long totalUsers = 0;
var uniqueModels = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var tid in tenantIds)
{
var sessions = await _storageService.GetSessionsAsync(tid, maxResults: 10000);
totalEnrollments += sessions.Count;
successfulEnrollments += sessions.Count(s => s.Status == SessionStatus.Succeeded);
foreach (var s in sessions)
{
var modelKey = $"{s.Manufacturer} {s.Model}".Trim();
if (!string.IsNullOrEmpty(modelKey))
uniqueModels.Add(modelKey);
totalEvents += s.EventCount;
}
var userMetrics = await _storageService.GetUserActivityMetricsAsync(tid);
totalUsers += userMetrics.TotalUniqueUsers;
}
// Preserve IssuesDetected from existing stats (incremented during ingestion)
var existingStats = await _storageService.GetPlatformStatsAsync();
var stats = new PlatformStats
{
TotalEnrollments = totalEnrollments,
TotalUsers = totalUsers,
TotalTenants = tenantIds.Count,
UniqueDeviceModels = uniqueModels.Count,
TotalEventsProcessed = totalEvents,
SuccessfulEnrollments = successfulEnrollments,
IssuesDetected = existingStats?.IssuesDetected ?? 0,
LastFullCompute = DateTime.UtcNow,
LastUpdated = DateTime.UtcNow
};
await _storageService.SavePlatformStatsAsync(stats);
sw.Stop();
_logger.LogInformation($"Platform stats recomputed in {sw.ElapsedMilliseconds}ms: " +
$"{totalEnrollments} enrollments, {totalUsers} users, {tenantIds.Count} tenants, {uniqueModels.Count} models");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to recompute platform stats");
}
}
/// <summary>
/// Calculates percentile value from sorted list
/// </summary>
@@ -437,5 +520,6 @@ namespace AutopilotMonitor.Functions.Functions
public bool MetricsAggregated { get; set; }
public string? AggregatedDate { get; set; }
public bool DataCleanupExecuted { get; set; }
public bool PlatformStatsRecomputed { get; set; }
}
}
@@ -16,11 +16,13 @@ namespace AutopilotMonitor.Functions.Functions
{
private readonly ILogger<GatherRulesFunction> _logger;
private readonly GatherRuleService _ruleService;
private readonly GalacticAdminService _galacticAdminService;
public GatherRulesFunction(ILogger<GatherRulesFunction> logger, GatherRuleService ruleService)
public GatherRulesFunction(ILogger<GatherRulesFunction> logger, GatherRuleService ruleService, GalacticAdminService galacticAdminService)
{
_logger = logger;
_ruleService = ruleService;
_galacticAdminService = galacticAdminService;
}
[Function("GetGatherRules")]
@@ -95,11 +97,32 @@ namespace AutopilotMonitor.Functions.Functions
}
rule.RuleId = ruleId;
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
return response;
// Galactic Admins can edit built-in rules globally (affects all tenants)
var globalEdit = req.Url.Query.Contains("global=true", StringComparison.OrdinalIgnoreCase);
if (globalEdit)
{
var upn = TenantHelper.GetUserIdentifier(req);
var isGalactic = await _galacticAdminService.IsGalacticAdminAsync(upn);
if (!isGalactic)
{
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { success = false, message = "Galactic Admin privileges required for global rule edits" });
return forbidden;
}
var success = await _ruleService.UpdateGlobalRuleAsync(rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Global rule updated" : "Failed to update global rule" });
return response;
}
else
{
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
return response;
}
}
[Function("DeleteGatherRule")]
@@ -0,0 +1,100 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
public class GetAppMetricsFunction
{
private readonly ILogger<GetAppMetricsFunction> _logger;
private readonly TableStorageService _storageService;
public GetAppMetricsFunction(ILogger<GetAppMetricsFunction> logger, TableStorageService storageService)
{
_logger = logger;
_storageService = storageService;
}
[Function("GetAppMetrics")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "app-metrics")] HttpRequestData req)
{
try
{
// Validate authentication
if (!TenantHelper.IsAuthenticated(req))
{
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new { success = false, message = "Authentication required." });
return unauthorizedResponse;
}
var tenantId = TenantHelper.GetTenantId(req);
_logger.LogInformation($"Fetching app metrics for tenant {tenantId}");
var summaries = await _storageService.GetAppInstallSummariesByTenantAsync(tenantId);
// Aggregate: slowest apps (avg duration) and top failing apps
var appGroups = summaries.GroupBy(s => s.AppName).Select(g =>
{
var completed = g.Where(s => s.Status == "Succeeded").ToList();
var failed = g.Where(s => s.Status == "Failed").ToList();
var total = g.Count();
return new
{
appName = g.Key,
totalInstalls = total,
succeeded = completed.Count,
failed = failed.Count,
failureRate = total > 0 ? Math.Round((double)failed.Count / total * 100, 1) : 0,
avgDurationSeconds = completed.Count > 0 ? Math.Round(completed.Average(s => s.DurationSeconds), 0) : 0,
maxDurationSeconds = completed.Count > 0 ? completed.Max(s => s.DurationSeconds) : 0,
avgDownloadBytes = completed.Count > 0 ? (long)completed.Average(s => s.DownloadBytes) : 0,
topFailureCodes = failed
.Where(f => !string.IsNullOrEmpty(f.FailureCode))
.GroupBy(f => f.FailureCode)
.OrderByDescending(fc => fc.Count())
.Take(3)
.Select(fc => new { code = fc.Key, count = fc.Count() })
};
}).ToList();
var slowestApps = appGroups
.Where(a => a.avgDurationSeconds > 0)
.OrderByDescending(a => a.avgDurationSeconds)
.Take(10)
.ToList();
var topFailingApps = appGroups
.Where(a => a.failed > 0)
.OrderByDescending(a => a.failed)
.ThenByDescending(a => a.failureRate)
.Take(10)
.ToList();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
totalApps = appGroups.Count,
totalInstalls = summaries.Count,
slowestApps,
topFailingApps
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching app metrics");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return errorResponse;
}
}
}
}
@@ -0,0 +1,73 @@
using System.Net;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
/// <summary>
/// Public endpoint - no authentication required.
/// Returns pre-computed platform stats for the landing page.
/// </summary>
public class GetPlatformStatsFunction
{
private readonly ILogger<GetPlatformStatsFunction> _logger;
private readonly TableStorageService _storageService;
public GetPlatformStatsFunction(ILogger<GetPlatformStatsFunction> logger, TableStorageService storageService)
{
_logger = logger;
_storageService = storageService;
}
[Function("GetPlatformStats")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "platform-stats")] HttpRequestData req)
{
try
{
var stats = await _storageService.GetPlatformStatsAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
if (stats == null)
{
// No stats computed yet - return zeros
await response.WriteAsJsonAsync(new
{
totalEnrollments = 0,
totalUsers = 0,
totalTenants = 0,
uniqueDeviceModels = 0,
totalEventsProcessed = 0,
successfulEnrollments = 0,
issuesDetected = 0
});
}
else
{
await response.WriteAsJsonAsync(new
{
totalEnrollments = stats.TotalEnrollments,
totalUsers = stats.TotalUsers,
totalTenants = stats.TotalTenants,
uniqueDeviceModels = stats.UniqueDeviceModels,
totalEventsProcessed = stats.TotalEventsProcessed,
successfulEnrollments = stats.SuccessfulEnrollments,
issuesDetected = stats.IssuesDetected
});
}
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching platform stats");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { error = "Failed to retrieve platform stats" });
return errorResponse;
}
}
}
}
@@ -110,6 +110,9 @@ namespace AutopilotMonitor.Functions.Functions
EnrollmentEvent? completionEvent = null;
EnrollmentEvent? failureEvent = null;
// Track app install events for AppInstallSummary aggregation
var appInstallUpdates = new Dictionary<string, AppInstallSummary>(StringComparer.OrdinalIgnoreCase);
foreach (var evt in request.Events)
{
var stored = await _storageService.StoreEventAsync(evt);
@@ -132,6 +135,9 @@ namespace AutopilotMonitor.Functions.Functions
{
failureEvent = evt;
}
// Track app install events for per-app metrics
AggregateAppInstallEvent(evt, request.TenantId, request.SessionId, appInstallUpdates);
}
else
{
@@ -139,6 +145,12 @@ namespace AutopilotMonitor.Functions.Functions
}
}
// Store app install summaries
foreach (var summary in appInstallUpdates.Values)
{
await _storageService.StoreAppInstallSummaryAsync(summary);
}
// Update session status based on events
if (completionEvent != null)
{
@@ -203,6 +215,13 @@ namespace AutopilotMonitor.Functions.Functions
}
}
// Increment platform stats (fire-and-forget, non-blocking)
_ = _storageService.IncrementPlatformStatAsync("TotalEventsProcessed", processedCount);
if (newRuleResults.Count > 0)
_ = _storageService.IncrementPlatformStatAsync("IssuesDetected", newRuleResults.Count);
if (completionEvent != null)
_ = _storageService.IncrementPlatformStatAsync("SuccessfulEnrollments");
// Retrieve updated session data to include in SignalR messages
var updatedSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId);
@@ -344,6 +363,80 @@ namespace AutopilotMonitor.Functions.Functions
};
}
/// <summary>
/// Aggregates app install events into AppInstallSummary records
/// </summary>
private void AggregateAppInstallEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary<string, AppInstallSummary> summaries)
{
string? appName = null;
if (evt.EventType == "app_install_start" || evt.EventType == "app_install_complete" ||
evt.EventType == "app_install_failed" || evt.EventType == "download_progress")
{
appName = evt.Data?.ContainsKey("appName") == true ? evt.Data["appName"]?.ToString() : null;
if (string.IsNullOrEmpty(appName)) return;
}
else
{
return;
}
if (!summaries.TryGetValue(appName, out var summary))
{
summary = new AppInstallSummary
{
AppName = appName,
SessionId = sessionId,
TenantId = tenantId,
StartedAt = evt.Timestamp
};
summaries[appName] = summary;
}
switch (evt.EventType)
{
case "app_install_start":
summary.Status = "InProgress";
summary.StartedAt = evt.Timestamp;
break;
case "app_install_complete":
summary.Status = "Succeeded";
summary.CompletedAt = evt.Timestamp;
if (summary.StartedAt != DateTime.MinValue)
{
summary.DurationSeconds = (int)(evt.Timestamp - summary.StartedAt).TotalSeconds;
}
break;
case "app_install_failed":
summary.Status = "Failed";
summary.CompletedAt = evt.Timestamp;
if (summary.StartedAt != DateTime.MinValue)
{
summary.DurationSeconds = (int)(evt.Timestamp - summary.StartedAt).TotalSeconds;
}
summary.FailureCode = evt.Data?.ContainsKey("errorCode") == true
? evt.Data["errorCode"]?.ToString() ?? string.Empty : string.Empty;
summary.FailureMessage = evt.Data?.ContainsKey("errorMessage") == true
? evt.Data["errorMessage"]?.ToString() ?? string.Empty : evt.Message ?? string.Empty;
break;
case "download_progress":
if (evt.Data?.ContainsKey("bytes_downloaded") == true &&
long.TryParse(evt.Data["bytes_downloaded"]?.ToString(), out var bytes))
{
summary.DownloadBytes = Math.Max(summary.DownloadBytes, bytes);
}
if (evt.Data?.ContainsKey("download_duration_seconds") == true &&
int.TryParse(evt.Data["download_duration_seconds"]?.ToString(), out var dlDuration))
{
summary.DownloadDurationSeconds = Math.Max(summary.DownloadDurationSeconds, dlDuration);
}
break;
}
}
private async Task<IngestEventsOutput> CreateErrorResponse(HttpRequestData req, HttpStatusCode statusCode, string message)
{
var response = req.CreateResponse(statusCode);
@@ -73,6 +73,9 @@ namespace AutopilotMonitor.Functions.Functions
return new RegisterSessionOutput { HttpResponse = errorResponse };
}
// Increment platform stats (fire-and-forget, non-blocking)
_ = _storageService.IncrementPlatformStatAsync("TotalEnrollments");
// Retrieve the stored session to include full data in SignalR message
var session = await _storageService.GetSessionAsync(registration.TenantId, registration.SessionId);
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
@@ -29,11 +30,9 @@ namespace AutopilotMonitor.Functions.Services
var connectionString = configuration["AzureWebJobsStorage"];
var serviceClient = new TableServiceClient(connectionString);
_adminTableClient = serviceClient.GetTableClient("AdminConfiguration");
_adminTableClient.CreateIfNotExists();
_tenantConfigTableClient = serviceClient.GetTableClient("TenantConfiguration");
_tenantConfigTableClient.CreateIfNotExists();
_adminTableClient = serviceClient.GetTableClient(Constants.TableNames.AdminConfiguration);
_tenantConfigTableClient = serviceClient.GetTableClient(Constants.TableNames.TenantConfiguration);
// Tables are initialized centrally by TableInitializerService at startup
}
/// <summary>
@@ -109,6 +109,17 @@ namespace AutopilotMonitor.Functions.Services
return await _storageService.StoreAnalyzeRuleAsync(rule, tenantId);
}
/// <summary>
/// Updates a built-in analyze rule globally (Galactic Admin only)
/// Modifies the global definition that all tenants inherit
/// </summary>
public async Task<bool> UpdateGlobalRuleAsync(AnalyzeRule rule)
{
rule.UpdatedAt = DateTime.UtcNow;
rule.IsBuiltIn = true;
return await _storageService.StoreAnalyzeRuleAsync(rule, "global");
}
/// <summary>
/// Deletes a custom analyze rule (cannot delete built-in rules)
/// </summary>
@@ -1,3 +1,4 @@
using AutopilotMonitor.Shared;
using Azure.Data.Tables;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
@@ -14,7 +15,6 @@ public class GalacticAdminService
private readonly TableServiceClient _tableServiceClient;
private readonly IMemoryCache _cache;
private readonly ILogger<GalacticAdminService> _logger;
private readonly string _tableName = "GalacticAdmins";
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);
public GalacticAdminService(
@@ -26,22 +26,7 @@ public class GalacticAdminService
_logger = logger;
var connectionString = configuration["AzureTableStorageConnectionString"];
_tableServiceClient = new TableServiceClient(connectionString);
// Ensure table exists
InitializeTableAsync().GetAwaiter().GetResult();
}
private async Task InitializeTableAsync()
{
try
{
await _tableServiceClient.CreateTableIfNotExistsAsync(_tableName);
_logger.LogInformation($"Galactic Admins table initialized: {_tableName}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to initialize Galactic Admins table: {_tableName}");
}
// Table is initialized centrally by TableInitializerService at startup
}
/// <summary>
@@ -90,7 +75,7 @@ public class GalacticAdminService
{
try
{
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GalacticAdmins);
var normalizedUpn = upn.ToLowerInvariant();
_logger.LogDebug($"Querying Table Storage - PartitionKey: 'GalacticAdmins', RowKey: '{normalizedUpn}'");
@@ -137,7 +122,7 @@ public class GalacticAdminService
AddedBy = addedBy
};
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GalacticAdmins);
await tableClient.UpsertEntityAsync(entity);
// Invalidate cache
@@ -153,7 +138,7 @@ public class GalacticAdminService
{
upn = upn.ToLowerInvariant();
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GalacticAdmins);
await tableClient.DeleteEntityAsync("GalacticAdmins", upn);
// Invalidate cache
@@ -172,7 +157,7 @@ public class GalacticAdminService
{
admin.IsEnabled = false;
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GalacticAdmins);
await tableClient.UpdateEntityAsync(admin, Azure.ETag.All);
// Invalidate cache
@@ -185,7 +170,7 @@ public class GalacticAdminService
/// </summary>
public async Task<List<GalacticAdminEntity>> GetAllGalacticAdminsAsync()
{
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GalacticAdmins);
var admins = new List<GalacticAdminEntity>();
await foreach (var entity in tableClient.QueryAsync<GalacticAdminEntity>(
@@ -119,6 +119,17 @@ namespace AutopilotMonitor.Functions.Services
return await _storageService.StoreGatherRuleAsync(rule, tenantId);
}
/// <summary>
/// Updates a built-in gather rule globally (Galactic Admin only)
/// Modifies the global definition that all tenants inherit
/// </summary>
public async Task<bool> UpdateGlobalRuleAsync(GatherRule rule)
{
rule.UpdatedAt = DateTime.UtcNow;
rule.IsBuiltIn = true;
return await _storageService.StoreGatherRuleAsync(rule, "global");
}
/// <summary>
/// Deletes a custom gather rule (cannot delete built-in rules)
/// </summary>
@@ -0,0 +1,44 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Hosted service that initializes all Azure Table Storage tables at application startup.
/// This ensures all tables exist before any requests are processed.
/// </summary>
public class TableInitializerService : IHostedService
{
private readonly TableStorageService _tableStorageService;
private readonly ILogger<TableInitializerService> _logger;
public TableInitializerService(
TableStorageService tableStorageService,
ILogger<TableInitializerService> logger)
{
_tableStorageService = tableStorageService;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("TableInitializerService starting - initializing all Azure Table Storage tables");
try
{
await _tableStorageService.InitializeTablesAsync();
_logger.LogInformation("TableInitializerService completed - all tables ready");
}
catch (Exception ex)
{
_logger.LogError(ex, "TableInitializerService failed to initialize tables");
// Don't throw - allow the application to start even if table creation fails
// Individual operations will fail gracefully with appropriate error messages
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
@@ -1,5 +1,6 @@
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
@@ -14,45 +15,68 @@ namespace AutopilotMonitor.Functions.Services
{
private readonly TableServiceClient _tableServiceClient;
private readonly ILogger<TableStorageService> _logger;
private const string SessionsTableName = "Sessions";
private const string EventsTableName = "Events";
private const string AuditLogsTableName = "AuditLogs";
private const string UsageMetricsTableName = "UsageMetrics";
private const string RuleResultsTableName = "RuleResults";
private const string GatherRulesTableName = "GatherRules";
private const string AnalyzeRulesTableName = "AnalyzeRules";
private const string UserActivityTableName = "UserActivity";
private bool _tablesInitialized = false;
private readonly object _initLock = new object();
public TableStorageService(IConfiguration configuration, ILogger<TableStorageService> logger)
{
_logger = logger;
var connectionString = configuration["AzureTableStorageConnectionString"];
_tableServiceClient = new TableServiceClient(connectionString);
// Ensure tables exist
InitializeTablesAsync().GetAwaiter().GetResult();
}
private async Task InitializeTablesAsync()
/// <summary>
/// Initializes all Azure Table Storage tables.
/// This method is idempotent and safe to call multiple times.
/// Should be called at application startup.
/// </summary>
public async Task InitializeTablesAsync()
{
try
if (_tablesInitialized)
{
await _tableServiceClient.CreateTableIfNotExistsAsync(SessionsTableName);
await _tableServiceClient.CreateTableIfNotExistsAsync(EventsTableName);
await _tableServiceClient.CreateTableIfNotExistsAsync(AuditLogsTableName);
await _tableServiceClient.CreateTableIfNotExistsAsync(UsageMetricsTableName);
await _tableServiceClient.CreateTableIfNotExistsAsync(RuleResultsTableName);
await _tableServiceClient.CreateTableIfNotExistsAsync(GatherRulesTableName);
await _tableServiceClient.CreateTableIfNotExistsAsync(AnalyzeRulesTableName);
await _tableServiceClient.CreateTableIfNotExistsAsync(UserActivityTableName);
_logger.LogInformation("Tables initialized successfully");
_logger.LogDebug("Tables already initialized, skipping");
return;
}
catch (Exception ex)
lock (_initLock)
{
_logger.LogError(ex, "Failed to initialize tables");
if (_tablesInitialized) return;
}
_logger.LogInformation("Initializing Azure Table Storage tables...");
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var successCount = 0;
var failCount = 0;
foreach (var tableName in Constants.TableNames.All)
{
try
{
await _tableServiceClient.CreateTableIfNotExistsAsync(tableName);
_logger.LogDebug($"Table '{tableName}' initialized");
successCount++;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to initialize table '{tableName}'");
failCount++;
}
}
stopwatch.Stop();
_logger.LogInformation($"Table initialization completed in {stopwatch.ElapsedMilliseconds}ms: {successCount} succeeded, {failCount} failed");
lock (_initLock)
{
_tablesInitialized = failCount == 0;
}
}
/// <summary>
/// Gets the TableServiceClient for direct access (used by other services)
/// </summary>
public TableServiceClient GetTableServiceClient() => _tableServiceClient;
/// <summary>
/// Stores a session registration
/// </summary>
@@ -60,7 +84,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var entity = new TableEntity(registration.TenantId, registration.SessionId)
{
@@ -100,7 +124,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(EventsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events);
// PartitionKey: TenantId_SessionId for efficient querying
// RowKey: Timestamp_Sequence for ordering
@@ -144,7 +168,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var sessions = new List<SessionSummary>();
// Query sessions by tenant (PartitionKey)
@@ -175,7 +199,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var sessions = new List<SessionSummary>();
// Query all sessions without tenant filter
@@ -204,7 +228,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
return MapToSessionSummary(entity.Value);
}
@@ -231,7 +255,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
// Get the existing entity
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
@@ -259,7 +283,7 @@ namespace AutopilotMonitor.Functions.Services
}
// Update event count (calculated on-demand during phase changes)
var eventsTableClient = _tableServiceClient.GetTableClient(EventsTableName);
var eventsTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events);
var partitionKey = $"{tenantId}_{sessionId}";
var eventCountFilter = $"PartitionKey eq '{partitionKey}'";
var eventCount = 0;
@@ -305,7 +329,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(EventsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events);
var events = new List<EnrollmentEvent>();
// Events are stored with PartitionKey = "{TenantId}_{SessionId}"
@@ -398,7 +422,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var response = await tableClient.DeleteEntityAsync(tenantId, sessionId);
_logger.LogInformation($"Deleted session {sessionId} for tenant {tenantId}");
return true;
@@ -417,7 +441,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(EventsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events);
// Query all events for this session using the same PartitionKey format as StoreEventAsync
var partitionKey = $"{tenantId}_{sessionId}";
@@ -448,7 +472,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(AuditLogsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
var timestamp = DateTime.UtcNow;
var entity = new TableEntity(tenantId, Guid.NewGuid().ToString())
@@ -479,7 +503,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(AuditLogsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
var logs = new List<AuditLogEntry>();
@@ -519,7 +543,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(UsageMetricsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics);
var entity = new TableEntity(metrics.Date, metrics.TenantId)
{
@@ -559,7 +583,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(UsageMetricsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics);
// Build filter
var filters = new List<string>();
@@ -622,7 +646,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
// Query sessions for this tenant older than cutoff date
var filter = $"PartitionKey eq '{tenantId}' and StartedAt lt datetime'{cutoffDate:yyyy-MM-ddTHH:mm:ss}Z'";
@@ -651,7 +675,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(SessionsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var query = tableClient.QueryAsync<TableEntity>(select: new[] { "PartitionKey" });
var tenantIds = new HashSet<string>();
@@ -680,7 +704,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(RuleResultsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults);
var partitionKey = $"{result.TenantId}_{result.SessionId}";
var entity = new TableEntity(partitionKey, result.RuleId)
@@ -718,7 +742,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(RuleResultsTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults);
var partitionKey = $"{tenantId}_{sessionId}";
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
@@ -762,7 +786,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(GatherRulesTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules);
var entity = new TableEntity(tenantId, rule.RuleId)
{
@@ -806,7 +830,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(GatherRulesTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
var rules = new List<GatherRule>();
@@ -831,7 +855,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(GatherRulesTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules);
await tableClient.DeleteEntityAsync(tenantId, ruleId);
return true;
}
@@ -880,7 +904,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(AnalyzeRulesTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules);
var entity = new TableEntity(tenantId, rule.RuleId)
{
@@ -923,7 +947,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(AnalyzeRulesTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
var rules = new List<AnalyzeRule>();
@@ -948,7 +972,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(AnalyzeRulesTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules);
await tableClient.DeleteEntityAsync(tenantId, ruleId);
return true;
}
@@ -986,6 +1010,256 @@ namespace AutopilotMonitor.Functions.Services
};
}
// ===== APP INSTALL SUMMARIES METHODS =====
/// <summary>
/// Stores or updates an app install summary
/// PartitionKey: TenantId, RowKey: {SessionId}_{AppName}
/// </summary>
public async Task<bool> StoreAppInstallSummaryAsync(AppInstallSummary summary)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries);
var rowKey = $"{summary.SessionId}_{summary.AppName}";
var entity = new TableEntity(summary.TenantId, rowKey)
{
["AppName"] = summary.AppName ?? string.Empty,
["SessionId"] = summary.SessionId ?? string.Empty,
["TenantId"] = summary.TenantId ?? string.Empty,
["Status"] = summary.Status ?? "InProgress",
["DurationSeconds"] = summary.DurationSeconds,
["DownloadBytes"] = summary.DownloadBytes,
["DownloadDurationSeconds"] = summary.DownloadDurationSeconds,
["FailureCode"] = summary.FailureCode ?? string.Empty,
["FailureMessage"] = summary.FailureMessage ?? string.Empty,
["StartedAt"] = summary.StartedAt,
["CompletedAt"] = summary.CompletedAt
};
await tableClient.UpsertEntityAsync(entity);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store app install summary for {summary.AppName}");
return false;
}
}
/// <summary>
/// Gets all app install summaries for a tenant (fleet-level metrics)
/// </summary>
public async Task<List<AppInstallSummary>> GetAppInstallSummariesByTenantAsync(string tenantId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
var summaries = new List<AppInstallSummary>();
await foreach (var entity in query)
{
summaries.Add(MapToAppInstallSummary(entity));
}
return summaries;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get app install summaries for tenant {tenantId}");
return new List<AppInstallSummary>();
}
}
/// <summary>
/// Deletes all app install summaries for a session
/// </summary>
public async Task<int> DeleteSessionAppInstallSummariesAsync(string tenantId, string sessionId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries);
var query = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{tenantId}' and SessionId eq '{sessionId}'");
int deletedCount = 0;
await foreach (var entity in query)
{
await tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey);
deletedCount++;
}
return deletedCount;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete app install summaries for session {sessionId}");
return 0;
}
}
/// <summary>
/// Deletes all rule results for a session
/// </summary>
public async Task<int> DeleteSessionRuleResultsAsync(string tenantId, string sessionId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults);
var partitionKey = $"{tenantId}_{sessionId}";
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
int deletedCount = 0;
await foreach (var entity in query)
{
await tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey);
deletedCount++;
}
return deletedCount;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete rule results for session {sessionId}");
return 0;
}
}
private AppInstallSummary MapToAppInstallSummary(TableEntity entity)
{
return new AppInstallSummary
{
AppName = entity.GetString("AppName") ?? string.Empty,
SessionId = entity.GetString("SessionId") ?? string.Empty,
TenantId = entity.GetString("TenantId") ?? entity.PartitionKey,
Status = entity.GetString("Status") ?? "InProgress",
DurationSeconds = entity.GetInt32("DurationSeconds") ?? 0,
DownloadBytes = entity.GetInt64("DownloadBytes") ?? 0,
DownloadDurationSeconds = entity.GetInt32("DownloadDurationSeconds") ?? 0,
FailureCode = entity.GetString("FailureCode") ?? string.Empty,
FailureMessage = entity.GetString("FailureMessage") ?? string.Empty,
StartedAt = entity.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MinValue,
CompletedAt = entity.GetDateTimeOffset("CompletedAt")?.UtcDateTime
};
}
// ===== PLATFORM STATS METHODS =====
/// <summary>
/// Gets the current platform stats (single row: global/current)
/// </summary>
public async Task<PlatformStats?> GetPlatformStatsAsync()
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PlatformStats);
var response = await tableClient.GetEntityAsync<TableEntity>("global", "current");
var entity = response.Value;
return new PlatformStats
{
TotalEnrollments = entity.GetInt64("TotalEnrollments") ?? 0,
TotalUsers = entity.GetInt64("TotalUsers") ?? 0,
TotalTenants = entity.GetInt64("TotalTenants") ?? 0,
UniqueDeviceModels = entity.GetInt64("UniqueDeviceModels") ?? 0,
TotalEventsProcessed = entity.GetInt64("TotalEventsProcessed") ?? 0,
SuccessfulEnrollments = entity.GetInt64("SuccessfulEnrollments") ?? 0,
IssuesDetected = entity.GetInt64("IssuesDetected") ?? 0,
LastFullCompute = entity.GetDateTimeOffset("LastFullCompute")?.UtcDateTime ?? DateTime.MinValue,
LastUpdated = entity.GetDateTimeOffset("LastUpdated")?.UtcDateTime ?? DateTime.MinValue
};
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get platform stats");
return null;
}
}
/// <summary>
/// Saves the full platform stats (upsert)
/// </summary>
public async Task<bool> SavePlatformStatsAsync(PlatformStats stats)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PlatformStats);
var entity = new TableEntity("global", "current")
{
["TotalEnrollments"] = stats.TotalEnrollments,
["TotalUsers"] = stats.TotalUsers,
["TotalTenants"] = stats.TotalTenants,
["UniqueDeviceModels"] = stats.UniqueDeviceModels,
["TotalEventsProcessed"] = stats.TotalEventsProcessed,
["SuccessfulEnrollments"] = stats.SuccessfulEnrollments,
["IssuesDetected"] = stats.IssuesDetected,
["LastFullCompute"] = stats.LastFullCompute,
["LastUpdated"] = stats.LastUpdated
};
await tableClient.UpsertEntityAsync(entity);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save platform stats");
return false;
}
}
/// <summary>
/// Increments a specific platform stat counter atomically.
/// Reads current value, increments, and writes back.
/// </summary>
public async Task IncrementPlatformStatAsync(string field, long amount = 1)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PlatformStats);
TableEntity entity;
try
{
var response = await tableClient.GetEntityAsync<TableEntity>("global", "current");
entity = response.Value;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
entity = new TableEntity("global", "current")
{
["TotalEnrollments"] = 0L,
["TotalUsers"] = 0L,
["TotalTenants"] = 0L,
["UniqueDeviceModels"] = 0L,
["TotalEventsProcessed"] = 0L,
["SuccessfulEnrollments"] = 0L,
["IssuesDetected"] = 0L,
["LastFullCompute"] = DateTime.MinValue,
["LastUpdated"] = DateTime.UtcNow
};
}
var current = entity.GetInt64(field) ?? 0;
entity[field] = current + amount;
entity["LastUpdated"] = DateTime.UtcNow;
await tableClient.UpsertEntityAsync(entity);
}
catch (Exception ex)
{
// Non-fatal: don't break the caller if stats update fails
_logger.LogWarning(ex, $"Failed to increment platform stat {field}");
}
}
// ===== USER ACTIVITY METHODS =====
/// <summary>
@@ -996,7 +1270,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(UserActivityTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
var now = DateTime.UtcNow;
var invertedTicks = (DateTime.MaxValue.Ticks - now.Ticks).ToString("D20");
@@ -1025,7 +1299,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(UserActivityTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
var now = DateTime.UtcNow;
@@ -1079,7 +1353,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(UserActivityTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
var query = tableClient.QueryAsync<TableEntity>();
var now = DateTime.UtcNow;
@@ -1133,7 +1407,7 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
var tableClient = _tableServiceClient.GetTableClient(UserActivityTableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
var startOfDay = date.Date;
var endOfDay = startOfDay.AddDays(1);
@@ -1,3 +1,4 @@
using AutopilotMonitor.Shared;
using Azure.Data.Tables;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
@@ -14,7 +15,6 @@ public class TenantAdminsService
private readonly TableServiceClient _tableServiceClient;
private readonly IMemoryCache _cache;
private readonly ILogger<TenantAdminsService> _logger;
private readonly string _tableName = "TenantAdmins";
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);
public TenantAdminsService(
@@ -26,22 +26,7 @@ public class TenantAdminsService
_logger = logger;
var connectionString = configuration["AzureTableStorageConnectionString"];
_tableServiceClient = new TableServiceClient(connectionString);
// Ensure table exists
InitializeTableAsync().GetAwaiter().GetResult();
}
private async Task InitializeTableAsync()
{
try
{
await _tableServiceClient.CreateTableIfNotExistsAsync(_tableName);
_logger.LogInformation($"Tenant Admins table initialized: {_tableName}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to initialize Tenant Admins table: {_tableName}");
}
// Table is initialized centrally by TableInitializerService at startup
}
/// <summary>
@@ -92,7 +77,7 @@ public class TenantAdminsService
{
try
{
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins);
var normalizedTenantId = tenantId.ToLowerInvariant();
var normalizedUpn = upn.ToLowerInvariant();
@@ -143,7 +128,7 @@ public class TenantAdminsService
AddedBy = addedBy
};
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins);
await tableClient.UpsertEntityAsync(entity);
// Invalidate cache
@@ -162,7 +147,7 @@ public class TenantAdminsService
tenantId = tenantId.ToLowerInvariant();
upn = upn.ToLowerInvariant();
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins);
await tableClient.DeleteEntityAsync(tenantId, upn);
// Invalidate cache
@@ -184,7 +169,7 @@ public class TenantAdminsService
{
admin.IsEnabled = false;
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins);
await tableClient.UpdateEntityAsync(admin, Azure.ETag.All);
// Invalidate cache
@@ -207,7 +192,7 @@ public class TenantAdminsService
{
admin.IsEnabled = true;
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins);
await tableClient.UpdateEntityAsync(admin, Azure.ETag.All);
// Invalidate cache
@@ -223,7 +208,7 @@ public class TenantAdminsService
public async Task<List<TenantAdminEntity>> GetTenantAdminsAsync(string tenantId)
{
tenantId = tenantId.ToLowerInvariant();
var tableClient = _tableServiceClient.GetTableClient(_tableName);
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins);
var admins = new List<TenantAdminEntity>();
await foreach (var entity in tableClient.QueryAsync<TenantAdminEntity>(
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
@@ -29,8 +30,8 @@ namespace AutopilotMonitor.Functions.Services
var connectionString = configuration["AzureWebJobsStorage"];
var serviceClient = new TableServiceClient(connectionString);
_tableClient = serviceClient.GetTableClient("TenantConfiguration");
_tableClient.CreateIfNotExists();
_tableClient = serviceClient.GetTableClient(Constants.TableNames.TenantConfiguration);
// Table is initialized centrally by TableInitializerService at startup
}
/// <summary>
@@ -184,13 +184,26 @@ namespace AutopilotMonitor.Functions.Services
.ToList()
};
// Platform Stats (cumulative since release)
var platformStats = await _storageService.GetPlatformStatsAsync();
return new PlatformUsageMetrics
{
Sessions = sessionMetrics,
Tenants = tenantMetrics,
Users = userMetrics,
Performance = performanceMetrics,
Hardware = hardwareMetrics
Hardware = hardwareMetrics,
PlatformStats = platformStats != null ? new PlatformStats
{
TotalEnrollments = platformStats.TotalEnrollments,
TotalUsers = platformStats.TotalUsers,
TotalTenants = platformStats.TotalTenants,
UniqueDeviceModels = platformStats.UniqueDeviceModels,
TotalEventsProcessed = platformStats.TotalEventsProcessed,
SuccessfulEnrollments = platformStats.SuccessfulEnrollments,
IssuesDetected = platformStats.IssuesDetected
} : null
};
}
@@ -97,14 +97,54 @@ namespace AutopilotMonitor.Shared
/// <summary>
/// Azure Table Storage table names
/// All table names are defined here centrally and initialized at application startup
/// </summary>
public static class TableNames
{
public const string Sessions = "sessions";
public const string Events = "events";
public const string RuleResults = "ruleresults";
public const string GatherRules = "gatherrules";
public const string AnalyzeRules = "analyzerules";
// Core data tables
public const string Sessions = "Sessions";
public const string Events = "Events";
public const string AuditLogs = "AuditLogs";
public const string UsageMetrics = "UsageMetrics";
public const string UserActivity = "UserActivity";
// Rules engine tables
public const string RuleResults = "RuleResults";
public const string GatherRules = "GatherRules";
public const string AnalyzeRules = "AnalyzeRules";
// App metrics tables
public const string AppInstallSummaries = "AppInstallSummaries";
public const string PlatformStats = "PlatformStats";
// Configuration tables
public const string TenantConfiguration = "TenantConfiguration";
public const string AdminConfiguration = "AdminConfiguration";
// Admin tables
public const string GalacticAdmins = "GalacticAdmins";
public const string TenantAdmins = "TenantAdmins";
/// <summary>
/// Returns all table names for initialization
/// </summary>
public static string[] All => new[]
{
Sessions,
Events,
AuditLogs,
UsageMetrics,
UserActivity,
RuleResults,
GatherRules,
AnalyzeRules,
AppInstallSummaries,
PlatformStats,
TenantConfiguration,
AdminConfiguration,
GalacticAdmins,
TenantAdmins
};
}
/// <summary>
@@ -0,0 +1,39 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Per-app installation summary, written during event ingestion.
/// Enables fleet-level app metrics without scanning raw events.
/// </summary>
public class AppInstallSummary
{
public string AppName { get; set; } = string.Empty;
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
/// <summary>Succeeded, Failed, InProgress</summary>
public string Status { get; set; } = "InProgress";
/// <summary>Total installation duration in seconds (from start to complete/failed)</summary>
public int DurationSeconds { get; set; }
/// <summary>Total download size in bytes</summary>
public long DownloadBytes { get; set; }
/// <summary>Download duration in seconds</summary>
public int DownloadDurationSeconds { get; set; }
/// <summary>Error code if failed</summary>
public string FailureCode { get; set; } = string.Empty;
/// <summary>Error message if failed</summary>
public string FailureMessage { get; set; } = string.Empty;
/// <summary>When this app install started</summary>
public DateTime StartedAt { get; set; }
/// <summary>When this app install completed or failed</summary>
public DateTime? CompletedAt { get; set; }
}
}
@@ -0,0 +1,39 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Pre-computed platform-wide statistics for the public landing page.
/// Stored as a single row (PartitionKey: "global", RowKey: "current").
/// Recomputed during daily maintenance; incremented during registration/login.
/// </summary>
public class PlatformStats
{
/// <summary>Total enrollment sessions monitored since launch</summary>
public long TotalEnrollments { get; set; }
/// <summary>Total unique users who logged in</summary>
public long TotalUsers { get; set; }
/// <summary>Total unique tenants using the platform</summary>
public long TotalTenants { get; set; }
/// <summary>Total unique device models seen (manufacturer + model)</summary>
public long UniqueDeviceModels { get; set; }
/// <summary>Total events processed across all tenants</summary>
public long TotalEventsProcessed { get; set; }
/// <summary>Total successful enrollments</summary>
public long SuccessfulEnrollments { get; set; }
/// <summary>Total analysis issues detected</summary>
public long IssuesDetected { get; set; }
/// <summary>When these stats were last fully recomputed</summary>
public DateTime LastFullCompute { get; set; }
/// <summary>When these stats were last updated (including incremental)</summary>
public DateTime LastUpdated { get; set; }
}
}
@@ -33,6 +33,11 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public HardwareMetrics Hardware { get; set; } = new();
/// <summary>
/// Platform statistics (cumulative since release)
/// </summary>
public PlatformStats? PlatformStats { get; set; }
/// <summary>
/// When these metrics were computed
/// </summary>
File diff suppressed because it is too large Load Diff
@@ -23,9 +23,30 @@ interface Session {
failureReason?: string;
}
interface AppMetric {
appName: string;
totalInstalls: number;
succeeded: number;
failed: number;
failureRate: number;
avgDurationSeconds: number;
maxDurationSeconds: number;
avgDownloadBytes: number;
topFailureCodes: { code: string; count: number }[];
}
interface AppMetricsResponse {
success: boolean;
totalApps: number;
totalInstalls: number;
slowestApps: AppMetric[];
topFailingApps: AppMetric[];
}
export default function FleetHealthPage() {
const router = useRouter();
const [sessions, setSessions] = useState<Session[]>([]);
const [appMetrics, setAppMetrics] = useState<AppMetricsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("7d");
@@ -47,6 +68,7 @@ export default function FleetHealthPage() {
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
fetchSessions();
fetchAppMetrics();
}, []);
useEffect(() => {
@@ -122,6 +144,23 @@ export default function FleetHealthPage() {
}
};
const fetchAppMetrics = async () => {
try {
const token = await getAccessToken();
if (!token) return;
const response = await fetch(
`${API_BASE_URL}/api/app-metrics?tenantId=${tenantId}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (response.ok) {
const data = await response.json();
setAppMetrics(data);
}
} catch (error) {
console.error("Failed to fetch app metrics:", error);
}
};
// Filter sessions by time range
const filteredSessions = useMemo(() => {
const now = new Date();
@@ -233,7 +272,7 @@ export default function FleetHealthPage() {
.slice(0, 6);
}, [filteredSessions]);
// Slowest apps - derive from session durations by model (proxy since we don't have per-app data at fleet level)
// Slowest models by avg enrollment duration
const slowestModels = useMemo(() => {
const models: Record<string, { totalDuration: number; count: number; model: string }> = {};
filteredSessions
@@ -255,6 +294,27 @@ export default function FleetHealthPage() {
.slice(0, 5);
}, [filteredSessions]);
// Top failing models - models with most failures
const topFailingModels = useMemo(() => {
const models: Record<string, { failed: number; total: number; model: string }> = {};
filteredSessions.forEach((s) => {
const key = `${s.manufacturer} ${s.model}`.trim() || "Unknown";
if (!models[key]) models[key] = { failed: 0, total: 0, model: key };
models[key].total++;
if (s.status === "Failed") models[key].failed++;
});
return Object.values(models)
.filter((m) => m.failed > 0)
.map((m) => ({
model: m.model,
failed: m.failed,
total: m.total,
failureRate: Math.round((m.failed / m.total) * 100),
}))
.sort((a, b) => b.failed - a.failed)
.slice(0, 5);
}, [filteredSessions]);
const maxDaily = useMemo(() => {
return Math.max(
1,
@@ -467,8 +527,8 @@ export default function FleetHealthPage() {
)}
</div>
{/* Two column: Failure Reasons + Slowest Models */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* Three column: Failure Reasons + Slowest Models + Top Failing Models */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* Top Failure Reasons */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
@@ -512,11 +572,11 @@ export default function FleetHealthPage() {
{/* Slowest Device Models */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Avg. Duration by Model
Slowest Models
</h2>
{slowestModels.length === 0 ? (
<div className="text-center py-6 text-gray-400 text-sm">
No completed enrollments in this time range
No completed enrollments
</div>
) : (
<div className="space-y-3">
@@ -543,6 +603,164 @@ export default function FleetHealthPage() {
</div>
)}
</div>
{/* Top Failing Models */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Top Failing Models
</h2>
{topFailingModels.length === 0 ? (
<div className="text-center py-6 text-gray-400 text-sm">
No failures in this time range
</div>
) : (
<div className="space-y-3">
{topFailingModels.map((m, i) => (
<div key={m.model} className="flex items-center space-x-3">
<span className="text-xs text-gray-400 w-4">
{i + 1}.
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-700 truncate pr-2">
{m.model}
</span>
<span className="text-sm font-medium text-red-600 flex-shrink-0">
{m.failed} failed
</span>
</div>
<div className="flex items-center space-x-2">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-red-400 rounded-full"
style={{ width: `${m.failureRate}%` }}
/>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">
{m.failureRate}%
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Slowest Apps + Top Failing Apps */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Slowest Apps */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Slowest Apps
</h2>
{!appMetrics || appMetrics.slowestApps.length === 0 ? (
<div className="text-center py-6 text-gray-400 text-sm">
No app install data available
</div>
) : (
<div className="space-y-3">
{appMetrics.slowestApps.map((app, i) => {
const avgMinutes = Math.round(app.avgDurationSeconds / 60);
const maxMinutes = Math.round(app.maxDurationSeconds / 60);
const maxAvg = Math.max(
...appMetrics.slowestApps.map((a) => a.avgDurationSeconds)
);
return (
<div
key={app.appName}
className="flex items-center space-x-3"
>
<span className="text-xs text-gray-400 w-4">
{i + 1}.
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-700 truncate pr-2">
{app.appName}
</span>
<span className="text-sm font-medium text-gray-900 flex-shrink-0">
{avgMinutes} min avg
</span>
</div>
<div className="flex items-center space-x-2">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-amber-400 rounded-full"
style={{
width: `${
maxAvg > 0
? (app.avgDurationSeconds / maxAvg) * 100
: 0
}%`,
}}
/>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">
max {maxMinutes}m | {app.succeeded} installs
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Top Failing Apps */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Top Failing Apps
</h2>
{!appMetrics || appMetrics.topFailingApps.length === 0 ? (
<div className="text-center py-6 text-gray-400 text-sm">
No app failures recorded
</div>
) : (
<div className="space-y-3">
{appMetrics.topFailingApps.map((app, i) => (
<div
key={app.appName}
className="flex items-center space-x-3"
>
<span className="text-xs text-gray-400 w-4">
{i + 1}.
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-700 truncate pr-2">
{app.appName}
</span>
<span className="text-sm font-medium text-red-600 flex-shrink-0">
{app.failed} failed
</span>
</div>
<div className="flex items-center space-x-2">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-red-400 rounded-full"
style={{ width: `${app.failureRate}%` }}
/>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">
{app.failureRate}% of {app.totalInstalls}
</span>
</div>
{app.topFailureCodes.length > 0 && (
<div className="text-xs text-gray-400 mt-1">
{app.topFailureCodes
.map((fc) => `${fc.code} (${fc.count}x)`)
.join(", ")}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Health by Device Model */}
@@ -60,6 +60,14 @@ const CATEGORY_COLORS: Record<string, { bg: string; text: string }> = {
enrollment: { bg: "bg-indigo-100", text: "text-indigo-700" },
};
const COLLECTOR_TYPE_LABELS: Record<string, string> = {
registry: "Registry",
eventlog: "Event Log",
wmi: "WMI Query",
file: "File",
command: "Command",
};
const EMPTY_FORM: NewRuleForm = {
ruleId: "",
title: "",
@@ -75,10 +83,19 @@ const EMPTY_FORM: NewRuleForm = {
outputSeverity: "info",
};
function formatTrigger(trigger: string) {
switch (trigger) {
case "phase_change": return "Phase Change";
case "on_event": return "On Event";
default: return trigger.charAt(0).toUpperCase() + trigger.slice(1);
}
}
export default function GatherRulesPage() {
const router = useRouter();
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const { getAccessToken, user } = useAuth();
const isGalacticAdmin = user?.isGalacticAdmin ?? false;
const [rules, setRules] = useState<GatherRule[]>([]);
const [loading, setLoading] = useState(true);
@@ -90,6 +107,12 @@ export default function GatherRulesPage() {
const [categoryFilter, setCategoryFilter] = useState("all");
const [typeFilter, setTypeFilter] = useState("all");
// Expanded / editing state
const [expandedRuleId, setExpandedRuleId] = useState<string | null>(null);
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
const [editForm, setEditForm] = useState<NewRuleForm>({ ...EMPTY_FORM });
const [saving, setSaving] = useState(false);
// Create form state
const [showCreateForm, setShowCreateForm] = useState(false);
const [newRule, setNewRule] = useState<NewRuleForm>({ ...EMPTY_FORM });
@@ -215,6 +238,7 @@ export default function GatherRulesPage() {
}
setRules((prev) => prev.filter((r) => r.ruleId !== rule.ruleId));
if (expandedRuleId === rule.ruleId) setExpandedRuleId(null);
showSuccess(`Rule "${rule.title}" deleted successfully!`);
} catch (err) {
console.error("Error deleting rule:", err);
@@ -288,6 +312,92 @@ export default function GatherRulesPage() {
}
};
// Start editing a rule (Galactic Admin for built-in, or custom rules)
const startEditing = (rule: GatherRule) => {
setEditingRuleId(rule.ruleId);
setEditForm({
ruleId: rule.ruleId,
title: rule.title,
description: rule.description || "",
category: rule.category,
collectorType: rule.collectorType,
target: rule.target,
trigger: rule.trigger,
intervalSeconds: rule.intervalSeconds || 60,
triggerPhase: rule.triggerPhase || "",
triggerEventType: rule.triggerEventType || "",
outputEventType: rule.outputEventType,
outputSeverity: rule.outputSeverity,
});
};
// Save edited rule
const handleSaveEdit = async (rule: GatherRule) => {
if (!editForm.title.trim() || !editForm.target.trim() || !editForm.outputEventType.trim()) {
showError("Title, Target, and Output Event Type are required.");
return;
}
try {
setSaving(true);
setError(null);
const token = await getAccessToken();
if (!token) {
throw new Error("Failed to get access token");
}
const payload: Record<string, unknown> = {
...rule,
title: editForm.title.trim(),
description: editForm.description.trim(),
category: editForm.category,
collectorType: editForm.collectorType,
target: editForm.target.trim(),
trigger: editForm.trigger,
outputEventType: editForm.outputEventType.trim(),
outputSeverity: editForm.outputSeverity,
};
if (editForm.trigger === "interval") {
payload.intervalSeconds = editForm.intervalSeconds;
}
if (editForm.trigger === "phase_change") {
payload.triggerPhase = editForm.triggerPhase.trim();
}
if (editForm.trigger === "on_event") {
payload.triggerEventType = editForm.triggerEventType.trim();
}
// Galactic Admin editing a built-in rule = global update
const isGlobalEdit = rule.isBuiltIn && isGalacticAdmin;
const url = `${API_BASE_URL}/api/gather-rules/${encodeURIComponent(rule.ruleId)}${isGlobalEdit ? "?global=true" : ""}`;
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || errorData.message || `Failed to update rule: ${response.statusText}`);
}
setEditingRuleId(null);
showSuccess(`Rule "${editForm.title}" updated successfully${isGlobalEdit ? " (global)" : ""}!`);
await fetchRules();
} catch (err) {
console.error("Error saving rule:", err);
showError(err instanceof Error ? err.message : "Failed to save rule");
} finally {
setSaving(false);
}
};
// Filtered rules
const filteredRules = rules.filter((rule) => {
const matchesSearch =
@@ -320,6 +430,196 @@ export default function GatherRulesPage() {
);
};
// Shared form fields component (used by both create and edit forms)
const renderFormFields = (
form: NewRuleForm,
setForm: (f: NewRuleForm) => void,
showRuleId: boolean
) => (
<div className="space-y-5">
{/* Row 1: Rule ID (create only), Title */}
<div className={`grid grid-cols-1 ${showRuleId ? "sm:grid-cols-2" : ""} gap-4`}>
{showRuleId && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Rule ID <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.ruleId}
onChange={(e) => setForm({ ...form, ruleId: e.target.value })}
placeholder="e.g., custom-network-check"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="e.g., Custom Network Check"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Describe what this rule collects and why..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors resize-none"
/>
</div>
{/* Row 2: Category, Collector Type */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
>
{CATEGORIES.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Collector Type</label>
<select
value={form.collectorType}
onChange={(e) => setForm({ ...form, collectorType: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
>
{COLLECTOR_TYPES.map((ct) => (
<option key={ct} value={ct}>
{ct.charAt(0).toUpperCase() + ct.slice(1)}
</option>
))}
</select>
</div>
</div>
{/* Target */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Target <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.target}
onChange={(e) => setForm({ ...form, target: e.target.value })}
placeholder="e.g., HKLM\SOFTWARE\Microsoft\... or Win32_OperatingSystem"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Registry path, WMI class, event log name, file path, or command depending on collector type</p>
</div>
{/* Trigger */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger</label>
<select
value={form.trigger}
onChange={(e) => setForm({ ...form, trigger: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
>
{TRIGGERS.map((t) => (
<option key={t} value={t}>{formatTrigger(t)}</option>
))}
</select>
</div>
{/* Conditional Trigger Fields */}
{form.trigger === "interval" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Interval (seconds)</label>
<input
type="number"
min={5}
max={3600}
value={form.intervalSeconds}
onChange={(e) => setForm({ ...form, intervalSeconds: parseInt(e.target.value) || 60 })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">How often to run this rule (5 - 3600 seconds)</p>
</div>
)}
{form.trigger === "phase_change" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger Phase</label>
<input
type="text"
value={form.triggerPhase}
onChange={(e) => setForm({ ...form, triggerPhase: e.target.value })}
placeholder="e.g., DeviceESP, AccountESP, DevicePreparation"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
)}
{form.trigger === "on_event" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger Event Type</label>
<input
type="text"
value={form.triggerEventType}
onChange={(e) => setForm({ ...form, triggerEventType: e.target.value })}
placeholder="e.g., NetworkChange, PolicyApplied"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
)}
{/* Row 3: Output Event Type, Output Severity */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Output Event Type <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.outputEventType}
onChange={(e) => setForm({ ...form, outputEventType: e.target.value })}
placeholder="e.g., CustomNetworkStatus"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Output Severity</label>
<select
value={form.outputSeverity}
onChange={(e) => setForm({ ...form, outputSeverity: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
>
{SEVERITIES.map((s) => (
<option key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</option>
))}
</select>
</div>
</div>
</div>
);
return (
<ProtectedRoute>
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
@@ -495,194 +795,11 @@ export default function GatherRulesPage() {
</div>
</div>
</div>
<div className="p-6 space-y-5">
{/* Row 1: Rule ID, Title */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Rule ID <span className="text-red-500">*</span>
</label>
<input
type="text"
value={newRule.ruleId}
onChange={(e) => setNewRule({ ...newRule, ruleId: e.target.value })}
placeholder="e.g., custom-network-check"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
value={newRule.title}
onChange={(e) => setNewRule({ ...newRule, title: e.target.value })}
placeholder="e.g., Custom Network Check"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={newRule.description}
onChange={(e) => setNewRule({ ...newRule, description: e.target.value })}
placeholder="Describe what this rule collects and why..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors resize-none"
/>
</div>
{/* Row 2: Category, Collector Type */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
value={newRule.category}
onChange={(e) => setNewRule({ ...newRule, category: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
>
{CATEGORIES.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Collector Type</label>
<select
value={newRule.collectorType}
onChange={(e) => setNewRule({ ...newRule, collectorType: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
>
{COLLECTOR_TYPES.map((ct) => (
<option key={ct} value={ct}>
{ct.charAt(0).toUpperCase() + ct.slice(1)}
</option>
))}
</select>
</div>
</div>
{/* Target */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Target <span className="text-red-500">*</span>
</label>
<input
type="text"
value={newRule.target}
onChange={(e) => setNewRule({ ...newRule, target: e.target.value })}
placeholder="e.g., HKLM\SOFTWARE\Microsoft\... or Win32_OperatingSystem"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Registry path, WMI class, event log name, file path, or command depending on collector type</p>
</div>
{/* Trigger */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger</label>
<select
value={newRule.trigger}
onChange={(e) => setNewRule({ ...newRule, trigger: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
>
{TRIGGERS.map((t) => (
<option key={t} value={t}>
{t === "phase_change"
? "Phase Change"
: t === "on_event"
? "On Event"
: t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
{/* Conditional Trigger Fields */}
{newRule.trigger === "interval" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Interval (seconds)</label>
<input
type="number"
min={5}
max={3600}
value={newRule.intervalSeconds}
onChange={(e) => setNewRule({ ...newRule, intervalSeconds: parseInt(e.target.value) || 60 })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">How often to run this rule (5 - 3600 seconds)</p>
</div>
)}
{newRule.trigger === "phase_change" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger Phase</label>
<input
type="text"
value={newRule.triggerPhase}
onChange={(e) => setNewRule({ ...newRule, triggerPhase: e.target.value })}
placeholder="e.g., DeviceESP, AccountESP, DevicePreparation"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
)}
{newRule.trigger === "on_event" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger Event Type</label>
<input
type="text"
value={newRule.triggerEventType}
onChange={(e) => setNewRule({ ...newRule, triggerEventType: e.target.value })}
placeholder="e.g., NetworkChange, PolicyApplied"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
)}
{/* Row 3: Output Event Type, Output Severity */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Output Event Type <span className="text-red-500">*</span>
</label>
<input
type="text"
value={newRule.outputEventType}
onChange={(e) => setNewRule({ ...newRule, outputEventType: e.target.value })}
placeholder="e.g., CustomNetworkStatus"
autoComplete="off"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Output Severity</label>
<select
value={newRule.outputSeverity}
onChange={(e) => setNewRule({ ...newRule, outputSeverity: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
>
{SEVERITIES.map((s) => (
<option key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</option>
))}
</select>
</div>
</div>
<div className="p-6">
{renderFormFields(newRule, setNewRule, true)}
{/* Action Buttons */}
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200">
<div className="flex items-center justify-end space-x-3 pt-4 mt-5 border-t border-gray-200">
<button
onClick={() => {
setShowCreateForm(false);
@@ -713,24 +830,19 @@ export default function GatherRulesPage() {
)}
{/* Rules List */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-blue-50 to-indigo-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Rules</h2>
<p className="text-sm text-gray-500 mt-1">
{filteredRules.length} rule{filteredRules.length !== 1 ? "s" : ""} found
{(searchQuery || categoryFilter !== "all" || typeFilter !== "all") && " (filtered)"}
</p>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2 px-1">
<svg className="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span className="text-sm text-gray-500">
{filteredRules.length} rule{filteredRules.length !== 1 ? "s" : ""} found
{(searchQuery || categoryFilter !== "all" || typeFilter !== "all") && " (filtered)"}
</span>
</div>
{filteredRules.length === 0 ? (
<div className="p-8 text-center">
<div className="bg-white rounded-lg shadow p-8 text-center">
<svg className="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
@@ -749,116 +861,328 @@ export default function GatherRulesPage() {
)}
</div>
) : (
<div className="divide-y divide-gray-200">
{filteredRules.map((rule) => (
filteredRules.map((rule) => {
const isExpanded = expandedRuleId === rule.ruleId;
const isEditing = editingRuleId === rule.ruleId;
const catColor = CATEGORY_COLORS[rule.category] || { bg: "bg-gray-100", text: "text-gray-700" };
const canEdit = isGalacticAdmin || !rule.isBuiltIn;
return (
<div
key={rule.ruleId}
className={`p-4 hover:bg-gray-50 transition-colors ${!rule.enabled ? "opacity-60" : ""}`}
className={`bg-white rounded-lg shadow border transition-all ${
isExpanded ? "border-indigo-300 ring-1 ring-indigo-200" : "border-gray-200 hover:border-gray-300"
} ${!rule.enabled ? "opacity-60" : ""}`}
>
<div className="flex items-start justify-between gap-4">
{/* Left: Toggle + Info */}
<div className="flex items-start space-x-4 flex-1 min-w-0">
{/* Toggle */}
<div className="pt-1 flex-shrink-0">
<button
onClick={() => handleToggleRule(rule)}
disabled={togglingRule === rule.ruleId}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
rule.enabled ? "bg-emerald-500" : "bg-gray-300"
{/* Collapsed Header */}
<div
className="p-4 cursor-pointer select-none"
onClick={() => {
if (isEditing) return;
setExpandedRuleId(isExpanded ? null : rule.ruleId);
if (isExpanded && editingRuleId === rule.ruleId) {
setEditingRuleId(null);
}
}}
>
<div className="flex items-center space-x-4">
{/* Enable/Disable Toggle */}
<button
onClick={(e) => {
e.stopPropagation();
handleToggleRule(rule);
}}
disabled={togglingRule === rule.ruleId}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
togglingRule === rule.ruleId
? "opacity-50 cursor-not-allowed"
: "cursor-pointer"
} ${rule.enabled ? "bg-emerald-500" : "bg-gray-300"}`}
title={rule.enabled ? "Disable rule" : "Enable rule"}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
rule.enabled ? "translate-x-6" : "translate-x-1"
}`}
title={rule.enabled ? "Disable rule" : "Enable rule"}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
rule.enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
/>
</button>
{/* Rule ID */}
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-medium bg-gray-100 text-gray-600 border border-gray-200 flex-shrink-0 hidden sm:inline-flex">
{rule.ruleId}
</span>
{/* Title */}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">
{rule.title}
</h3>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center flex-wrap gap-2">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-medium bg-gray-100 text-gray-600 border border-gray-200">
{rule.ruleId}
</span>
{getCategoryBadge(rule.category)}
{rule.isBuiltIn && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-600 border border-blue-200">
Built-in
</span>
)}
{rule.isCommunity && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600 border border-amber-200">
Community
{/* Category Badge */}
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${catColor.bg} ${catColor.text} flex-shrink-0`}>
{rule.category.charAt(0).toUpperCase() + rule.category.slice(1)}
</span>
{/* Collector Type */}
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-gray-600 flex-shrink-0 hidden md:inline-flex">
{COLLECTOR_TYPE_LABELS[rule.collectorType] || rule.collectorType}
</span>
{/* Type Badge */}
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ${
rule.isBuiltIn
? "bg-blue-50 text-blue-600 border border-blue-200"
: rule.isCommunity
? "bg-amber-50 text-amber-600 border border-amber-200"
: "bg-purple-50 text-purple-600 border border-purple-200"
}`}
>
{rule.isBuiltIn ? "Built-in" : rule.isCommunity ? "Community" : "Custom"}
</span>
{/* Expand/Collapse Arrow */}
<svg
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${
isExpanded ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Expanded Details */}
{isExpanded && !isEditing && (
<div className="border-t border-gray-200 p-6 space-y-6">
{/* Meta Info Row */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500">
<span>
<span className="font-medium text-gray-700">Version:</span> {rule.version}
</span>
<span>
<span className="font-medium text-gray-700">Author:</span> {rule.author}
</span>
<span>
<span className="font-medium text-gray-700">Created:</span>{" "}
{new Date(rule.createdAt).toLocaleDateString()}
</span>
<span>
<span className="font-medium text-gray-700">Updated:</span>{" "}
{new Date(rule.updatedAt).toLocaleDateString()}
</span>
<span className="text-xs font-mono text-gray-400 sm:hidden">
{rule.ruleId}
</span>
</div>
{/* Tags */}
{rule.tags && rule.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{rule.tags.map((tag, idx) => (
<span
key={idx}
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600"
>
#{tag}
</span>
))}
</div>
)}
{/* Description */}
{rule.description && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-1">Description</h4>
<p className="text-sm text-gray-600 leading-relaxed">{rule.description}</p>
</div>
)}
{/* Collection Details */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Collection Details</h4>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div>
<span className="text-gray-500 font-medium">Collector Type:</span>{" "}
<span className="text-gray-900 font-semibold">{COLLECTOR_TYPE_LABELS[rule.collectorType] || rule.collectorType}</span>
</div>
<div>
<span className="text-gray-500 font-medium">Category:</span>{" "}
{getCategoryBadge(rule.category)}
</div>
</div>
<div className="text-sm">
<span className="text-gray-500 font-medium">Target:</span>
<code className="ml-2 px-2 py-1 bg-gray-200 rounded text-xs font-mono text-gray-800 break-all">
{rule.target}
</code>
</div>
{rule.parameters && Object.keys(rule.parameters).length > 0 && (
<div className="text-sm">
<span className="text-gray-500 font-medium">Parameters:</span>
<div className="mt-1 space-y-1">
{Object.entries(rule.parameters).map(([key, value]) => (
<div key={key} className="flex items-start gap-2">
<code className="px-1.5 py-0.5 bg-indigo-100 rounded text-xs font-mono text-indigo-700">{key}</code>
<span className="text-gray-400">=</span>
<code className="px-1.5 py-0.5 bg-gray-200 rounded text-xs font-mono text-gray-700 break-all">{value}</code>
</div>
))}
</div>
</div>
)}
</div>
<h3 className="mt-1 text-sm font-semibold text-gray-900 truncate">{rule.title}</h3>
{rule.description && (
<p className="mt-0.5 text-sm text-gray-500 line-clamp-2">{rule.description}</p>
)}
<div className="mt-2 flex items-center flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500">
<span className="flex items-center space-x-1">
<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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
<span>{rule.collectorType}</span>
</span>
<span className="flex items-center space-x-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div>
{/* Trigger Details */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Trigger</h4>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>
{rule.trigger === "phase_change"
? "Phase Change"
: rule.trigger === "on_event"
? "On Event"
: rule.trigger.charAt(0).toUpperCase() + rule.trigger.slice(1)}
{rule.trigger === "interval" && rule.intervalSeconds
? ` (${rule.intervalSeconds}s)`
: ""}
</span>
</span>
<span className="flex items-center space-x-1">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>{rule.author}</span>
</span>
<span className="font-semibold text-gray-900">{formatTrigger(rule.trigger)}</span>
{rule.trigger === "interval" && rule.intervalSeconds && (
<span className="text-gray-500">every {rule.intervalSeconds} seconds</span>
)}
{rule.trigger === "phase_change" && rule.triggerPhase && (
<span className="text-gray-500">
on phase: <code className="px-1 bg-gray-200 rounded text-xs">{rule.triggerPhase}</code>
</span>
)}
{rule.trigger === "on_event" && rule.triggerEventType && (
<span className="text-gray-500">
on event: <code className="px-1 bg-gray-200 rounded text-xs">{rule.triggerEventType}</code>
</span>
)}
</div>
</div>
</div>
</div>
{/* Right: Delete button for custom rules */}
{!rule.isBuiltIn && (
<div className="flex-shrink-0">
{/* Output */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Output</h4>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm">
<div className="flex flex-wrap items-center gap-4">
<div>
<span className="text-gray-500 font-medium">Event Type:</span>{" "}
<code className="px-1.5 py-0.5 bg-gray-200 rounded text-xs font-mono text-gray-800">{rule.outputEventType}</code>
</div>
<div>
<span className="text-gray-500 font-medium">Severity:</span>{" "}
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
rule.outputSeverity.toLowerCase() === "error" || rule.outputSeverity.toLowerCase() === "critical"
? "bg-red-100 text-red-700"
: rule.outputSeverity.toLowerCase() === "warning"
? "bg-yellow-100 text-yellow-700"
: "bg-blue-100 text-blue-700"
}`}>
{rule.outputSeverity.charAt(0).toUpperCase() + rule.outputSeverity.slice(1)}
</span>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200">
{canEdit && (
<button
onClick={() => startEditing(rule)}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center space-x-2"
title={rule.isBuiltIn ? "Edit global rule (Galactic Admin)" : "Edit rule"}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>{rule.isBuiltIn ? "Edit (Global)" : "Edit"}</span>
</button>
)}
{!rule.isBuiltIn && (
<button
onClick={() => handleDeleteRule(rule)}
disabled={deletingRule === rule.ruleId}
className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
{deletingRule === rule.ruleId ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Deleting...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span>Delete</span>
</>
)}
</button>
)}
</div>
</div>
)}
{/* Edit Form (replaces detail view when editing) */}
{isExpanded && isEditing && (
<div className="border-t border-gray-200 p-6">
<div className="flex items-center space-x-2 mb-4">
<svg className="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<h4 className="text-sm font-semibold text-gray-900">
Editing: {rule.ruleId}
{rule.isBuiltIn && isGalacticAdmin && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 border border-amber-200">
Global Edit
</span>
)}
</h4>
</div>
{rule.isBuiltIn && isGalacticAdmin && (
<div className="mb-4 bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-800">
<strong>Galactic Admin:</strong> Changes will apply globally to all tenants that haven&apos;t overridden this rule.
</div>
)}
{renderFormFields(editForm, setEditForm, false)}
{/* Action Buttons */}
<div className="flex items-center justify-end space-x-3 pt-4 mt-5 border-t border-gray-200">
<button
onClick={() => handleDeleteRule(rule)}
disabled={deletingRule === rule.ruleId}
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-1"
title="Delete custom rule"
onClick={() => setEditingRuleId(null)}
disabled={saving}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
>
{deletingRule === rule.ruleId ? (
Cancel
</button>
<button
onClick={() => handleSaveEdit(rule)}
disabled={saving}
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium"
>
{saving ? (
<>
<div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-white"></div>
<span>Deleting...</span>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Saving...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span>Delete</span>
</>
<span>Save Changes</span>
)}
</button>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
);
})
)}
</div>
</div>
+175 -8
View File
@@ -3,17 +3,10 @@
@tailwind utilities;
:root {
--background: #ffffff;
--background: #f9fafb;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
@@ -25,3 +18,177 @@ body {
text-wrap: balance;
}
}
/* ===== DARK MODE OVERRIDES =====
Pragmatic approach: override common Tailwind utility classes globally
when .dark is on <html>. This covers the entire app without adding
dark: prefixes to hundreds of components. */
/* -- Page background -- */
.dark body {
--background: #0f172a;
--foreground: #e2e8f0;
}
/* -- Surfaces -- */
.dark .bg-white {
background-color: #1e293b !important;
}
.dark .bg-gray-50 {
background-color: #0f172a !important;
}
.dark .bg-gray-100 {
background-color: #1e293b !important;
}
.dark .bg-gray-200 {
background-color: #334155 !important;
}
/* -- Text colors -- */
.dark .text-gray-900 {
color: #f1f5f9 !important;
}
.dark .text-gray-800 {
color: #e2e8f0 !important;
}
.dark .text-gray-700 {
color: #cbd5e1 !important;
}
.dark .text-gray-600 {
color: #94a3b8 !important;
}
.dark .text-gray-500 {
color: #64748b !important;
}
.dark .text-gray-400 {
color: #64748b !important;
}
/* -- Borders -- */
.dark .border-gray-200 {
border-color: #334155 !important;
}
.dark .border-gray-300 {
border-color: #475569 !important;
}
.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
border-color: #334155 !important;
}
/* -- Shadows (softer in dark mode) -- */
.dark .shadow,
.dark .shadow-sm,
.dark .shadow-md,
.dark .shadow-lg {
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.4) !important;
}
/* -- Navbar -- */
.dark nav.bg-white,
.dark nav .bg-white {
background-color: #1e293b !important;
}
.dark .border-b.border-gray-200 {
border-color: #334155 !important;
}
/* -- Hover states -- */
.dark .hover\:bg-gray-50:hover {
background-color: #334155 !important;
}
.dark .hover\:bg-gray-100:hover {
background-color: #334155 !important;
}
/* -- Input fields -- */
.dark input,
.dark select,
.dark textarea {
background-color: #0f172a !important;
color: #e2e8f0 !important;
border-color: #475569 !important;
}
.dark input::placeholder,
.dark textarea::placeholder {
color: #64748b !important;
}
/* -- Cards with colored backgrounds (keep but darken) -- */
.dark .bg-blue-50 {
background-color: #1e3a5f !important;
}
.dark .bg-blue-100 {
background-color: #1e3a5f !important;
}
.dark .bg-green-50 {
background-color: #14332a !important;
}
.dark .bg-green-100 {
background-color: #14332a !important;
}
.dark .bg-red-50 {
background-color: #3b1c1c !important;
}
.dark .bg-red-100 {
background-color: #3b1c1c !important;
}
.dark .bg-red-200 {
background-color: #4a2020 !important;
}
.dark .bg-yellow-50 {
background-color: #3d2e0a !important;
}
.dark .bg-yellow-100 {
background-color: #3d2e0a !important;
}
.dark .bg-orange-100 {
background-color: #3d260a !important;
}
.dark .bg-amber-50 {
background-color: #3d2e0a !important;
}
.dark .bg-purple-50 {
background-color: #2d1b4e !important;
}
.dark .bg-purple-100 {
background-color: #2d1b4e !important;
}
.dark .bg-indigo-50 {
background-color: #1e1b4b !important;
}
/* -- Colored borders in dark mode -- */
.dark .border-blue-200 {
border-color: #1e3a5f !important;
}
.dark .border-green-200 {
border-color: #14532d !important;
}
.dark .border-red-200 {
border-color: #4a2020 !important;
}
.dark .border-yellow-200 {
border-color: #3d2e0a !important;
}
.dark .border-purple-200 {
border-color: #2d1b4e !important;
}
/* -- Progress bars and charts keep their vivid colors -- */
/* (no overrides needed for bg-blue-500, bg-green-500, etc.) */
/* -- Scrollbar -- */
.dark ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark ::-webkit-scrollbar-track {
background: #1e293b;
}
.dark ::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
@@ -1,19 +1,30 @@
"use client";
import { useAuth } from "../../contexts/AuthContext";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
// Static platform stats - manually updated periodically
const PLATFORM_STATS = {
totalEnrollments: 123,
totalTenants: 2,
uniqueDeviceModels: 1,
};
export default function LandingPage() {
const { login, isAuthenticated, isLoading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
// Redirect to dashboard if already authenticated
// Check for preview mode (for debugging while logged in)
const previewMode = searchParams.get("preview") === "true";
// Redirect to dashboard if already authenticated (unless in preview mode)
useEffect(() => {
if (isAuthenticated && !isLoading) {
if (isAuthenticated && !isLoading && !previewMode) {
router.push("/");
}
}, [isAuthenticated, isLoading, router]);
}, [isAuthenticated, isLoading, previewMode, router]);
if (isLoading) {
return (
@@ -77,6 +88,39 @@ export default function LandingPage() {
<p className="mt-4 text-sm text-gray-500">
Free to try • Enterprise-ready • Multi-tenant support
</p>
{/* Platform Stats - Since Release (Static) - Always show, even if 0 */}
<div className="mt-12 pt-12 border-t border-gray-200">
<p className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-6">
Since Release
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-8">
<div className="text-center">
<div className="text-4xl font-bold text-blue-600 mb-2">
{PLATFORM_STATS.totalEnrollments.toLocaleString()}
</div>
<div className="text-sm text-gray-600">
Enrollments Monitored
</div>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-indigo-600 mb-2">
{PLATFORM_STATS.totalTenants.toLocaleString()}
</div>
<div className="text-sm text-gray-600">
Active Organizations
</div>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-purple-600 mb-2">
{PLATFORM_STATS.uniqueDeviceModels.toLocaleString()}
</div>
<div className="text-sm text-gray-600">
Different Device Models
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -191,6 +235,11 @@ export default function LandingPage() {
<footer className="border-t border-gray-200 bg-white/50 backdrop-blur-sm py-8 px-6">
<div className="max-w-7xl mx-auto text-center text-gray-600">
<p>&copy; 2026 Autopilot Monitor. Powered by Azure and Microsoft Identity.</p>
{previewMode && (
<p className="mt-2 text-xs text-orange-600 font-semibold">
🔍 Preview Mode Active - Auto-redirect disabled
</p>
)}
</div>
</footer>
</div>
+13 -10
View File
@@ -5,6 +5,7 @@ import { AuthProvider } from "../contexts/AuthContext";
import { SignalRProvider } from "../contexts/SignalRContext";
import { TenantProvider } from "../contexts/TenantContext";
import { NotificationProvider } from "../contexts/NotificationContext";
import { ThemeProvider } from "../contexts/ThemeContext";
import Navbar from "../components/Navbar";
const inter = Inter({ subsets: ["latin"] });
@@ -22,16 +23,18 @@ export default function RootLayout({
return (
<html lang="en">
<body className={inter.className}>
<AuthProvider>
<NotificationProvider>
<TenantProvider>
<SignalRProvider>
<Navbar />
{children}
</SignalRProvider>
</TenantProvider>
</NotificationProvider>
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<NotificationProvider>
<TenantProvider>
<SignalRProvider>
<Navbar />
{children}
</SignalRProvider>
</TenantProvider>
</NotificationProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
@@ -452,9 +452,6 @@ export default function Home() {
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Welcome to Autopilot Monitor
</h2>
<p className="text-sm text-gray-600 mb-4">
Real-time monitoring and troubleshooting for Windows Autopilot
</p>
<p className="text-gray-600 mb-6">
No enrollment sessions yet. Sessions will appear below once devices start enrolling.
</p>
@@ -49,12 +49,23 @@ interface HardwareMetrics {
topModels: HardwareCount[];
}
interface PlatformStats {
totalEnrollments: number;
totalUsers: number;
totalTenants: number;
uniqueDeviceModels: number;
totalEventsProcessed: number;
successfulEnrollments: number;
issuesDetected: number;
}
interface PlatformUsageMetrics {
sessions: SessionMetrics;
tenants: TenantMetrics;
users: UserMetrics;
performance: PerformanceMetrics;
hardware: HardwareMetrics;
platformStats?: PlatformStats;
computedAt: string;
computeDurationMs: number;
fromCache: boolean;
@@ -354,6 +365,45 @@ export default function PlatformUsageMetricsPage() {
</div>
</div>
{/* Platform Statistics (Cumulative Since Release) */}
{metrics.platformStats && (
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Platform Statistics (Since Release)</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Total Enrollments</div>
<div className="text-3xl font-bold text-blue-600">{metrics.platformStats.totalEnrollments.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Successful Enrollments</div>
<div className="text-3xl font-bold text-green-600">{metrics.platformStats.successfulEnrollments.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Issues Detected</div>
<div className="text-3xl font-bold text-red-600">{metrics.platformStats.issuesDetected.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Total Events Processed</div>
<div className="text-3xl font-bold text-purple-600">{metrics.platformStats.totalEventsProcessed.toLocaleString()}</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Total Users</div>
<div className="text-3xl font-bold text-indigo-600">{metrics.platformStats.totalUsers.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Total Tenants</div>
<div className="text-3xl font-bold text-cyan-600">{metrics.platformStats.totalTenants.toLocaleString()}</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm text-gray-500 mb-1">Unique Device Models</div>
<div className="text-3xl font-bold text-amber-600">{metrics.platformStats.uniqueDeviceModels.toLocaleString()}</div>
</div>
</div>
</div>
)}
{/* Hardware Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Top Manufacturers */}
@@ -78,6 +78,8 @@ export default function SessionDetailPage() {
const [showMarkFailedConfirm, setShowMarkFailedConfirm] = useState(false);
const [analysisResults, setAnalysisResults] = useState<RuleResult[]>([]);
const [loadingAnalysis, setLoadingAnalysis] = useState(false);
const [analysisExpanded, setAnalysisExpanded] = useState(true);
const [timelineExpanded, setTimelineExpanded] = useState(true);
const [adminMode, setAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('adminMode') === 'true';
@@ -506,32 +508,41 @@ export default function SessionDetailPage() {
{/* Analysis Results */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div
onClick={() => setAnalysisExpanded(!analysisExpanded)}
className="flex items-center justify-between w-full text-left mb-4 cursor-pointer"
>
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<h2 className="text-xl font-semibold text-gray-900">Analysis Results</h2>
{analysisResults.length > 0 && (
<span className="text-xs text-gray-400">({analysisResults.length} issues)</span>
<>
<span className="text-xs text-gray-400">({analysisResults.length} {analysisResults.length === 1 ? 'issue' : 'issues'})</span>
<div className="flex items-center space-x-2 text-xs">
{analysisResults.filter(r => r.severity === 'critical').length > 0 && (
<span className="px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
{analysisResults.filter(r => r.severity === 'critical').length} Critical
</span>
)}
{analysisResults.filter(r => r.severity === 'high').length > 0 && (
<span className="px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 font-medium">
{analysisResults.filter(r => r.severity === 'high').length} High
</span>
)}
{analysisResults.filter(r => r.severity === 'warning').length > 0 && (
<span className="px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700 font-medium">
{analysisResults.filter(r => r.severity === 'warning').length} Warning
</span>
)}
</div>
</>
)}
</div>
<div className="flex items-center space-x-3">
{analysisResults.length > 0 && (
<div className="flex items-center space-x-2 text-sm">
<span className="px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
{analysisResults.filter(r => r.severity === 'critical').length} Critical
</span>
<span className="px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 font-medium">
{analysisResults.filter(r => r.severity === 'high').length} High
</span>
<span className="px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700 font-medium">
{analysisResults.filter(r => r.severity === 'warning').length} Warning
</span>
</div>
)}
<button
onClick={() => fetchAnalysisResults(true)}
onClick={(e) => { e.stopPropagation(); fetchAnalysisResults(true); }}
disabled={loadingAnalysis}
title="Runs all analyze rules (single + correlation) against the current event data. Analysis also runs automatically when enrollment completes or fails."
className="px-3 py-1.5 text-sm font-medium bg-amber-50 text-amber-700 hover:bg-amber-100 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1.5"
@@ -553,21 +564,26 @@ export default function SessionDetailPage() {
</>
)}
</button>
<span className="text-gray-400">{analysisExpanded ? '▼' : '▶'}</span>
</div>
</div>
{loadingAnalysis && analysisResults.length === 0 ? (
<div className="text-center py-4 text-gray-500">Running analysis...</div>
) : analysisResults.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm">
No issues detected yet. Click &quot;Analyze Now&quot; to run analysis on the current events, or wait for enrollment to complete for automatic analysis.
</div>
) : (
<div className="space-y-3">
{analysisResults.map((result) => (
<AnalysisResultCard key={result.ruleId} result={result} />
))}
</div>
{analysisExpanded && (
<>
{loadingAnalysis && analysisResults.length === 0 ? (
<div className="text-center py-4 text-gray-500">Running analysis...</div>
) : analysisResults.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm">
No issues detected yet. Click &quot;Analyze Now&quot; to run analysis on the current events, or wait for enrollment to complete for automatic analysis.
</div>
) : (
<div className="space-y-3">
{analysisResults.map((result) => (
<AnalysisResultCard key={result.ruleId} result={result} />
))}
</div>
)}
</>
)}
</div>
@@ -583,64 +599,74 @@ export default function SessionDetailPage() {
{/* Timeline */}
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setTimelineExpanded(!timelineExpanded)}
className="flex items-center justify-between w-full text-left mb-4"
>
<h2 className="text-xl font-semibold text-gray-900">Event Timeline</h2>
{orderedPhases.length > 0 && (
<div className="flex gap-2">
<button
onClick={expandAll}
className="px-3 py-1 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 rounded transition-colors"
>
Expand All
</button>
<button
onClick={collapseAll}
className="px-3 py-1 text-sm bg-gray-50 text-gray-700 hover:bg-gray-100 rounded transition-colors"
>
Collapse All
</button>
<span className="text-gray-400">{timelineExpanded ? '▼' : '▶'}</span>
</button>
{timelineExpanded && (
<>
{/* Severity Filters + Expand/Collapse All */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-500">Filter:</span>
{(["Debug", "Info", "Warning", "Error", "Critical"] as const).map((sev) => {
const active = severityFilters.has(sev);
const colors: Record<string, { on: string; off: string }> = {
Debug: { on: "bg-gray-200 text-gray-800", off: "bg-gray-50 text-gray-400" },
Info: { on: "bg-blue-100 text-blue-800", off: "bg-gray-50 text-gray-400" },
Warning: { on: "bg-yellow-100 text-yellow-800", off: "bg-gray-50 text-gray-400" },
Error: { on: "bg-red-100 text-red-800", off: "bg-gray-50 text-gray-400" },
Critical: { on: "bg-red-200 text-red-900", off: "bg-gray-50 text-gray-400" },
};
return (
<button
key={sev}
onClick={() => toggleSeverityFilter(sev)}
className={`px-2.5 py-1 text-xs font-medium rounded-full transition-colors ${active ? colors[sev].on : colors[sev].off} hover:opacity-80`}
>
{sev}
</button>
);
})}
<span className="text-xs text-gray-400 ml-1">({filteredEvents.length}/{events.length})</span>
</div>
{orderedPhases.length > 0 && (
<div className="flex gap-2">
<button
onClick={expandAll}
className="px-3 py-1 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 rounded transition-colors"
>
Expand All
</button>
<button
onClick={collapseAll}
className="px-3 py-1 text-sm bg-gray-50 text-gray-700 hover:bg-gray-100 rounded transition-colors"
>
Collapse All
</button>
</div>
)}
</div>
)}
</div>
{/* Severity Filters */}
<div className="flex items-center gap-2 mb-6">
<span className="text-xs font-medium text-gray-500">Filter:</span>
{(["Debug", "Info", "Warning", "Error", "Critical"] as const).map((sev) => {
const active = severityFilters.has(sev);
const colors: Record<string, { on: string; off: string }> = {
Debug: { on: "bg-gray-200 text-gray-800", off: "bg-gray-50 text-gray-400" },
Info: { on: "bg-blue-100 text-blue-800", off: "bg-gray-50 text-gray-400" },
Warning: { on: "bg-yellow-100 text-yellow-800", off: "bg-gray-50 text-gray-400" },
Error: { on: "bg-red-100 text-red-800", off: "bg-gray-50 text-gray-400" },
Critical: { on: "bg-red-200 text-red-900", off: "bg-gray-50 text-gray-400" },
};
return (
<button
key={sev}
onClick={() => toggleSeverityFilter(sev)}
className={`px-2.5 py-1 text-xs font-medium rounded-full transition-colors ${active ? colors[sev].on : colors[sev].off} hover:opacity-80`}
>
{sev}
</button>
);
})}
<span className="text-xs text-gray-400 ml-1">({filteredEvents.length}/{events.length})</span>
</div>
{orderedPhases.length === 0 ? (
<div className="text-gray-500 text-center py-8">No events found for this session.</div>
) : (
<div className="space-y-8">
{orderedPhases.map((phaseName) => (
<PhaseSection
key={phaseName}
phaseName={phaseName}
events={eventsByPhase[phaseName]}
isExpanded={expandedPhases.has(phaseName)}
onToggle={() => togglePhase(phaseName)}
/>
))}
</div>
{orderedPhases.length === 0 ? (
<div className="text-gray-500 text-center py-8">No events found for this session.</div>
) : (
<div className="space-y-8">
{orderedPhases.map((phaseName) => (
<PhaseSection
key={phaseName}
phaseName={phaseName}
events={eventsByPhase[phaseName]}
isExpanded={expandedPhases.has(phaseName)}
onToggle={() => togglePhase(phaseName)}
/>
))}
</div>
)}
</>
)}
</div>
</div>
@@ -95,6 +95,7 @@ export default function DownloadProgress({ events }: DownloadProgressProps) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">Download Progress</h2>
<span className="text-xs text-gray-400">({downloads.length} {downloads.length === 1 ? 'download' : 'downloads'})</span>
<div className="flex items-center space-x-2 text-xs">
{activeCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">
@@ -2,13 +2,18 @@
import { useAuth } from '@/contexts/AuthContext';
import { useNotifications } from '@/contexts/NotificationContext';
import { useTheme } from '@/contexts/ThemeContext';
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { API_BASE_URL } from '@/lib/config';
import { usePathname } from 'next/navigation';
export default function Navbar() {
const { isAuthenticated, user, logout } = useAuth();
const { notifications, unreadCount, markAsRead, markAllAsRead, removeNotification, clearAll } = useNotifications();
const { theme, toggleTheme } = useTheme();
const pathname = usePathname();
const [showNotifications, setShowNotifications] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [showSettings, setShowSettings] = useState(false);
@@ -64,6 +69,16 @@ export default function Navbar() {
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Don't render navbar on landing page
if (pathname === '/landing') {
return null;
}
// Don't render navbar if not authenticated
if (!isAuthenticated) {
return null;
}
const getNotificationIcon = (type: string) => {
switch (type) {
case 'error':
@@ -89,10 +104,6 @@ export default function Navbar() {
return `${Math.floor(diffHours / 24)}d ago`;
};
if (!isAuthenticated) {
return null;
}
return (
<nav className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -341,6 +352,23 @@ export default function Navbar() {
)}
</div>
{/* Dark Mode Toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-full hover:bg-gray-100 transition-colors"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? (
<svg className="w-6 h-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
{/* Feedback Icon */}
<a href="https://github.com/yourusername/autopilot-monitor/issues" target="_blank" rel="noopener noreferrer" className="p-2 rounded-full hover:bg-gray-100 transition-colors" title="Feedback">
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -0,0 +1,54 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark";
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType>({
theme: "light",
toggleTheme: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
// Initialize from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored === "dark" || stored === "light") {
setTheme(stored);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
setTheme("dark");
}
}, []);
// Apply class to <html> and persist
useEffect(() => {
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
@@ -1,6 +1,7 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: 'class',
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",