From f358d8ebd901771e2517da2aa87bbd9e52515267 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 7 Feb 2026 19:23:20 +0100 Subject: [PATCH] Central table creation logic app install metrics platform stats global gather and analyze rule editng Dark mode (needs improvement) UI enhancments --- .../Functions/AnalyzeRulesFunction.cs | 33 +- .../Functions/DailyMaintenanceFunction.cs | 90 +- .../Functions/GatherRulesFunction.cs | 33 +- .../Functions/GetAppMetricsFunction.cs | 100 ++ .../Functions/GetPlatformStatsFunction.cs | 73 ++ .../Functions/IngestEventsFunction.cs | 93 ++ .../Functions/RegisterSessionFunction.cs | 3 + .../Services/AdminConfigurationService.cs | 9 +- .../Services/AnalyzeRuleService.cs | 11 + .../Services/GalacticAdminService.cs | 29 +- .../Services/GatherRuleService.cs | 11 + .../Services/TableInitializerService.cs | 44 + .../Services/TableStorageService.cs | 378 +++++++- .../Services/TenantAdminsService.cs | 31 +- .../Services/TenantConfigurationService.cs | 5 +- .../Services/UsageMetricsService.cs | 15 +- .../AutopilotMonitor.Shared/Constants.cs | 50 +- .../Models/AppInstallSummary.cs | 39 + .../Models/PlatformStats.cs | 39 + .../Models/UsageMetrics.cs | 5 + .../app/analyze-rules/page.tsx | 831 ++++++++++------ .../app/fleet-health/page.tsx | 228 ++++- .../app/gather-rules/page.tsx | 902 ++++++++++++------ src/Web/autopilot-monitor-web/app/globals.css | 183 +++- .../app/landing/page.tsx | 57 +- src/Web/autopilot-monitor-web/app/layout.tsx | 23 +- src/Web/autopilot-monitor-web/app/page.tsx | 3 - .../app/platform-usage-metrics/page.tsx | 50 + .../app/sessions/[sessionId]/page.tsx | 192 ++-- .../components/DownloadProgress.tsx | 1 + .../components/Navbar.tsx | 36 +- .../contexts/ThemeContext.tsx | 54 ++ .../autopilot-monitor-web/tailwind.config.ts | 1 + 33 files changed, 2824 insertions(+), 828 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/GetAppMetricsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/GetPlatformStatsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/TableInitializerService.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/AppInstallSummary.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/PlatformStats.cs create mode 100644 src/Web/autopilot-monitor-web/contexts/ThemeContext.tsx diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/AnalyzeRulesFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/AnalyzeRulesFunction.cs index 72d59ab4..c78479cf 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/AnalyzeRulesFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/AnalyzeRulesFunction.cs @@ -16,11 +16,13 @@ namespace AutopilotMonitor.Functions.Functions { private readonly ILogger _logger; private readonly AnalyzeRuleService _ruleService; + private readonly GalacticAdminService _galacticAdminService; - public AnalyzeRulesFunction(ILogger logger, AnalyzeRuleService ruleService) + public AnalyzeRulesFunction(ILogger 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")] diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/DailyMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/DailyMaintenanceFunction.cs index 0bcde160..a58549b2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/DailyMaintenanceFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/DailyMaintenanceFunction.cs @@ -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 } } + /// + /// Recomputes platform-wide stats from all tables. + /// Used on the public landing page (no auth required). + /// + 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(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"); + } + } + /// /// Calculates percentile value from sorted list /// @@ -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; } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GatherRulesFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GatherRulesFunction.cs index 85048d1c..880aeaca 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GatherRulesFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GatherRulesFunction.cs @@ -16,11 +16,13 @@ namespace AutopilotMonitor.Functions.Functions { private readonly ILogger _logger; private readonly GatherRuleService _ruleService; + private readonly GalacticAdminService _galacticAdminService; - public GatherRulesFunction(ILogger logger, GatherRuleService ruleService) + public GatherRulesFunction(ILogger 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")] diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAppMetricsFunction.cs new file mode 100644 index 00000000..d9a6dcf3 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAppMetricsFunction.cs @@ -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 _logger; + private readonly TableStorageService _storageService; + + public GetAppMetricsFunction(ILogger logger, TableStorageService storageService) + { + _logger = logger; + _storageService = storageService; + } + + [Function("GetAppMetrics")] + public async Task 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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetPlatformStatsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetPlatformStatsFunction.cs new file mode 100644 index 00000000..7f579277 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetPlatformStatsFunction.cs @@ -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 +{ + /// + /// Public endpoint - no authentication required. + /// Returns pre-computed platform stats for the landing page. + /// + public class GetPlatformStatsFunction + { + private readonly ILogger _logger; + private readonly TableStorageService _storageService; + + public GetPlatformStatsFunction(ILogger logger, TableStorageService storageService) + { + _logger = logger; + _storageService = storageService; + } + + [Function("GetPlatformStats")] + public async Task 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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs index 6dccd528..83a5fa6f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -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(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 }; } + /// + /// Aggregates app install events into AppInstallSummary records + /// + private void AggregateAppInstallEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary 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 CreateErrorResponse(HttpRequestData req, HttpStatusCode statusCode, string message) { var response = req.CreateResponse(statusCode); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs index 61a21973..0f6b0893 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs index 76fe72a6..e828130c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs @@ -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 } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs index 3d631d80..b88bf273 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs @@ -109,6 +109,17 @@ namespace AutopilotMonitor.Functions.Services return await _storageService.StoreAnalyzeRuleAsync(rule, tenantId); } + /// + /// Updates a built-in analyze rule globally (Galactic Admin only) + /// Modifies the global definition that all tenants inherit + /// + public async Task UpdateGlobalRuleAsync(AnalyzeRule rule) + { + rule.UpdatedAt = DateTime.UtcNow; + rule.IsBuiltIn = true; + return await _storageService.StoreAnalyzeRuleAsync(rule, "global"); + } + /// /// Deletes a custom analyze rule (cannot delete built-in rules) /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GalacticAdminService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GalacticAdminService.cs index d5877cc6..803cf692 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GalacticAdminService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GalacticAdminService.cs @@ -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 _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 } /// @@ -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 /// public async Task> GetAllGalacticAdminsAsync() { - var tableClient = _tableServiceClient.GetTableClient(_tableName); + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GalacticAdmins); var admins = new List(); await foreach (var entity in tableClient.QueryAsync( diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs index 4ebc29fe..6de74af6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs @@ -119,6 +119,17 @@ namespace AutopilotMonitor.Functions.Services return await _storageService.StoreGatherRuleAsync(rule, tenantId); } + /// + /// Updates a built-in gather rule globally (Galactic Admin only) + /// Modifies the global definition that all tenants inherit + /// + public async Task UpdateGlobalRuleAsync(GatherRule rule) + { + rule.UpdatedAt = DateTime.UtcNow; + rule.IsBuiltIn = true; + return await _storageService.StoreGatherRuleAsync(rule, "global"); + } + /// /// Deletes a custom gather rule (cannot delete built-in rules) /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableInitializerService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableInitializerService.cs new file mode 100644 index 00000000..8bd82f01 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableInitializerService.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services; + +/// +/// Hosted service that initializes all Azure Table Storage tables at application startup. +/// This ensures all tables exist before any requests are processed. +/// +public class TableInitializerService : IHostedService +{ + private readonly TableStorageService _tableStorageService; + private readonly ILogger _logger; + + public TableInitializerService( + TableStorageService tableStorageService, + ILogger 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; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs index 7863470a..f637e392 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs @@ -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 _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 logger) { _logger = logger; var connectionString = configuration["AzureTableStorageConnectionString"]; _tableServiceClient = new TableServiceClient(connectionString); - - // Ensure tables exist - InitializeTablesAsync().GetAwaiter().GetResult(); } - private async Task InitializeTablesAsync() + /// + /// Initializes all Azure Table Storage tables. + /// This method is idempotent and safe to call multiple times. + /// Should be called at application startup. + /// + 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; } } + /// + /// Gets the TableServiceClient for direct access (used by other services) + /// + public TableServiceClient GetTableServiceClient() => _tableServiceClient; + /// /// Stores a session registration /// @@ -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(); // 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(); // 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(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(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(); // 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(filter: $"PartitionKey eq '{tenantId}'"); var logs = new List(); @@ -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(); @@ -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(select: new[] { "PartitionKey" }); var tenantIds = new HashSet(); @@ -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(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(filter: $"PartitionKey eq '{partitionKey}'"); var rules = new List(); @@ -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(filter: $"PartitionKey eq '{partitionKey}'"); var rules = new List(); @@ -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 ===== + + /// + /// Stores or updates an app install summary + /// PartitionKey: TenantId, RowKey: {SessionId}_{AppName} + /// + public async Task 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; + } + } + + /// + /// Gets all app install summaries for a tenant (fleet-level metrics) + /// + public async Task> GetAppInstallSummariesByTenantAsync(string tenantId) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); + var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{tenantId}'"); + + var summaries = new List(); + 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(); + } + } + + /// + /// Deletes all app install summaries for a session + /// + public async Task DeleteSessionAppInstallSummariesAsync(string tenantId, string sessionId) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); + var query = tableClient.QueryAsync( + 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; + } + } + + /// + /// Deletes all rule results for a session + /// + public async Task DeleteSessionRuleResultsAsync(string tenantId, string sessionId) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults); + var partitionKey = $"{tenantId}_{sessionId}"; + var query = tableClient.QueryAsync(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 ===== + + /// + /// Gets the current platform stats (single row: global/current) + /// + public async Task GetPlatformStatsAsync() + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PlatformStats); + var response = await tableClient.GetEntityAsync("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; + } + } + + /// + /// Saves the full platform stats (upsert) + /// + public async Task 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; + } + } + + /// + /// Increments a specific platform stat counter atomically. + /// Reads current value, increments, and writes back. + /// + 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("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 ===== /// @@ -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(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(); 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); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs index 0e7afffa..14fb8eb5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs @@ -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 _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 } /// @@ -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> GetTenantAdminsAsync(string tenantId) { tenantId = tenantId.ToLowerInvariant(); - var tableClient = _tableServiceClient.GetTableClient(_tableName); + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins); var admins = new List(); await foreach (var entity in tableClient.QueryAsync( diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs index 5b7c25ea..1267c0bb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs @@ -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 } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs index 9b4802bb..d0b8cda6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs @@ -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 }; } diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 6a5c56fd..cfaa04bf 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -97,14 +97,54 @@ namespace AutopilotMonitor.Shared /// /// Azure Table Storage table names + /// All table names are defined here centrally and initialized at application startup /// 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"; + + /// + /// Returns all table names for initialization + /// + public static string[] All => new[] + { + Sessions, + Events, + AuditLogs, + UsageMetrics, + UserActivity, + RuleResults, + GatherRules, + AnalyzeRules, + AppInstallSummaries, + PlatformStats, + TenantConfiguration, + AdminConfiguration, + GalacticAdmins, + TenantAdmins + }; } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AppInstallSummary.cs b/src/Shared/AutopilotMonitor.Shared/Models/AppInstallSummary.cs new file mode 100644 index 00000000..5dfe0562 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/AppInstallSummary.cs @@ -0,0 +1,39 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Per-app installation summary, written during event ingestion. + /// Enables fleet-level app metrics without scanning raw events. + /// + public class AppInstallSummary + { + public string AppName { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + + /// Succeeded, Failed, InProgress + public string Status { get; set; } = "InProgress"; + + /// Total installation duration in seconds (from start to complete/failed) + public int DurationSeconds { get; set; } + + /// Total download size in bytes + public long DownloadBytes { get; set; } + + /// Download duration in seconds + public int DownloadDurationSeconds { get; set; } + + /// Error code if failed + public string FailureCode { get; set; } = string.Empty; + + /// Error message if failed + public string FailureMessage { get; set; } = string.Empty; + + /// When this app install started + public DateTime StartedAt { get; set; } + + /// When this app install completed or failed + public DateTime? CompletedAt { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/PlatformStats.cs b/src/Shared/AutopilotMonitor.Shared/Models/PlatformStats.cs new file mode 100644 index 00000000..683fe688 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/PlatformStats.cs @@ -0,0 +1,39 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// 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. + /// + public class PlatformStats + { + /// Total enrollment sessions monitored since launch + public long TotalEnrollments { get; set; } + + /// Total unique users who logged in + public long TotalUsers { get; set; } + + /// Total unique tenants using the platform + public long TotalTenants { get; set; } + + /// Total unique device models seen (manufacturer + model) + public long UniqueDeviceModels { get; set; } + + /// Total events processed across all tenants + public long TotalEventsProcessed { get; set; } + + /// Total successful enrollments + public long SuccessfulEnrollments { get; set; } + + /// Total analysis issues detected + public long IssuesDetected { get; set; } + + /// When these stats were last fully recomputed + public DateTime LastFullCompute { get; set; } + + /// When these stats were last updated (including incremental) + public DateTime LastUpdated { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/UsageMetrics.cs b/src/Shared/AutopilotMonitor.Shared/Models/UsageMetrics.cs index 1e0086ed..5690eb46 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/UsageMetrics.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/UsageMetrics.cs @@ -33,6 +33,11 @@ namespace AutopilotMonitor.Shared.Models /// public HardwareMetrics Hardware { get; set; } = new(); + /// + /// Platform statistics (cumulative since release) + /// + public PlatformStats? PlatformStats { get; set; } + /// /// When these metrics were computed /// diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx index b499e1d6..554ce070 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -39,6 +39,7 @@ interface AnalyzeRule { description: string; severity: string; category: string; + trigger: string; version: string; author: string; enabled: boolean; @@ -56,6 +57,28 @@ interface AnalyzeRule { updatedAt: string; } +interface RuleForm { + ruleId: string; + title: string; + description: string; + severity: string; + category: string; + trigger: string; + explanation: string; + baseConfidence: number; + confidenceThreshold: number; + conditions: RuleCondition[]; + confidenceFactors: ConfidenceFactor[]; + remediation: RemediationStep[]; + relatedDocs: RelatedDoc[]; +} + +const CATEGORIES = ["network", "identity", "apps", "device", "esp", "enrollment"] as const; +const SEVERITIES = ["info", "warning", "high", "critical"] as const; +const TRIGGERS = ["single", "correlation"] as const; +const OPERATORS = ["equals", "contains", "regex", "gt", "lt", "gte", "lte", "exists", "count_gte"] as const; +const SOURCES = ["event_type", "event_data", "phase_duration", "event_count"] as const; + const SEVERITY_COLORS: Record = { critical: { bg: "bg-red-100", text: "text-red-800", border: "border-red-300", dot: "bg-red-500" }, high: { bg: "bg-orange-100", text: "text-orange-800", border: "border-orange-300", dot: "bg-orange-500" }, @@ -80,10 +103,61 @@ function getCategoryColor(category: string) { return CATEGORY_COLORS[category.toLowerCase()] || { bg: "bg-gray-100", text: "text-gray-700" }; } +const EMPTY_CONDITION: RuleCondition = { + signal: "", + source: "event_type", + eventType: "", + dataField: "", + operator: "contains", + value: "", + required: true, +}; + +const EMPTY_FACTOR: ConfidenceFactor = { + signal: "", + condition: "", + weight: 10, +}; + +const EMPTY_FORM: RuleForm = { + ruleId: "", + title: "", + description: "", + severity: "warning", + category: "device", + trigger: "single", + explanation: "", + baseConfidence: 50, + confidenceThreshold: 40, + conditions: [{ ...EMPTY_CONDITION }], + confidenceFactors: [], + remediation: [], + relatedDocs: [], +}; + +function ruleToForm(rule: AnalyzeRule): RuleForm { + return { + ruleId: rule.ruleId, + title: rule.title, + description: rule.description || "", + severity: rule.severity, + category: rule.category, + trigger: rule.trigger || "single", + explanation: rule.explanation || "", + baseConfidence: rule.baseConfidence, + confidenceThreshold: rule.confidenceThreshold, + conditions: rule.conditions.length > 0 ? rule.conditions.map(c => ({ ...c })) : [{ ...EMPTY_CONDITION }], + confidenceFactors: rule.confidenceFactors.map(f => ({ ...f })), + remediation: rule.remediation.map(r => ({ title: r.title, steps: [...r.steps] })), + relatedDocs: rule.relatedDocs.map(d => ({ ...d })), + }; +} + export default function AnalyzeRulesPage() { const router = useRouter(); const { tenantId } = useTenant(); - const { getAccessToken } = useAuth(); + const { getAccessToken, user } = useAuth(); + const isGalacticAdmin = user?.isGalacticAdmin ?? false; const [rules, setRules] = useState([]); const [loading, setLoading] = useState(true); @@ -96,8 +170,16 @@ export default function AnalyzeRulesPage() { const [categoryFilter, setCategoryFilter] = useState("all"); const [typeFilter, setTypeFilter] = useState("all"); - // Expanded card state + // Expanded / editing state const [expandedRuleId, setExpandedRuleId] = useState(null); + const [editingRuleId, setEditingRuleId] = useState(null); + const [editForm, setEditForm] = useState({ ...EMPTY_FORM }); + const [saving, setSaving] = useState(false); + + // Create form state + const [showCreateForm, setShowCreateForm] = useState(false); + const [newRule, setNewRule] = useState({ ...EMPTY_FORM }); + const [creating, setCreating] = useState(false); // Toggling / deleting state const [togglingRuleId, setTogglingRuleId] = useState(null); @@ -190,7 +272,7 @@ export default function AnalyzeRulesPage() { // Delete custom rule const handleDeleteRule = async (rule: AnalyzeRule) => { - if (!confirm(`Are you sure you want to delete the custom rule "${rule.title}"?`)) { + if (!confirm(`Are you sure you want to delete the rule "${rule.title}"? This action cannot be undone.`)) { return; } @@ -215,9 +297,7 @@ export default function AnalyzeRulesPage() { } setRules((prev) => prev.filter((r) => r.ruleId !== rule.ruleId)); - if (expandedRuleId === rule.ruleId) { - setExpandedRuleId(null); - } + if (expandedRuleId === rule.ruleId) setExpandedRuleId(null); showSuccess(`Rule "${rule.title}" deleted successfully!`); } catch (err) { console.error("Error deleting rule:", err); @@ -227,6 +307,130 @@ export default function AnalyzeRulesPage() { } }; + // Create custom rule + const handleCreateRule = async () => { + if (!newRule.ruleId.trim() || !newRule.title.trim()) { + showError("Rule ID and Title are required."); + return; + } + + try { + setCreating(true); + setError(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error("Failed to get access token"); + } + + const payload = { + ruleId: newRule.ruleId.trim(), + title: newRule.title.trim(), + description: newRule.description.trim(), + severity: newRule.severity, + category: newRule.category, + trigger: newRule.trigger, + explanation: newRule.explanation.trim(), + baseConfidence: newRule.baseConfidence, + confidenceThreshold: newRule.confidenceThreshold, + conditions: newRule.conditions.filter(c => c.signal.trim()), + confidenceFactors: newRule.confidenceFactors.filter(f => f.signal.trim()), + remediation: newRule.remediation.filter(r => r.title.trim()), + relatedDocs: newRule.relatedDocs.filter(d => d.title.trim() && d.url.trim()), + }; + + const response = await fetch(`${API_BASE_URL}/api/analyze-rules`, { + method: "POST", + 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 create rule: ${response.statusText}`); + } + + showSuccess(`Rule "${newRule.title}" created successfully!`); + setNewRule({ ...EMPTY_FORM, conditions: [{ ...EMPTY_CONDITION }] }); + setShowCreateForm(false); + await fetchRules(); + } catch (err) { + console.error("Error creating rule:", err); + showError(err instanceof Error ? err.message : "Failed to create rule"); + } finally { + setCreating(false); + } + }; + + // Start editing + const startEditing = (rule: AnalyzeRule) => { + setEditingRuleId(rule.ruleId); + setEditForm(ruleToForm(rule)); + }; + + // Save edited rule + const handleSaveEdit = async (rule: AnalyzeRule) => { + if (!editForm.title.trim()) { + showError("Title is required."); + return; + } + + try { + setSaving(true); + setError(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error("Failed to get access token"); + } + + const payload = { + ...rule, + title: editForm.title.trim(), + description: editForm.description.trim(), + severity: editForm.severity, + category: editForm.category, + trigger: editForm.trigger, + explanation: editForm.explanation.trim(), + baseConfidence: editForm.baseConfidence, + confidenceThreshold: editForm.confidenceThreshold, + conditions: editForm.conditions.filter(c => c.signal.trim()), + confidenceFactors: editForm.confidenceFactors.filter(f => f.signal.trim()), + remediation: editForm.remediation.filter(r => r.title.trim()), + relatedDocs: editForm.relatedDocs.filter(d => d.title.trim() && d.url.trim()), + }; + + const isGlobalEdit = rule.isBuiltIn && isGalacticAdmin; + const url = `${API_BASE_URL}/api/analyze-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); + } + }; + // Filter rules const filteredRules = rules.filter((rule) => { const matchesSearch = @@ -256,9 +460,195 @@ export default function AnalyzeRulesPage() { const warningCount = rules.filter((r) => r.severity.toLowerCase() === "warning").length; const infoCount = rules.filter((r) => r.severity.toLowerCase() === "info").length; - // Get unique categories from rules for filter dropdown const uniqueCategories = Array.from(new Set(rules.map((r) => r.category.toLowerCase()))); + // Shared form fields renderer + const renderFormFields = ( + form: RuleForm, + setForm: (f: RuleForm) => void, + showRuleId: boolean + ) => ( +
+ {/* Basic Fields */} +
+ {showRuleId && ( +
+ + setForm({ ...form, ruleId: e.target.value })} placeholder="e.g., ANALYZE-CUSTOM-001" 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" /> +
+ )} +
+ + setForm({ ...form, title: e.target.value })} placeholder="e.g., Proxy Authentication Failure" 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" /> +
+
+ +
+ +