diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index e34885bb..58c9bdf9 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -107 +111 diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs new file mode 100644 index 00000000..57bee6e3 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs @@ -0,0 +1,380 @@ +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Services +{ + public partial class TableStorageService + { + // ===== AUDIT LOG METHODS ===== + + /// + /// Logs an audit entry + /// + public async Task LogAuditEntryAsync(string tenantId, string action, string entityType, string entityId, string performedBy, Dictionary? details = null) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs); + var timestamp = DateTime.UtcNow; + + var entity = new TableEntity(tenantId, Guid.NewGuid().ToString()) + { + { "Action", action }, + { "EntityType", entityType }, + { "EntityId", entityId }, + { "PerformedBy", performedBy }, + { "Timestamp", timestamp }, + { "Details", details != null ? JsonConvert.SerializeObject(details) : string.Empty } + }; + + await tableClient.AddEntityAsync(entity); + _logger.LogInformation($"Audit log created: {action} on {entityType} {entityId} by {performedBy}"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create audit log entry"); + return false; + } + } + + /// + /// Gets audit log entries for a tenant + /// + public async Task> GetAuditLogsAsync(string tenantId, int maxResults = 100) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs); + var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{tenantId}'"); + + var logs = new List(); + await foreach (var entity in query) + { + logs.Add(new AuditLogEntry + { + Id = entity.RowKey, + TenantId = entity.PartitionKey, + Action = entity.GetString("Action") ?? string.Empty, + EntityType = entity.GetString("EntityType") ?? string.Empty, + EntityId = entity.GetString("EntityId") ?? string.Empty, + PerformedBy = entity.GetString("PerformedBy") ?? string.Empty, + Timestamp = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.UtcNow, + Details = entity.GetString("Details") ?? string.Empty + }); + + if (logs.Count >= maxResults) break; + } + + // Sort by timestamp descending (most recent first) + return logs.OrderByDescending(l => l.Timestamp).Take(maxResults).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get audit logs for tenant {tenantId}"); + return new List(); + } + } + + // ===== DATA RETENTION METHODS ===== + + /// + /// Gets sessions older than a specific date for a tenant + /// + public async Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + 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'"; + var query = tableClient.QueryAsync(filter: filter); + + var sessions = new List(); + await foreach (var entity in query) + { + sessions.Add(MapToSessionSummary(entity)); + } + + _logger.LogInformation($"Found {sessions.Count} sessions older than {cutoffDate:yyyy-MM-dd} for tenant {tenantId}"); + return sessions; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get old sessions for tenant {tenantId}"); + return new List(); + } + } + + /// + /// Gets all sessions within a date range, optionally filtered by tenant. + /// Uses server-side filtering to avoid loading all sessions into memory. + /// + public async Task> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null) + { + if (!string.IsNullOrEmpty(tenantId)) + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + + var filter = !string.IsNullOrEmpty(tenantId) + ? $"PartitionKey eq '{tenantId}' and StartedAt ge datetime'{startDate:yyyy-MM-ddTHH:mm:ss}Z' and StartedAt lt datetime'{endDate:yyyy-MM-ddTHH:mm:ss}Z'" + : $"StartedAt ge datetime'{startDate:yyyy-MM-ddTHH:mm:ss}Z' and StartedAt lt datetime'{endDate:yyyy-MM-ddTHH:mm:ss}Z'"; + + var query = tableClient.QueryAsync(filter: filter); + + var sessions = new List(); + await foreach (var entity in query) + { + sessions.Add(MapToSessionSummary(entity)); + } + + _logger.LogInformation($"Found {sessions.Count} sessions between {startDate:yyyy-MM-dd} and {endDate:yyyy-MM-dd}" + + (tenantId != null ? $" for tenant {tenantId}" : "")); + return sessions; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get sessions by date range"); + return new List(); + } + } + + /// + /// Gets stalled sessions (InProgress status, started before cutoff time) for a tenant. + /// Uses server-side filtering to avoid loading all sessions into memory. + /// + public async Task> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + + var filter = $"PartitionKey eq '{tenantId}' and Status eq 'InProgress' and StartedAt lt datetime'{cutoffTime:yyyy-MM-ddTHH:mm:ss}Z'"; + var query = tableClient.QueryAsync(filter: filter); + + var sessions = new List(); + await foreach (var entity in query) + { + sessions.Add(MapToSessionSummary(entity)); + } + + return sessions; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get stalled sessions for tenant {tenantId}"); + return new List(); + } + } + + /// + /// Gets sessions where the device has sent data within the last window period + /// AND the session started before that window cutoff – indicating a device that has been + /// actively sending data for longer than the allowed maximum. + /// Status-independent: detects excessive data senders regardless of session status. + /// Uses LastEventAt (written on every event batch) for the "still active" check. + /// Sessions without LastEventAt (predating this field) are not returned. + /// + public async Task> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + + // LastEventAt gt cutoff → device sent data recently (still active) + // StartedAt lt cutoff → session has been running longer than the allowed window + // IsPreProvisioned ne true → exclude WhiteGlove sessions: a pre-provisioned device + // that resumes after weeks in storage looks like an excessive sender (StartedAt old, + // LastEventAt recent) but is a legitimate resumption, not abuse. + var cutoffStr = windowCutoff.ToString("yyyy-MM-ddTHH:mm:ss"); + var filter = $"PartitionKey eq '{tenantId}' " + + $"and LastEventAt gt datetime'{cutoffStr}Z' " + + $"and StartedAt lt datetime'{cutoffStr}Z' " + + $"and IsPreProvisioned ne true"; + + var query = tableClient.QueryAsync(filter: filter); + + var sessions = new List(); + await foreach (var entity in query) + { + sessions.Add(MapToSessionSummary(entity)); + } + + return sessions; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get excessive data sender sessions for tenant {tenantId}"); + return new List(); + } + } + + /// + /// Gets all unique tenant IDs from sessions table + /// + public async Task> GetAllTenantIdsAsync() + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + var query = tableClient.QueryAsync(select: new[] { "PartitionKey" }); + + var tenantIds = new HashSet(); + await foreach (var entity in query) + { + tenantIds.Add(entity.PartitionKey); + } + + _logger.LogInformation($"Found {tenantIds.Count} unique tenants"); + return tenantIds.ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get tenant IDs"); + return new List(); + } + } + + // ===== DELETION HELPERS ===== + + /// + /// Deletes all events for a session from storage + /// + public async Task DeleteSessionEventsAsync(string tenantId, string sessionId) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); + + // Query all events for this session using the same PartitionKey format as StoreEventAsync + var partitionKey = $"{tenantId}_{sessionId}"; + var filter = $"PartitionKey eq '{partitionKey}'"; + var events = tableClient.QueryAsync(filter); + + int deletedCount = 0; + var batch = new List(); + await foreach (var eventEntity in events) + { + batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, eventEntity)); + deletedCount++; + if (batch.Count >= 100) + { + await tableClient.SubmitTransactionAsync(batch); + batch.Clear(); + } + } + if (batch.Count > 0) + { + await tableClient.SubmitTransactionAsync(batch); + } + + _logger.LogInformation($"Deleted {deletedCount} events for session {sessionId}"); + return deletedCount; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to delete events for session {sessionId}"); + return 0; + } + } + + /// + /// Deletes all rule results for a session + /// + public async Task DeleteSessionRuleResultsAsync(string tenantId, string sessionId) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults); + var partitionKey = $"{tenantId}_{sessionId}"; + var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{partitionKey}'"); + + int deletedCount = 0; + var batch = new List(); + await foreach (var entity in query) + { + batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity)); + deletedCount++; + if (batch.Count >= 100) + { + await tableClient.SubmitTransactionAsync(batch); + batch.Clear(); + } + } + if (batch.Count > 0) + { + await tableClient.SubmitTransactionAsync(batch); + } + + return deletedCount; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to delete rule results for session {sessionId}"); + return 0; + } + } + + /// + /// Deletes all app install summaries for a session + /// + public async Task DeleteSessionAppInstallSummariesAsync(string tenantId, string sessionId) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); + var query = tableClient.QueryAsync( + filter: $"PartitionKey eq '{tenantId}' and SessionId eq '{sessionId}'"); + + int deletedCount = 0; + var batch = new List(); + await foreach (var entity in query) + { + batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity)); + deletedCount++; + if (batch.Count >= 100) + { + await tableClient.SubmitTransactionAsync(batch); + batch.Clear(); + } + } + if (batch.Count > 0) + { + await tableClient.SubmitTransactionAsync(batch); + } + + return deletedCount; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to delete app install summaries for session {sessionId}"); + return 0; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs new file mode 100644 index 00000000..6f2d8faf --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs @@ -0,0 +1,581 @@ +using Azure; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services +{ + public partial class TableStorageService + { + // ===== HISTORICAL METRICS METHODS ===== + + /// + /// Saves a historical metrics snapshot + /// + public async Task SaveUsageMetricsSnapshotAsync(UsageMetricsSnapshot metrics) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics); + + var entity = new TableEntity(metrics.Date, metrics.TenantId) + { + ["ComputedAt"] = metrics.ComputedAt, + ["ComputeDurationMs"] = metrics.ComputeDurationMs, + ["SessionsTotal"] = metrics.SessionsTotal, + ["SessionsSucceeded"] = metrics.SessionsSucceeded, + ["SessionsFailed"] = metrics.SessionsFailed, + ["SessionsInProgress"] = metrics.SessionsInProgress, + ["SessionsSuccessRate"] = metrics.SessionsSuccessRate, + ["AvgDurationMinutes"] = metrics.AvgDurationMinutes, + ["MedianDurationMinutes"] = metrics.MedianDurationMinutes, + ["P95DurationMinutes"] = metrics.P95DurationMinutes, + ["P99DurationMinutes"] = metrics.P99DurationMinutes, + ["UniqueTenants"] = metrics.UniqueTenants, + ["UniqueUsers"] = metrics.UniqueUsers, + ["LoginCount"] = metrics.LoginCount, + ["TopManufacturers"] = metrics.TopManufacturers, + ["TopModels"] = metrics.TopModels + }; + + await tableClient.UpsertEntityAsync(entity); + _logger.LogInformation($"Saved historical metrics for {metrics.Date} / {metrics.TenantId}"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to save historical metrics for {metrics.Date} / {metrics.TenantId}"); + return false; + } + } + + /// + /// Gets historical metrics for a date range + /// + public async Task> GetUsageMetricsSnapshotAsync(string? tenantId = null, string? startDate = null, string? endDate = null, int maxResults = 100) + { + if (!string.IsNullOrEmpty(tenantId)) + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics); + + // Build filter + var filters = new List(); + + if (!string.IsNullOrEmpty(startDate)) + filters.Add($"PartitionKey ge '{startDate}'"); + + if (!string.IsNullOrEmpty(endDate)) + filters.Add($"PartitionKey le '{endDate}'"); + + if (!string.IsNullOrEmpty(tenantId)) + filters.Add($"RowKey eq '{tenantId}'"); + + var filter = filters.Count > 0 ? string.Join(" and ", filters) : null; + var query = tableClient.QueryAsync(filter: filter); + + var results = new List(); + await foreach (var entity in query) + { + results.Add(new UsageMetricsSnapshot + { + Date = entity.PartitionKey, + TenantId = entity.RowKey, + ComputedAt = entity.GetDateTimeOffset("ComputedAt")?.UtcDateTime ?? DateTime.UtcNow, + ComputeDurationMs = entity.GetInt32("ComputeDurationMs") ?? 0, + SessionsTotal = entity.GetInt32("SessionsTotal") ?? 0, + SessionsSucceeded = entity.GetInt32("SessionsSucceeded") ?? 0, + SessionsFailed = entity.GetInt32("SessionsFailed") ?? 0, + SessionsInProgress = entity.GetInt32("SessionsInProgress") ?? 0, + SessionsSuccessRate = entity.GetDouble("SessionsSuccessRate") ?? 0, + AvgDurationMinutes = entity.GetDouble("AvgDurationMinutes") ?? 0, + MedianDurationMinutes = entity.GetDouble("MedianDurationMinutes") ?? 0, + P95DurationMinutes = entity.GetDouble("P95DurationMinutes") ?? 0, + P99DurationMinutes = entity.GetDouble("P99DurationMinutes") ?? 0, + UniqueTenants = entity.GetInt32("UniqueTenants") ?? 0, + UniqueUsers = entity.GetInt32("UniqueUsers") ?? 0, + LoginCount = entity.GetInt32("LoginCount") ?? 0, + TopManufacturers = entity.GetString("TopManufacturers") ?? "[]", + TopModels = entity.GetString("TopModels") ?? "[]" + }); + + if (results.Count >= maxResults) break; + } + + return results.OrderByDescending(m => m.Date).Take(maxResults).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get historical metrics"); + return new List(); + } + } + + /// + /// Checks if a global usage metrics snapshot exists for a given date. + /// Used by maintenance catch-up to determine which dates need aggregation. + /// + public async Task HasUsageMetricsSnapshotAsync(string date) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics); + await tableClient.GetEntityAsync(date, "global"); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to check usage metrics snapshot for {date}"); + return false; + } + } + + // ===== APP INSTALL SUMMARIES METHODS ===== + + /// + /// Stores or updates an app install summary. + /// Merges with any existing record so StartedAt is never overwritten with a later timestamp. + /// 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}"; + + // Merge with existing record to preserve StartedAt from a prior batch + try + { + var existing = await tableClient.GetEntityAsync(summary.TenantId, rowKey); + var existingStartedAt = existing.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime; + if (existingStartedAt.HasValue && existingStartedAt.Value != DateTime.MinValue) + { + // Keep the earlier StartedAt; recalculate duration if CompletedAt is now known + if (summary.StartedAt == DateTime.MinValue || existingStartedAt.Value < summary.StartedAt) + { + summary.StartedAt = existingStartedAt.Value; + if (summary.CompletedAt.HasValue && summary.CompletedAt.Value >= summary.StartedAt) + { + summary.DurationSeconds = (int)(summary.CompletedAt.Value - summary.StartedAt).TotalSeconds; + } + } + } + } + catch (Azure.RequestFailedException ex) when (ex.Status == 404) + { + // No existing record – nothing to merge + } + + 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) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(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(); + } + } + + /// + /// Gets all app install summaries across all tenants (for galactic admin mode) + /// + public async Task> GetAllAppInstallSummariesAsync() + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); + var query = tableClient.QueryAsync(); + + 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 all app install summaries"); + return new List(); + } + } + + 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, + TotalSignedUpTenants = entity.GetInt64("TotalSignedUpTenants") ?? 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, + ["TotalSignedUpTenants"] = stats.TotalSignedUpTenants, + ["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, + ["TotalSignedUpTenants"] = 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 ===== + + /// + /// Records a user login activity + /// PartitionKey: TenantId, RowKey: {invertedTicks}_{Guid} for reverse-chronological ordering + /// + public async Task RecordUserLoginAsync(string tenantId, string upn, string? displayName, string? objectId) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity); + var now = DateTime.UtcNow; + var invertedTicks = (DateTime.MaxValue.Ticks - now.Ticks).ToString("D20"); + + var entity = new TableEntity(tenantId, $"{invertedTicks}_{Guid.NewGuid():N}") + { + ["Upn"] = upn ?? string.Empty, + ["DisplayName"] = displayName ?? string.Empty, + ["ObjectId"] = objectId ?? string.Empty, + ["LoginAt"] = now + }; + + await tableClient.AddEntityAsync(entity); + _logger.LogDebug($"Recorded login for {upn} in tenant {tenantId}"); + } + catch (Exception ex) + { + // Don't fail the login if activity recording fails + _logger.LogWarning(ex, $"Failed to record login activity for {upn}"); + } + } + + /// + /// Gets user activity metrics for a specific tenant + /// + public async Task GetUserActivityMetricsAsync(string tenantId) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity); + var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{tenantId}'"); + + var now = DateTime.UtcNow; + var today = now.Date; + var last7Days = now.AddDays(-7); + var last30Days = now.AddDays(-30); + + var allUpns = new HashSet(StringComparer.OrdinalIgnoreCase); + var todayUpns = new HashSet(StringComparer.OrdinalIgnoreCase); + var last7Upns = new HashSet(StringComparer.OrdinalIgnoreCase); + var last30Upns = new HashSet(StringComparer.OrdinalIgnoreCase); + int todayLogins = 0; + + await foreach (var entity in query) + { + var upn = entity.GetString("Upn") ?? string.Empty; + var loginAt = entity.GetDateTime("LoginAt") ?? DateTime.MinValue; + + if (string.IsNullOrEmpty(upn)) continue; + + allUpns.Add(upn); + + if (loginAt >= last30Days) last30Upns.Add(upn); + if (loginAt >= last7Days) last7Upns.Add(upn); + if (loginAt >= today) + { + todayUpns.Add(upn); + todayLogins++; + } + } + + return new UserActivityMetrics + { + TotalUniqueUsers = allUpns.Count, + DailyLogins = todayLogins, + ActiveUsersLast7Days = last7Upns.Count, + ActiveUsersLast30Days = last30Upns.Count + }; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get user activity metrics for tenant {tenantId}"); + return new UserActivityMetrics(); + } + } + + /// + /// Gets user activity metrics across all tenants (for galactic admin) + /// + public async Task GetAllUserActivityMetricsAsync() + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity); + var query = tableClient.QueryAsync(); + + var now = DateTime.UtcNow; + var today = now.Date; + var last7Days = now.AddDays(-7); + var last30Days = now.AddDays(-30); + + var allUpns = new HashSet(StringComparer.OrdinalIgnoreCase); + var todayUpns = new HashSet(StringComparer.OrdinalIgnoreCase); + var last7Upns = new HashSet(StringComparer.OrdinalIgnoreCase); + var last30Upns = new HashSet(StringComparer.OrdinalIgnoreCase); + int todayLogins = 0; + + await foreach (var entity in query) + { + var upn = entity.GetString("Upn") ?? string.Empty; + var loginAt = entity.GetDateTime("LoginAt") ?? DateTime.MinValue; + + if (string.IsNullOrEmpty(upn)) continue; + + allUpns.Add(upn); + + if (loginAt >= last30Days) last30Upns.Add(upn); + if (loginAt >= last7Days) last7Upns.Add(upn); + if (loginAt >= today) + { + todayUpns.Add(upn); + todayLogins++; + } + } + + return new UserActivityMetrics + { + TotalUniqueUsers = allUpns.Count, + DailyLogins = todayLogins, + ActiveUsersLast7Days = last7Upns.Count, + ActiveUsersLast30Days = last30Upns.Count + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get all user activity metrics"); + return new UserActivityMetrics(); + } + } + + /// + /// Gets user login count for a specific date range (used by daily maintenance) + /// + public async Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date) + { + if (!string.IsNullOrEmpty(tenantId) && tenantId != "global") + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity); + var startOfDay = date.Date; + var endOfDay = startOfDay.AddDays(1); + + string filter; + if (!string.IsNullOrEmpty(tenantId) && tenantId != "global") + { + filter = $"PartitionKey eq '{tenantId}' and LoginAt ge datetime'{startOfDay:yyyy-MM-ddTHH:mm:ss}Z' and LoginAt lt datetime'{endOfDay:yyyy-MM-ddTHH:mm:ss}Z'"; + } + else + { + filter = $"LoginAt ge datetime'{startOfDay:yyyy-MM-ddTHH:mm:ss}Z' and LoginAt lt datetime'{endOfDay:yyyy-MM-ddTHH:mm:ss}Z'"; + } + + var query = tableClient.QueryAsync(filter: filter); + + var upns = new HashSet(StringComparer.OrdinalIgnoreCase); + int loginCount = 0; + + await foreach (var entity in query) + { + var upn = entity.GetString("Upn") ?? string.Empty; + if (!string.IsNullOrEmpty(upn)) + { + upns.Add(upn); + loginCount++; + } + } + + return (upns.Count, loginCount); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get user activity for date {date:yyyy-MM-dd}"); + return (0, 0); + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs new file mode 100644 index 00000000..98ef4d93 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs @@ -0,0 +1,512 @@ +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Services +{ + public partial class TableStorageService + { + // ===== RULE RESULTS METHODS ===== + + /// + /// Stores a rule evaluation result + /// PartitionKey: {TenantId}_{SessionId}, RowKey: RuleId + /// + public async Task StoreRuleResultAsync(RuleResult result) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults); + var partitionKey = $"{result.TenantId}_{result.SessionId}"; + + var entity = new TableEntity(partitionKey, result.RuleId) + { + ["ResultId"] = result.ResultId, + ["SessionId"] = result.SessionId, + ["TenantId"] = result.TenantId, + ["RuleId"] = result.RuleId, + ["RuleTitle"] = result.RuleTitle ?? string.Empty, + ["Severity"] = result.Severity ?? string.Empty, + ["Category"] = result.Category ?? string.Empty, + ["ConfidenceScore"] = result.ConfidenceScore, + ["Explanation"] = result.Explanation ?? string.Empty, + ["RemediationJson"] = JsonConvert.SerializeObject(result.Remediation ?? new List()), + ["RelatedDocsJson"] = JsonConvert.SerializeObject(result.RelatedDocs ?? new List()), + ["MatchedConditionsJson"] = JsonConvert.SerializeObject(result.MatchedConditions ?? new Dictionary()), + ["DetectedAt"] = result.DetectedAt + }; + + await tableClient.UpsertEntityAsync(entity); + _logger.LogInformation($"Stored rule result {result.RuleId} for session {result.SessionId}"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to store rule result {result.RuleId}"); + return false; + } + } + + /// + /// Gets all rule results for a session + /// + public async Task> GetRuleResultsAsync(string tenantId, string sessionId) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults); + var partitionKey = $"{tenantId}_{sessionId}"; + var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{partitionKey}'"); + + var results = new List(); + await foreach (var entity in query) + { + results.Add(new RuleResult + { + ResultId = entity.GetString("ResultId") ?? string.Empty, + SessionId = entity.GetString("SessionId") ?? string.Empty, + TenantId = entity.GetString("TenantId") ?? string.Empty, + RuleId = entity.GetString("RuleId") ?? entity.RowKey, + RuleTitle = entity.GetString("RuleTitle") ?? string.Empty, + Severity = entity.GetString("Severity") ?? string.Empty, + Category = entity.GetString("Category") ?? string.Empty, + ConfidenceScore = entity.GetInt32("ConfidenceScore") ?? 0, + Explanation = entity.GetString("Explanation") ?? string.Empty, + Remediation = DeserializeJson>(entity.GetString("RemediationJson")), + RelatedDocs = DeserializeJson>(entity.GetString("RelatedDocsJson")), + MatchedConditions = DeserializeMatchedConditions(entity.GetString("MatchedConditionsJson")), + DetectedAt = entity.GetDateTimeOffset("DetectedAt")?.UtcDateTime ?? DateTime.UtcNow + }); + } + + return results.OrderByDescending(r => r.ConfidenceScore).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get rule results for session {sessionId}"); + return new List(); + } + } + + // ===== GATHER RULES METHODS ===== + + /// + /// Stores or updates a gather rule + /// PartitionKey: TenantId (or "global" for built-in), RowKey: RuleId + /// + public async Task StoreGatherRuleAsync(GatherRule rule, string tenantId = "global") + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules); + + var entity = new TableEntity(tenantId, rule.RuleId) + { + ["Title"] = rule.Title ?? string.Empty, + ["Description"] = rule.Description ?? string.Empty, + ["Category"] = rule.Category ?? string.Empty, + ["Version"] = rule.Version ?? "1.0.0", + ["Author"] = rule.Author ?? "Autopilot Monitor", + ["Enabled"] = rule.Enabled, + ["IsBuiltIn"] = rule.IsBuiltIn, + ["IsCommunity"] = rule.IsCommunity, + ["CollectorType"] = rule.CollectorType ?? string.Empty, + ["Target"] = rule.Target ?? string.Empty, + ["ParametersJson"] = JsonConvert.SerializeObject(rule.Parameters ?? new Dictionary()), + ["Trigger"] = rule.Trigger ?? string.Empty, + ["IntervalSeconds"] = rule.IntervalSeconds, + ["TriggerPhase"] = rule.TriggerPhase ?? string.Empty, + ["TriggerEventType"] = rule.TriggerEventType ?? string.Empty, + ["OutputEventType"] = rule.OutputEventType ?? string.Empty, + ["OutputSeverity"] = rule.OutputSeverity ?? "Info", + ["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]), + ["CreatedAt"] = rule.CreatedAt, + ["UpdatedAt"] = rule.UpdatedAt + }; + + await tableClient.UpsertEntityAsync(entity); + _logger.LogDebug($"Stored gather rule {rule.RuleId} for {tenantId}"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to store gather rule {rule.RuleId}"); + return false; + } + } + + /// + /// Gets gather rules for a partition (tenant or "global") + /// + public async Task> GetGatherRulesAsync(string partitionKey) + { + if (partitionKey != "global") + SecurityValidator.EnsureValidGuid(partitionKey, nameof(partitionKey)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules); + var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{partitionKey}'"); + + var rules = new List(); + await foreach (var entity in query) + { + rules.Add(MapToGatherRule(entity)); + } + + return rules; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get gather rules for {partitionKey}"); + return new List(); + } + } + + /// + /// Deletes a gather rule + /// + public async Task DeleteGatherRuleAsync(string tenantId, string ruleId) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules); + await tableClient.DeleteEntityAsync(tenantId, ruleId); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to delete gather rule {ruleId}"); + return false; + } + } + + private GatherRule MapToGatherRule(TableEntity entity) + { + return new GatherRule + { + RuleId = entity.RowKey, + Title = entity.GetString("Title") ?? string.Empty, + Description = entity.GetString("Description") ?? string.Empty, + Category = entity.GetString("Category") ?? string.Empty, + Version = entity.GetString("Version") ?? "1.0.0", + Author = entity.GetString("Author") ?? "Autopilot Monitor", + Enabled = entity.GetBoolean("Enabled") ?? true, + IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false, + IsCommunity = entity.GetBoolean("IsCommunity") ?? false, + CollectorType = entity.GetString("CollectorType") ?? string.Empty, + Target = entity.GetString("Target") ?? string.Empty, + Parameters = DeserializeJson>(entity.GetString("ParametersJson")), + Trigger = entity.GetString("Trigger") ?? string.Empty, + IntervalSeconds = entity.GetInt32("IntervalSeconds"), + TriggerPhase = entity.GetString("TriggerPhase") ?? string.Empty, + TriggerEventType = entity.GetString("TriggerEventType") ?? string.Empty, + OutputEventType = entity.GetString("OutputEventType") ?? string.Empty, + OutputSeverity = entity.GetString("OutputSeverity") ?? "Info", + Tags = DeserializeJsonArray(entity.GetString("TagsJson")), + CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.UtcNow, + UpdatedAt = entity.GetDateTimeOffset("UpdatedAt")?.UtcDateTime ?? DateTime.UtcNow + }; + } + + // ===== RULE STATES METHODS ===== + + /// + /// Stores or updates the enabled/disabled state for a built-in or community rule per tenant + /// PartitionKey: TenantId, RowKey: RuleId + /// + public async Task StoreRuleStateAsync(string tenantId, string ruleId, bool enabled) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates); + + var entity = new TableEntity(tenantId, ruleId) + { + ["Enabled"] = enabled, + ["UpdatedAt"] = DateTime.UtcNow + }; + + await tableClient.UpsertEntityAsync(entity); + _logger.LogDebug($"Stored rule state {ruleId} for {tenantId}: enabled={enabled}"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to store rule state {ruleId} for {tenantId}"); + return false; + } + } + + /// + /// Gets all rule states for a tenant as a dictionary of ruleId → enabled + /// + public async Task> GetRuleStatesAsync(string tenantId) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates); + var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{tenantId}'"); + + var states = new Dictionary(); + await foreach (var entity in query) + { + states[entity.RowKey] = entity.GetBoolean("Enabled") ?? true; + } + + return states; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get rule states for {tenantId}"); + return new Dictionary(); + } + } + + /// + /// Deletes the rule state for a tenant (resets to rule's default enabled state) + /// + public async Task DeleteRuleStateAsync(string tenantId, string ruleId) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates); + await tableClient.DeleteEntityAsync(tenantId, ruleId); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to delete rule state {ruleId} for {tenantId}"); + return false; + } + } + + // ===== ANALYZE RULES METHODS ===== + + /// + /// Stores or updates an analyze rule + /// PartitionKey: TenantId (or "global" for built-in), RowKey: RuleId + /// + public async Task StoreAnalyzeRuleAsync(AnalyzeRule rule, string tenantId = "global") + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules); + + var entity = new TableEntity(tenantId, rule.RuleId) + { + ["Title"] = rule.Title ?? string.Empty, + ["Description"] = rule.Description ?? string.Empty, + ["Severity"] = rule.Severity ?? string.Empty, + ["Category"] = rule.Category ?? string.Empty, + ["Version"] = rule.Version ?? "1.0.0", + ["Author"] = rule.Author ?? "Autopilot Monitor", + ["Enabled"] = rule.Enabled, + ["IsBuiltIn"] = rule.IsBuiltIn, + ["IsCommunity"] = rule.IsCommunity, + ["Trigger"] = rule.Trigger ?? "single", + ["ConditionsJson"] = JsonConvert.SerializeObject(rule.Conditions ?? new List()), + ["BaseConfidence"] = rule.BaseConfidence, + ["ConfidenceFactorsJson"] = JsonConvert.SerializeObject(rule.ConfidenceFactors ?? new List()), + ["ConfidenceThreshold"] = rule.ConfidenceThreshold, + ["Explanation"] = rule.Explanation ?? string.Empty, + ["RemediationJson"] = JsonConvert.SerializeObject(rule.Remediation ?? new List()), + ["RelatedDocsJson"] = JsonConvert.SerializeObject(rule.RelatedDocs ?? new List()), + ["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]), + ["CreatedAt"] = rule.CreatedAt, + ["UpdatedAt"] = rule.UpdatedAt + }; + + await tableClient.UpsertEntityAsync(entity); + _logger.LogDebug($"Stored analyze rule {rule.RuleId} for {tenantId}"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to store analyze rule {rule.RuleId}"); + return false; + } + } + + /// + /// Gets analyze rules for a partition (tenant or "global") + /// + public async Task> GetAnalyzeRulesAsync(string partitionKey) + { + if (partitionKey != "global") + SecurityValidator.EnsureValidGuid(partitionKey, nameof(partitionKey)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules); + var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{partitionKey}'"); + + var rules = new List(); + await foreach (var entity in query) + { + rules.Add(MapToAnalyzeRule(entity)); + } + + return rules; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get analyze rules for {partitionKey}"); + return new List(); + } + } + + /// + /// Deletes an analyze rule + /// + public async Task DeleteAnalyzeRuleAsync(string tenantId, string ruleId) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules); + await tableClient.DeleteEntityAsync(tenantId, ruleId); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to delete analyze rule {ruleId}"); + return false; + } + } + + private AnalyzeRule MapToAnalyzeRule(TableEntity entity) + { + return new AnalyzeRule + { + RuleId = entity.RowKey, + Title = entity.GetString("Title") ?? string.Empty, + Description = entity.GetString("Description") ?? string.Empty, + Severity = entity.GetString("Severity") ?? string.Empty, + Category = entity.GetString("Category") ?? string.Empty, + Version = entity.GetString("Version") ?? "1.0.0", + Author = entity.GetString("Author") ?? "Autopilot Monitor", + Enabled = entity.GetBoolean("Enabled") ?? true, + IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false, + IsCommunity = entity.GetBoolean("IsCommunity") ?? false, + Trigger = entity.GetString("Trigger") ?? "single", + Conditions = DeserializeJson>(entity.GetString("ConditionsJson")), + BaseConfidence = entity.GetInt32("BaseConfidence") ?? 50, + ConfidenceFactors = DeserializeJson>(entity.GetString("ConfidenceFactorsJson")), + ConfidenceThreshold = entity.GetInt32("ConfidenceThreshold") ?? 40, + Explanation = entity.GetString("Explanation") ?? string.Empty, + Remediation = DeserializeJson>(entity.GetString("RemediationJson")), + RelatedDocs = DeserializeJson>(entity.GetString("RelatedDocsJson")), + Tags = DeserializeJsonArray(entity.GetString("TagsJson")), + CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.UtcNow, + UpdatedAt = entity.GetDateTimeOffset("UpdatedAt")?.UtcDateTime ?? DateTime.UtcNow + }; + } + + // ===== IME LOG PATTERNS METHODS ===== + + /// + /// Stores or updates an IME log pattern + /// PartitionKey: TenantId (or "global" for built-in), RowKey: PatternId + /// + public async Task StoreImeLogPatternAsync(ImeLogPattern pattern, string tenantId = "global") + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeLogPatterns); + + var entity = new TableEntity(tenantId, pattern.PatternId) + { + ["Category"] = pattern.Category ?? string.Empty, + ["Pattern"] = pattern.Pattern ?? string.Empty, + ["Action"] = pattern.Action ?? string.Empty, + ["ParametersJson"] = JsonConvert.SerializeObject(pattern.Parameters ?? new Dictionary()), + ["Enabled"] = pattern.Enabled, + ["Description"] = pattern.Description ?? string.Empty, + ["IsBuiltIn"] = pattern.IsBuiltIn + }; + + await tableClient.UpsertEntityAsync(entity); + _logger.LogDebug($"Stored IME log pattern {pattern.PatternId} for {tenantId}"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to store IME log pattern {pattern.PatternId}"); + return false; + } + } + + /// + /// Gets IME log patterns for a partition (tenant or "global") + /// + public async Task> GetImeLogPatternsAsync(string partitionKey) + { + if (partitionKey != "global") + SecurityValidator.EnsureValidGuid(partitionKey, nameof(partitionKey)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeLogPatterns); + var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{partitionKey}'"); + + var patterns = new List(); + await foreach (var entity in query) + { + patterns.Add(MapToImeLogPattern(entity)); + } + + return patterns; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get IME log patterns for {partitionKey}"); + return new List(); + } + } + + /// + /// Deletes an IME log pattern + /// + public async Task DeleteImeLogPatternAsync(string tenantId, string patternId) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeLogPatterns); + await tableClient.DeleteEntityAsync(tenantId, patternId); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to delete IME log pattern {patternId}"); + return false; + } + } + + private ImeLogPattern MapToImeLogPattern(TableEntity entity) + { + return new ImeLogPattern + { + PatternId = entity.RowKey, + Category = entity.GetString("Category") ?? string.Empty, + Pattern = entity.GetString("Pattern") ?? string.Empty, + Action = entity.GetString("Action") ?? string.Empty, + Parameters = DeserializeJson>(entity.GetString("ParametersJson")), + Enabled = entity.GetBoolean("Enabled") ?? true, + Description = entity.GetString("Description") ?? string.Empty, + IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false + }; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs new file mode 100644 index 00000000..cff6f115 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -0,0 +1,753 @@ +using Azure; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Services +{ + public partial class TableStorageService + { + // ===== SESSION MANAGEMENT METHODS ===== + + /// + /// Stores a session registration + /// + public async Task StoreSessionAsync(SessionRegistration registration) + { + SecurityValidator.EnsureValidGuid(registration.TenantId, "TenantId"); + SecurityValidator.EnsureValidGuid(registration.SessionId, "SessionId"); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + + // If the agent restarts with the same session ID, preserve timeline/progress fields + // from the existing session row instead of resetting them to "fresh start". + DateTime startedAt = registration.StartedAt; + int currentPhase = (int)EnrollmentPhase.Start; + string status = SessionStatus.InProgress.ToString(); + int eventCount = 0; + DateTime? completedAt = null; + string failureReason = string.Empty; + + try + { + var existing = await tableClient.GetEntityAsync(registration.TenantId, registration.SessionId); + var existingEntity = existing.Value; + + var existingStartedAt = existingEntity.GetDateTimeOffset("StartedAt")?.UtcDateTime; + if (existingStartedAt.HasValue && existingStartedAt.Value < startedAt) + startedAt = existingStartedAt.Value; + + currentPhase = existingEntity.GetInt32("CurrentPhase") ?? currentPhase; + status = existingEntity.GetString("Status") ?? status; + eventCount = existingEntity.GetInt32("EventCount") ?? eventCount; + completedAt = existingEntity.GetDateTimeOffset("CompletedAt")?.UtcDateTime; + failureReason = existingEntity.GetString("FailureReason") ?? string.Empty; + + // WhiteGlove resumption: if the existing session was in Pending state, + // this re-registration means the user has received the device and booted it. + // Transition back to InProgress for Part 2 of enrollment. + if (status == SessionStatus.Pending.ToString()) + { + _logger.LogInformation($"Session {registration.SessionId} resuming from Pending (WhiteGlove Part 2)"); + status = SessionStatus.InProgress.ToString(); + } + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + // New session row - use defaults above. + } + + // If events were ingested before session registration succeeded, align StartedAt + // with the earliest event we already have for this session. + var earliestEventTimestamp = await GetEarliestSessionEventTimestampAsync(registration.TenantId, registration.SessionId); + if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < startedAt) + { + startedAt = earliestEventTimestamp.Value; + } + + var entity = new TableEntity(registration.TenantId, registration.SessionId) + { + ["SerialNumber"] = registration.SerialNumber ?? string.Empty, + ["Manufacturer"] = registration.Manufacturer ?? string.Empty, + ["Model"] = registration.Model ?? string.Empty, + ["DeviceName"] = registration.DeviceName ?? string.Empty, + ["OsBuild"] = registration.OsBuild ?? string.Empty, + ["OsEdition"] = registration.OsEdition ?? string.Empty, + ["OsLanguage"] = registration.OsLanguage ?? string.Empty, + ["AutopilotProfileName"] = registration.AutopilotProfileName ?? string.Empty, + ["AutopilotProfileId"] = registration.AutopilotProfileId ?? string.Empty, + ["IsUserDriven"] = registration.IsUserDriven, + ["IsPreProvisioned"] = registration.IsPreProvisioned, + ["StartedAt"] = startedAt, + ["AgentVersion"] = registration.AgentVersion ?? string.Empty, + ["EnrollmentType"] = registration.EnrollmentType ?? "v1", + ["CurrentPhase"] = currentPhase, + ["Status"] = status, + ["EventCount"] = eventCount + }; + + if (completedAt.HasValue) + entity["CompletedAt"] = completedAt.Value; + + if (!string.IsNullOrWhiteSpace(failureReason)) + entity["FailureReason"] = failureReason; + + await tableClient.UpsertEntityAsync(entity); + _logger.LogInformation($"Stored session {registration.SessionId}"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to store session {registration.SessionId}"); + return false; + } + } + + // ===== EVENT MANAGEMENT METHODS ===== + + /// + /// Stores an event + /// + public async Task StoreEventAsync(EnrollmentEvent evt) + { + SecurityValidator.EnsureValidGuid(evt.TenantId, "TenantId"); + SecurityValidator.EnsureValidGuid(evt.SessionId, "SessionId"); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); + + // PartitionKey: TenantId_SessionId for efficient querying + // RowKey: Timestamp_Sequence for ordering + var partitionKey = $"{evt.TenantId}_{evt.SessionId}"; + var rowKey = $"{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence:D10}"; + + var entity = new TableEntity(partitionKey, rowKey) + { + ["EventId"] = evt.EventId, + ["SessionId"] = evt.SessionId, + ["TenantId"] = evt.TenantId, + ["Timestamp"] = evt.Timestamp, + ["EventType"] = evt.EventType ?? string.Empty, + ["Severity"] = (int)evt.Severity, + ["Source"] = evt.Source ?? string.Empty, + ["Phase"] = (int)evt.Phase, + ["Message"] = evt.Message ?? string.Empty, + ["Sequence"] = evt.Sequence, + ["DataJson"] = evt.Data != null && evt.Data.Count > 0 + ? JsonConvert.SerializeObject(evt.Data) + : string.Empty + }; + + await tableClient.UpsertEntityAsync(entity); + _logger.LogDebug($"Stored event {evt.EventId}"); + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to store event {evt.EventId}"); + return false; + } + } + + /// + /// Stores multiple events as batch transactions (Entity Group Transactions). + /// All events must share the same PartitionKey (TenantId_SessionId). + /// Azure Table Storage allows max 100 entities per transaction. + /// Falls back to individual writes if a batch fails. + /// + /// List of successfully stored events + public async Task> StoreEventsBatchAsync(List events) + { + if (events == null || events.Count == 0) + return new List(); + + // Validate all events upfront + foreach (var evt in events) + { + SecurityValidator.EnsureValidGuid(evt.TenantId, "TenantId"); + SecurityValidator.EnsureValidGuid(evt.SessionId, "SessionId"); + } + + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); + var storedEvents = new List(); + + // Group by PartitionKey (should be the same for all events in a request, but be safe) + var groups = events.GroupBy(e => $"{e.TenantId}_{e.SessionId}"); + + foreach (var group in groups) + { + // Chunk into batches of 100 (Azure Table Storage limit) + var chunks = group.Select((evt, index) => new { evt, index }) + .GroupBy(x => x.index / 100) + .Select(g => g.Select(x => x.evt).ToList()); + + foreach (var chunk in chunks) + { + try + { + var actions = chunk.Select(evt => + { + var partitionKey = $"{evt.TenantId}_{evt.SessionId}"; + var rowKey = $"{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence:D10}"; + + var entity = new TableEntity(partitionKey, rowKey) + { + ["EventId"] = evt.EventId, + ["SessionId"] = evt.SessionId, + ["TenantId"] = evt.TenantId, + ["Timestamp"] = evt.Timestamp, + ["EventType"] = evt.EventType ?? string.Empty, + ["Severity"] = (int)evt.Severity, + ["Source"] = evt.Source ?? string.Empty, + ["Phase"] = (int)evt.Phase, + ["Message"] = evt.Message ?? string.Empty, + ["Sequence"] = evt.Sequence, + ["DataJson"] = evt.Data != null && evt.Data.Count > 0 + ? JsonConvert.SerializeObject(evt.Data) + : string.Empty + }; + + return new TableTransactionAction(TableTransactionActionType.UpsertReplace, entity); + }).ToList(); + + await tableClient.SubmitTransactionAsync(actions); + storedEvents.AddRange(chunk); + _logger.LogDebug($"Batch stored {chunk.Count} events for partition {group.Key}"); + } + catch (Exception ex) + { + // Batch failed - fall back to individual writes for this chunk + _logger.LogWarning(ex, $"Batch write failed for {chunk.Count} events, falling back to individual writes"); + + foreach (var evt in chunk) + { + if (await StoreEventAsync(evt)) + { + storedEvents.Add(evt); + } + } + } + } + } + + return storedEvents; + } + + /// + /// Gets all sessions for a tenant + /// + public async Task> GetSessionsAsync(string tenantId, int maxResults = 100) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + var sessions = new List(); + + // Query sessions by tenant (PartitionKey) + // maxPerPage capped at 1000 (Azure Table Storage limit), pagination is automatic + var query = tableClient.QueryAsync( + filter: $"PartitionKey eq '{tenantId}'", + maxPerPage: Math.Min(maxResults, 1000) + ); + + await foreach (var entity in query) + { + sessions.Add(MapToSessionSummary(entity)); + if (sessions.Count >= maxResults) break; + } + + // Sort by StartedAt descending (most recent first) + return sessions.OrderByDescending(s => s.StartedAt).ToList(); + } + catch (Exception ex) + { + _logger.LogError("Failed to get sessions for tenant {TenantId}: {ExType}: {ExMessage}\n{StackTrace}", + tenantId, ex.GetType().Name, ex.Message, ex.StackTrace); + return new List(); + } + } + + /// + /// Gets all sessions across all tenants (for galactic admin mode) + /// + public async Task> GetAllSessionsAsync(int maxResults = 100) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + var sessions = new List(); + + // Query all sessions without tenant filter + // maxPerPage is capped at 1000 (Azure Table Storage limit); pagination is automatic + var query = tableClient.QueryAsync(maxPerPage: Math.Min(maxResults, 1000)); + + await foreach (var entity in query) + { + sessions.Add(MapToSessionSummary(entity)); + if (sessions.Count >= maxResults) break; + } + + // Sort by StartedAt descending (most recent first) + return sessions.OrderByDescending(s => s.StartedAt).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get all sessions"); + return new List(); + } + } + + /// + /// Gets a specific session + /// + public async Task GetSessionAsync(string tenantId, string sessionId) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + var entity = await tableClient.GetEntityAsync(tenantId, sessionId); + return MapToSessionSummary(entity.Value); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get session {sessionId}"); + return null; + } + } + + /// + /// Updates the session status and current phase + /// + public async Task UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + + const int maxRetries = 3; + int retryCount = 0; + + while (retryCount < maxRetries) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + + // Get the existing entity + var entity = await tableClient.GetEntityAsync(tenantId, sessionId); + var session = entity.Value; + + // Idempotency: if the session is already in a terminal state (Succeeded/Failed), + // do not overwrite it with another terminal state to prevent duplicate notifications. + var existingStatusStr = session.GetString("Status"); + if (status == SessionStatus.Succeeded || status == SessionStatus.Failed) + { + if (existingStatusStr == SessionStatus.Succeeded.ToString() || existingStatusStr == SessionStatus.Failed.ToString()) + { + _logger.LogInformation($"Session {sessionId} already in terminal state '{existingStatusStr}', skipping status update to '{status}'"); + return false; + } + } + + // Status promotion rule: a Pending session (WhiteGlove pre-provisioning complete) + // must not regress to InProgress via phase-change events from the resumed boot. + // The Pending → InProgress transition happens exclusively in StoreSessionAsync + // (agent re-registration at Boot 2). + if (status == SessionStatus.InProgress && existingStatusStr == SessionStatus.Pending.ToString()) + { + _logger.LogInformation($"Session {sessionId} is Pending (WhiteGlove), preserving status (InProgress blocked by promotion rule)"); + // Phase, timestamps, and event count are still updated below. + } + else + { + // Update status + session["Status"] = status.ToString(); + } + + // Update current phase if provided + if (currentPhase.HasValue) + { + session["CurrentPhase"] = (int)currentPhase.Value; + } + + var currentStartedAt = session.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue; + var effectiveEarliest = earliestEventTimestamp; + + // Self-heal StartedAt by also looking at persisted events in case an older event + // arrived in a previous request/batch. + var earliestStoredEventTimestamp = await GetEarliestSessionEventTimestampAsync(tenantId, sessionId); + if (earliestStoredEventTimestamp.HasValue && + (!effectiveEarliest.HasValue || earliestStoredEventTimestamp.Value < effectiveEarliest.Value)) + { + effectiveEarliest = earliestStoredEventTimestamp.Value; + } + + if (effectiveEarliest.HasValue && effectiveEarliest.Value < currentStartedAt) + { + session["StartedAt"] = effectiveEarliest.Value; + } + + // Set completion time if succeeded or failed + if (status == SessionStatus.Succeeded || status == SessionStatus.Failed) + { + // Use the provided completedAt timestamp (from event) if available, otherwise use current time + var effectiveCompletedAt = completedAt ?? DateTime.UtcNow; + session["CompletedAt"] = effectiveCompletedAt; + + // Store accurate DurationSeconds based on first event timestamp (not registration StartedAt) + // The agent registers before events arrive, so registration StartedAt is always earlier + // than the actual enrollment start — using the first event gives the true enrollment duration. + var durationStart = effectiveEarliest ?? currentStartedAt; + if (durationStart < effectiveCompletedAt) + session["DurationSeconds"] = (int)(effectiveCompletedAt - durationStart).TotalSeconds; + } + + // Track the most recent event timestamp for excessive data sender detection + if (latestEventTimestamp.HasValue) + { + var currentLastEventAt = session.GetDateTimeOffset("LastEventAt")?.UtcDateTime; + if (!currentLastEventAt.HasValue || latestEventTimestamp.Value > currentLastEventAt.Value) + session["LastEventAt"] = latestEventTimestamp.Value; + } + + // Set failure reason if failed + if (status == SessionStatus.Failed && !string.IsNullOrEmpty(failureReason)) + { + session["FailureReason"] = failureReason; + } + + // Update event count (full recount for accuracy on status changes) + var eventsTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); + var partitionKey = $"{tenantId}_{sessionId}"; + var eventCountFilter = $"PartitionKey eq '{partitionKey}'"; + var eventCount = 0; + await foreach (var _ in eventsTableClient.QueryAsync(eventCountFilter, select: new[] { "RowKey" })) + { + eventCount++; + } + session["EventCount"] = eventCount; + + // Update entity with optimistic concurrency + await tableClient.UpdateEntityAsync(session, session.ETag, TableUpdateMode.Replace); + + _logger.LogInformation($"Updated session {sessionId} status to {status}"); + return true; + } + catch (Azure.RequestFailedException ex) when (ex.Status == 412) // Precondition Failed (ETag conflict) + { + retryCount++; + if (retryCount >= maxRetries) + { + _logger.LogWarning($"Failed to update session {sessionId} status after {maxRetries} retries due to ETag conflicts"); + return false; + } + + // Exponential backoff: 50ms, 100ms, 200ms + await Task.Delay(50 * (int)Math.Pow(2, retryCount - 1)); + _logger.LogDebug($"Retrying session {sessionId} update (attempt {retryCount}/{maxRetries})"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to update session {sessionId} status"); + return false; + } + } + + return false; + } + + /// + /// Increments the session event count without touching status or phase fields. + /// Uses Merge mode to safely handle concurrent updates. + /// + public async Task IncrementSessionEventCountAsync(string tenantId, string sessionId, int increment, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + + const int maxRetries = 3; + int retryCount = 0; + + while (retryCount < maxRetries) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + var entity = await tableClient.GetEntityAsync(tenantId, sessionId); + var currentCount = entity.Value.GetInt32("EventCount") ?? 0; + + var update = new TableEntity(tenantId, sessionId) + { + ["EventCount"] = currentCount + increment + }; + + var currentStartedAt = entity.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue; + var effectiveEarliest = earliestEventTimestamp; + + // Self-heal StartedAt by checking already persisted events for this session. + var earliestStoredEventTimestamp = await GetEarliestSessionEventTimestampAsync(tenantId, sessionId); + if (earliestStoredEventTimestamp.HasValue && + (!effectiveEarliest.HasValue || earliestStoredEventTimestamp.Value < effectiveEarliest.Value)) + { + effectiveEarliest = earliestStoredEventTimestamp.Value; + } + + if (effectiveEarliest.HasValue && effectiveEarliest.Value < currentStartedAt) + { + update["StartedAt"] = effectiveEarliest.Value; + } + + // Track the most recent event timestamp for excessive data sender detection + if (latestEventTimestamp.HasValue) + { + var currentLastEventAt = entity.Value.GetDateTimeOffset("LastEventAt")?.UtcDateTime; + if (!currentLastEventAt.HasValue || latestEventTimestamp.Value > currentLastEventAt.Value) + update["LastEventAt"] = latestEventTimestamp.Value; + } + + await tableClient.UpdateEntityAsync(update, entity.Value.ETag, TableUpdateMode.Merge); + return; + } + catch (Azure.RequestFailedException ex) when (ex.Status == 412) + { + retryCount++; + if (retryCount >= maxRetries) + { + _logger.LogWarning($"Failed to increment event count for session {sessionId} after {maxRetries} retries due to ETag conflicts"); + return; + } + await Task.Delay(50 * (int)Math.Pow(2, retryCount - 1)); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to increment event count for session {sessionId}"); + return; + } + } + } + + /// + /// Gets all events for a specific session + /// + public async Task> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); + var events = new List(); + + // Events are stored with PartitionKey = "{TenantId}_{SessionId}" + var partitionKey = $"{tenantId}_{sessionId}"; + + var query = tableClient.QueryAsync( + filter: $"PartitionKey eq '{partitionKey}'", + maxPerPage: maxResults + ); + + await foreach (var entity in query) + { + events.Add(MapToEnrollmentEvent(entity)); + } + + // Sort by Timestamp ascending (chronological order) + return events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to get events for session {sessionId}"); + return new List(); + } + } + + /// + /// Stores the diagnostics blob name on an existing session (Merge-mode, single field update). + /// + public async Task UpdateSessionDiagnosticsBlobAsync(string tenantId, string sessionId, string blobName) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + var entity = await tableClient.GetEntityAsync(tenantId, sessionId); + + var update = new TableEntity(tenantId, sessionId) + { + ["DiagnosticsBlobName"] = blobName + }; + + await tableClient.UpdateEntityAsync(update, entity.Value.ETag, Azure.Data.Tables.TableUpdateMode.Merge); + _logger.LogInformation($"Stored diagnostics blob name for session {sessionId}: {blobName}"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to store diagnostics blob name for session {sessionId}"); + } + } + + /// + /// Sets the IsPreProvisioned flag on an existing session (Merge-mode, single field update). + /// + public async Task SetSessionPreProvisionedAsync(string tenantId, string sessionId, bool isPreProvisioned) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + var entity = await tableClient.GetEntityAsync(tenantId, sessionId); + + var update = new TableEntity(tenantId, sessionId) + { + ["IsPreProvisioned"] = isPreProvisioned + }; + + await tableClient.UpdateEntityAsync(update, entity.Value.ETag, Azure.Data.Tables.TableUpdateMode.Merge); + _logger.LogInformation($"Set IsPreProvisioned={isPreProvisioned} for session {sessionId}"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to set IsPreProvisioned for session {sessionId}"); + } + } + + /// + /// Deletes a session from storage + /// + public async Task DeleteSessionAsync(string tenantId, string sessionId) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + var response = await tableClient.DeleteEntityAsync(tenantId, sessionId); + _logger.LogInformation($"Deleted session {sessionId} for tenant {tenantId}"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to delete session {sessionId}"); + return false; + } + } + + // ===== SESSION/EVENT MAPPING HELPERS ===== + + /// + /// Maps a TableEntity to EnrollmentEvent + /// + private EnrollmentEvent MapToEnrollmentEvent(TableEntity entity) + { + return new EnrollmentEvent + { + EventId = entity.GetString("EventId") ?? string.Empty, + SessionId = entity.GetString("SessionId") ?? string.Empty, + TenantId = entity.PartitionKey, + Timestamp = DateTime.SpecifyKind( + entity.GetDateTimeOffset("Timestamp")?.UtcDateTime + ?? entity.GetDateTime("Timestamp") + ?? DateTime.UtcNow, DateTimeKind.Utc), + EventType = entity.GetString("EventType") ?? string.Empty, + Severity = (EventSeverity)(entity.GetInt32("Severity") ?? 0), + Source = entity.GetString("Source") ?? string.Empty, + Phase = (EnrollmentPhase)(entity.GetInt32("Phase") ?? 0), + Message = entity.GetString("Message") ?? string.Empty, + Sequence = entity.GetInt64("Sequence") ?? 0, + Data = DeserializeEventData(entity.GetString("DataJson")), + RowKey = entity.RowKey + }; + } + + /// + /// Maps a TableEntity to SessionSummary + /// + private SessionSummary MapToSessionSummary(TableEntity entity) + { + // All typed getters (GetInt32, GetDateTime, etc.) throw InvalidOperationException + // when a property exists but has a different type (e.g. legacy data stored as string + // instead of int). Use safe helpers to handle type mismatches gracefully. + var startedAt = SafeGetDateTime(entity, "StartedAt") ?? DateTime.UtcNow; + var completedAt = SafeGetDateTime(entity, "CompletedAt"); + + // Parse status with error handling and case-insensitivity + var statusString = entity.GetString("Status") ?? "InProgress"; + if (!Enum.TryParse(statusString, ignoreCase: true, out var status)) + { + _logger.LogWarning($"Failed to parse status '{statusString}' for session {entity.RowKey}, defaulting to Unknown"); + status = SessionStatus.Unknown; + } + + return new SessionSummary + { + SessionId = entity.RowKey, + TenantId = entity.PartitionKey, + SerialNumber = entity.GetString("SerialNumber") ?? string.Empty, + DeviceName = entity.GetString("DeviceName") ?? string.Empty, + Manufacturer = entity.GetString("Manufacturer") ?? string.Empty, + Model = entity.GetString("Model") ?? string.Empty, + StartedAt = startedAt, + CompletedAt = completedAt, + CurrentPhase = SafeGetInt32(entity, "CurrentPhase") ?? 0, + CurrentPhaseDetail = entity.GetString("CurrentPhaseDetail") ?? string.Empty, + Status = status, + FailureReason = entity.GetString("FailureReason") ?? string.Empty, + EventCount = SafeGetInt32(entity, "EventCount") ?? 0, + DurationSeconds = SafeGetInt32(entity, "DurationSeconds") + ?? (completedAt.HasValue + ? (int)(completedAt.Value - startedAt).TotalSeconds + : (int)(DateTime.UtcNow - startedAt).TotalSeconds), + EnrollmentType = entity.GetString("EnrollmentType") ?? "v1", + DiagnosticsBlobName = entity.GetString("DiagnosticsBlobName"), + LastEventAt = SafeGetDateTime(entity, "LastEventAt"), + IsPreProvisioned = entity.GetBoolean("IsPreProvisioned") ?? false + }; + } + + /// + /// Returns the earliest event timestamp persisted for a session, if any. + /// Events are written with RowKey "{Timestamp}_{Sequence}", so querying the partition + /// and taking the first row yields the earliest event. + /// + private async Task GetEarliestSessionEventTimestampAsync(string tenantId, string sessionId) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); + var partitionKey = $"{tenantId}_{sessionId}"; + var query = tableClient.QueryAsync( + filter: $"PartitionKey eq '{partitionKey}'", + maxPerPage: 1, + select: new[] { "Timestamp", "RowKey" } + ); + + await foreach (var entity in query) + { + return entity.GetDateTimeOffset("Timestamp")?.UtcDateTime + ?? entity.GetDateTime("Timestamp"); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, $"Could not determine earliest event timestamp for session {sessionId}"); + } + + return null; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs index a23db59b..33f7773c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs @@ -11,9 +11,15 @@ using Newtonsoft.Json.Linq; namespace AutopilotMonitor.Functions.Services { /// - /// Service for managing Azure Table Storage operations + /// Service for managing Azure Table Storage operations. + /// Split into partial class files by domain: + /// - TableStorageService.cs (this file: core, initialization, helpers) + /// - TableStorageService.Sessions.cs (sessions, events, mapping) + /// - TableStorageService.Rules.cs (gather/analyze rules, rule states, IME patterns) + /// - TableStorageService.Metrics.cs (usage metrics, platform stats, user activity, app installs) + /// - TableStorageService.Maintenance.cs (audit logs, data retention, deletion helpers) /// - public class TableStorageService + public partial class TableStorageService { private readonly TableServiceClient _tableServiceClient; private readonly ILogger _logger; @@ -79,689 +85,7 @@ namespace AutopilotMonitor.Functions.Services /// public TableServiceClient GetTableServiceClient() => _tableServiceClient; - /// - /// Stores a session registration - /// - public async Task StoreSessionAsync(SessionRegistration registration) - { - SecurityValidator.EnsureValidGuid(registration.TenantId, "TenantId"); - SecurityValidator.EnsureValidGuid(registration.SessionId, "SessionId"); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - - // If the agent restarts with the same session ID, preserve timeline/progress fields - // from the existing session row instead of resetting them to "fresh start". - DateTime startedAt = registration.StartedAt; - int currentPhase = (int)EnrollmentPhase.Start; - string status = SessionStatus.InProgress.ToString(); - int eventCount = 0; - DateTime? completedAt = null; - string failureReason = string.Empty; - - try - { - var existing = await tableClient.GetEntityAsync(registration.TenantId, registration.SessionId); - var existingEntity = existing.Value; - - var existingStartedAt = existingEntity.GetDateTimeOffset("StartedAt")?.UtcDateTime; - if (existingStartedAt.HasValue && existingStartedAt.Value < startedAt) - startedAt = existingStartedAt.Value; - - currentPhase = existingEntity.GetInt32("CurrentPhase") ?? currentPhase; - status = existingEntity.GetString("Status") ?? status; - eventCount = existingEntity.GetInt32("EventCount") ?? eventCount; - completedAt = existingEntity.GetDateTimeOffset("CompletedAt")?.UtcDateTime; - failureReason = existingEntity.GetString("FailureReason") ?? string.Empty; - - // WhiteGlove resumption: if the existing session was in Pending state, - // this re-registration means the user has received the device and booted it. - // Transition back to InProgress for Part 2 of enrollment. - if (status == SessionStatus.Pending.ToString()) - { - _logger.LogInformation($"Session {registration.SessionId} resuming from Pending (WhiteGlove Part 2)"); - status = SessionStatus.InProgress.ToString(); - } - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - // New session row - use defaults above. - } - - // If events were ingested before session registration succeeded, align StartedAt - // with the earliest event we already have for this session. - var earliestEventTimestamp = await GetEarliestSessionEventTimestampAsync(registration.TenantId, registration.SessionId); - if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < startedAt) - { - startedAt = earliestEventTimestamp.Value; - } - - var entity = new TableEntity(registration.TenantId, registration.SessionId) - { - ["SerialNumber"] = registration.SerialNumber ?? string.Empty, - ["Manufacturer"] = registration.Manufacturer ?? string.Empty, - ["Model"] = registration.Model ?? string.Empty, - ["DeviceName"] = registration.DeviceName ?? string.Empty, - ["OsBuild"] = registration.OsBuild ?? string.Empty, - ["OsEdition"] = registration.OsEdition ?? string.Empty, - ["OsLanguage"] = registration.OsLanguage ?? string.Empty, - ["AutopilotProfileName"] = registration.AutopilotProfileName ?? string.Empty, - ["AutopilotProfileId"] = registration.AutopilotProfileId ?? string.Empty, - ["IsUserDriven"] = registration.IsUserDriven, - ["IsPreProvisioned"] = registration.IsPreProvisioned, - ["StartedAt"] = startedAt, - ["AgentVersion"] = registration.AgentVersion ?? string.Empty, - ["EnrollmentType"] = registration.EnrollmentType ?? "v1", - ["CurrentPhase"] = currentPhase, - ["Status"] = status, - ["EventCount"] = eventCount - }; - - if (completedAt.HasValue) - entity["CompletedAt"] = completedAt.Value; - - if (!string.IsNullOrWhiteSpace(failureReason)) - entity["FailureReason"] = failureReason; - - await tableClient.UpsertEntityAsync(entity); - _logger.LogInformation($"Stored session {registration.SessionId}"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to store session {registration.SessionId}"); - return false; - } - } - - /// - /// Stores an event - /// - public async Task StoreEventAsync(EnrollmentEvent evt) - { - SecurityValidator.EnsureValidGuid(evt.TenantId, "TenantId"); - SecurityValidator.EnsureValidGuid(evt.SessionId, "SessionId"); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); - - // PartitionKey: TenantId_SessionId for efficient querying - // RowKey: Timestamp_Sequence for ordering - var partitionKey = $"{evt.TenantId}_{evt.SessionId}"; - var rowKey = $"{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence:D10}"; - - var entity = new TableEntity(partitionKey, rowKey) - { - ["EventId"] = evt.EventId, - ["SessionId"] = evt.SessionId, - ["TenantId"] = evt.TenantId, - ["Timestamp"] = evt.Timestamp, - ["EventType"] = evt.EventType ?? string.Empty, - ["Severity"] = (int)evt.Severity, - ["Source"] = evt.Source ?? string.Empty, - ["Phase"] = (int)evt.Phase, - ["Message"] = evt.Message ?? string.Empty, - ["Sequence"] = evt.Sequence, - ["DataJson"] = evt.Data != null && evt.Data.Count > 0 - ? JsonConvert.SerializeObject(evt.Data) - : string.Empty - }; - - await tableClient.UpsertEntityAsync(entity); - _logger.LogDebug($"Stored event {evt.EventId}"); - - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to store event {evt.EventId}"); - return false; - } - } - - /// - /// Stores multiple events as batch transactions (Entity Group Transactions). - /// All events must share the same PartitionKey (TenantId_SessionId). - /// Azure Table Storage allows max 100 entities per transaction. - /// Falls back to individual writes if a batch fails. - /// - /// List of successfully stored events - public async Task> StoreEventsBatchAsync(List events) - { - if (events == null || events.Count == 0) - return new List(); - - // Validate all events upfront - foreach (var evt in events) - { - SecurityValidator.EnsureValidGuid(evt.TenantId, "TenantId"); - SecurityValidator.EnsureValidGuid(evt.SessionId, "SessionId"); - } - - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); - var storedEvents = new List(); - - // Group by PartitionKey (should be the same for all events in a request, but be safe) - var groups = events.GroupBy(e => $"{e.TenantId}_{e.SessionId}"); - - foreach (var group in groups) - { - // Chunk into batches of 100 (Azure Table Storage limit) - var chunks = group.Select((evt, index) => new { evt, index }) - .GroupBy(x => x.index / 100) - .Select(g => g.Select(x => x.evt).ToList()); - - foreach (var chunk in chunks) - { - try - { - var actions = chunk.Select(evt => - { - var partitionKey = $"{evt.TenantId}_{evt.SessionId}"; - var rowKey = $"{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence:D10}"; - - var entity = new TableEntity(partitionKey, rowKey) - { - ["EventId"] = evt.EventId, - ["SessionId"] = evt.SessionId, - ["TenantId"] = evt.TenantId, - ["Timestamp"] = evt.Timestamp, - ["EventType"] = evt.EventType ?? string.Empty, - ["Severity"] = (int)evt.Severity, - ["Source"] = evt.Source ?? string.Empty, - ["Phase"] = (int)evt.Phase, - ["Message"] = evt.Message ?? string.Empty, - ["Sequence"] = evt.Sequence, - ["DataJson"] = evt.Data != null && evt.Data.Count > 0 - ? JsonConvert.SerializeObject(evt.Data) - : string.Empty - }; - - return new TableTransactionAction(TableTransactionActionType.UpsertReplace, entity); - }).ToList(); - - await tableClient.SubmitTransactionAsync(actions); - storedEvents.AddRange(chunk); - _logger.LogDebug($"Batch stored {chunk.Count} events for partition {group.Key}"); - } - catch (Exception ex) - { - // Batch failed - fall back to individual writes for this chunk - _logger.LogWarning(ex, $"Batch write failed for {chunk.Count} events, falling back to individual writes"); - - foreach (var evt in chunk) - { - if (await StoreEventAsync(evt)) - { - storedEvents.Add(evt); - } - } - } - } - } - - return storedEvents; - } - - /// - /// Gets all sessions for a tenant - /// - public async Task> GetSessionsAsync(string tenantId, int maxResults = 100) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - var sessions = new List(); - - // Query sessions by tenant (PartitionKey) - // maxPerPage capped at 1000 (Azure Table Storage limit), pagination is automatic - var query = tableClient.QueryAsync( - filter: $"PartitionKey eq '{tenantId}'", - maxPerPage: Math.Min(maxResults, 1000) - ); - - await foreach (var entity in query) - { - sessions.Add(MapToSessionSummary(entity)); - if (sessions.Count >= maxResults) break; - } - - // Sort by StartedAt descending (most recent first) - return sessions.OrderByDescending(s => s.StartedAt).ToList(); - } - catch (Exception ex) - { - _logger.LogError("Failed to get sessions for tenant {TenantId}: {ExType}: {ExMessage}\n{StackTrace}", - tenantId, ex.GetType().Name, ex.Message, ex.StackTrace); - return new List(); - } - } - - /// - /// Gets all sessions across all tenants (for galactic admin mode) - /// - public async Task> GetAllSessionsAsync(int maxResults = 100) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - var sessions = new List(); - - // Query all sessions without tenant filter - // maxPerPage is capped at 1000 (Azure Table Storage limit); pagination is automatic - var query = tableClient.QueryAsync(maxPerPage: Math.Min(maxResults, 1000)); - - await foreach (var entity in query) - { - sessions.Add(MapToSessionSummary(entity)); - if (sessions.Count >= maxResults) break; - } - - // Sort by StartedAt descending (most recent first) - return sessions.OrderByDescending(s => s.StartedAt).ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get all sessions"); - return new List(); - } - } - - /// - /// Gets a specific session - /// - public async Task GetSessionAsync(string tenantId, string sessionId) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - var entity = await tableClient.GetEntityAsync(tenantId, sessionId); - return MapToSessionSummary(entity.Value); - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get session {sessionId}"); - return null; - } - } - - /// - /// Updates the session status and current phase - /// - public async Task UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); - - const int maxRetries = 3; - int retryCount = 0; - - while (retryCount < maxRetries) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - - // Get the existing entity - var entity = await tableClient.GetEntityAsync(tenantId, sessionId); - var session = entity.Value; - - // Idempotency: if the session is already in a terminal state (Succeeded/Failed), - // do not overwrite it with another terminal state to prevent duplicate notifications. - var existingStatusStr = session.GetString("Status"); - if (status == SessionStatus.Succeeded || status == SessionStatus.Failed) - { - if (existingStatusStr == SessionStatus.Succeeded.ToString() || existingStatusStr == SessionStatus.Failed.ToString()) - { - _logger.LogInformation($"Session {sessionId} already in terminal state '{existingStatusStr}', skipping status update to '{status}'"); - return false; - } - } - - // Status promotion rule: a Pending session (WhiteGlove pre-provisioning complete) - // must not regress to InProgress via phase-change events from the resumed boot. - // The Pending → InProgress transition happens exclusively in StoreSessionAsync - // (agent re-registration at Boot 2). - if (status == SessionStatus.InProgress && existingStatusStr == SessionStatus.Pending.ToString()) - { - _logger.LogInformation($"Session {sessionId} is Pending (WhiteGlove), preserving status (InProgress blocked by promotion rule)"); - // Phase, timestamps, and event count are still updated below. - } - else - { - // Update status - session["Status"] = status.ToString(); - } - - // Update current phase if provided - if (currentPhase.HasValue) - { - session["CurrentPhase"] = (int)currentPhase.Value; - } - - var currentStartedAt = session.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue; - var effectiveEarliest = earliestEventTimestamp; - - // Self-heal StartedAt by also looking at persisted events in case an older event - // arrived in a previous request/batch. - var earliestStoredEventTimestamp = await GetEarliestSessionEventTimestampAsync(tenantId, sessionId); - if (earliestStoredEventTimestamp.HasValue && - (!effectiveEarliest.HasValue || earliestStoredEventTimestamp.Value < effectiveEarliest.Value)) - { - effectiveEarliest = earliestStoredEventTimestamp.Value; - } - - if (effectiveEarliest.HasValue && effectiveEarliest.Value < currentStartedAt) - { - session["StartedAt"] = effectiveEarliest.Value; - } - - // Set completion time if succeeded or failed - if (status == SessionStatus.Succeeded || status == SessionStatus.Failed) - { - // Use the provided completedAt timestamp (from event) if available, otherwise use current time - var effectiveCompletedAt = completedAt ?? DateTime.UtcNow; - session["CompletedAt"] = effectiveCompletedAt; - - // Store accurate DurationSeconds based on first event timestamp (not registration StartedAt) - // The agent registers before events arrive, so registration StartedAt is always earlier - // than the actual enrollment start — using the first event gives the true enrollment duration. - var durationStart = effectiveEarliest ?? currentStartedAt; - if (durationStart < effectiveCompletedAt) - session["DurationSeconds"] = (int)(effectiveCompletedAt - durationStart).TotalSeconds; - } - - // Track the most recent event timestamp for excessive data sender detection - if (latestEventTimestamp.HasValue) - { - var currentLastEventAt = session.GetDateTimeOffset("LastEventAt")?.UtcDateTime; - if (!currentLastEventAt.HasValue || latestEventTimestamp.Value > currentLastEventAt.Value) - session["LastEventAt"] = latestEventTimestamp.Value; - } - - // Set failure reason if failed - if (status == SessionStatus.Failed && !string.IsNullOrEmpty(failureReason)) - { - session["FailureReason"] = failureReason; - } - - // Update event count (full recount for accuracy on status changes) - var eventsTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); - var partitionKey = $"{tenantId}_{sessionId}"; - var eventCountFilter = $"PartitionKey eq '{partitionKey}'"; - var eventCount = 0; - await foreach (var _ in eventsTableClient.QueryAsync(eventCountFilter, select: new[] { "RowKey" })) - { - eventCount++; - } - session["EventCount"] = eventCount; - - // Update entity with optimistic concurrency - await tableClient.UpdateEntityAsync(session, session.ETag, TableUpdateMode.Replace); - - _logger.LogInformation($"Updated session {sessionId} status to {status}"); - return true; - } - catch (Azure.RequestFailedException ex) when (ex.Status == 412) // Precondition Failed (ETag conflict) - { - retryCount++; - if (retryCount >= maxRetries) - { - _logger.LogWarning($"Failed to update session {sessionId} status after {maxRetries} retries due to ETag conflicts"); - return false; - } - - // Exponential backoff: 50ms, 100ms, 200ms - await Task.Delay(50 * (int)Math.Pow(2, retryCount - 1)); - _logger.LogDebug($"Retrying session {sessionId} update (attempt {retryCount}/{maxRetries})"); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to update session {sessionId} status"); - return false; - } - } - - return false; - } - - /// - /// Increments the session event count without touching status or phase fields. - /// Uses Merge mode to safely handle concurrent updates. - /// - public async Task IncrementSessionEventCountAsync(string tenantId, string sessionId, int increment, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); - - const int maxRetries = 3; - int retryCount = 0; - - while (retryCount < maxRetries) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - var entity = await tableClient.GetEntityAsync(tenantId, sessionId); - var currentCount = entity.Value.GetInt32("EventCount") ?? 0; - - var update = new TableEntity(tenantId, sessionId) - { - ["EventCount"] = currentCount + increment - }; - - var currentStartedAt = entity.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue; - var effectiveEarliest = earliestEventTimestamp; - - // Self-heal StartedAt by checking already persisted events for this session. - var earliestStoredEventTimestamp = await GetEarliestSessionEventTimestampAsync(tenantId, sessionId); - if (earliestStoredEventTimestamp.HasValue && - (!effectiveEarliest.HasValue || earliestStoredEventTimestamp.Value < effectiveEarliest.Value)) - { - effectiveEarliest = earliestStoredEventTimestamp.Value; - } - - if (effectiveEarliest.HasValue && effectiveEarliest.Value < currentStartedAt) - { - update["StartedAt"] = effectiveEarliest.Value; - } - - // Track the most recent event timestamp for excessive data sender detection - if (latestEventTimestamp.HasValue) - { - var currentLastEventAt = entity.Value.GetDateTimeOffset("LastEventAt")?.UtcDateTime; - if (!currentLastEventAt.HasValue || latestEventTimestamp.Value > currentLastEventAt.Value) - update["LastEventAt"] = latestEventTimestamp.Value; - } - - await tableClient.UpdateEntityAsync(update, entity.Value.ETag, TableUpdateMode.Merge); - return; - } - catch (Azure.RequestFailedException ex) when (ex.Status == 412) - { - retryCount++; - if (retryCount >= maxRetries) - { - _logger.LogWarning($"Failed to increment event count for session {sessionId} after {maxRetries} retries due to ETag conflicts"); - return; - } - await Task.Delay(50 * (int)Math.Pow(2, retryCount - 1)); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to increment event count for session {sessionId}"); - return; - } - } - } - - /// - /// Gets all events for a specific session - /// - public async Task> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); - var events = new List(); - - // Events are stored with PartitionKey = "{TenantId}_{SessionId}" - var partitionKey = $"{tenantId}_{sessionId}"; - - var query = tableClient.QueryAsync( - filter: $"PartitionKey eq '{partitionKey}'", - maxPerPage: maxResults - ); - - await foreach (var entity in query) - { - events.Add(MapToEnrollmentEvent(entity)); - } - - // Sort by Timestamp ascending (chronological order) - return events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get events for session {sessionId}"); - return new List(); - } - } - - /// - /// Maps a TableEntity to EnrollmentEvent - /// - private EnrollmentEvent MapToEnrollmentEvent(TableEntity entity) - { - return new EnrollmentEvent - { - EventId = entity.GetString("EventId") ?? string.Empty, - SessionId = entity.GetString("SessionId") ?? string.Empty, - TenantId = entity.PartitionKey, - Timestamp = DateTime.SpecifyKind( - entity.GetDateTimeOffset("Timestamp")?.UtcDateTime - ?? entity.GetDateTime("Timestamp") - ?? DateTime.UtcNow, DateTimeKind.Utc), - EventType = entity.GetString("EventType") ?? string.Empty, - Severity = (EventSeverity)(entity.GetInt32("Severity") ?? 0), - Source = entity.GetString("Source") ?? string.Empty, - Phase = (EnrollmentPhase)(entity.GetInt32("Phase") ?? 0), - Message = entity.GetString("Message") ?? string.Empty, - Sequence = entity.GetInt64("Sequence") ?? 0, - Data = DeserializeEventData(entity.GetString("DataJson")), - RowKey = entity.RowKey - }; - } - - /// - /// Maps a TableEntity to SessionSummary - /// - private SessionSummary MapToSessionSummary(TableEntity entity) - { - // All typed getters (GetInt32, GetDateTime, etc.) throw InvalidOperationException - // when a property exists but has a different type (e.g. legacy data stored as string - // instead of int). Use safe helpers to handle type mismatches gracefully. - var startedAt = SafeGetDateTime(entity, "StartedAt") ?? DateTime.UtcNow; - var completedAt = SafeGetDateTime(entity, "CompletedAt"); - - // Parse status with error handling and case-insensitivity - var statusString = entity.GetString("Status") ?? "InProgress"; - if (!Enum.TryParse(statusString, ignoreCase: true, out var status)) - { - _logger.LogWarning($"Failed to parse status '{statusString}' for session {entity.RowKey}, defaulting to Unknown"); - status = SessionStatus.Unknown; - } - - return new SessionSummary - { - SessionId = entity.RowKey, - TenantId = entity.PartitionKey, - SerialNumber = entity.GetString("SerialNumber") ?? string.Empty, - DeviceName = entity.GetString("DeviceName") ?? string.Empty, - Manufacturer = entity.GetString("Manufacturer") ?? string.Empty, - Model = entity.GetString("Model") ?? string.Empty, - StartedAt = startedAt, - CompletedAt = completedAt, - CurrentPhase = SafeGetInt32(entity, "CurrentPhase") ?? 0, - CurrentPhaseDetail = entity.GetString("CurrentPhaseDetail") ?? string.Empty, - Status = status, - FailureReason = entity.GetString("FailureReason") ?? string.Empty, - EventCount = SafeGetInt32(entity, "EventCount") ?? 0, - DurationSeconds = SafeGetInt32(entity, "DurationSeconds") - ?? (completedAt.HasValue - ? (int)(completedAt.Value - startedAt).TotalSeconds - : (int)(DateTime.UtcNow - startedAt).TotalSeconds), - EnrollmentType = entity.GetString("EnrollmentType") ?? "v1", - DiagnosticsBlobName = entity.GetString("DiagnosticsBlobName"), - LastEventAt = SafeGetDateTime(entity, "LastEventAt"), - IsPreProvisioned = entity.GetBoolean("IsPreProvisioned") ?? false - }; - } - - /// - /// Stores the diagnostics blob name on an existing session (Merge-mode, single field update). - /// - public async Task UpdateSessionDiagnosticsBlobAsync(string tenantId, string sessionId, string blobName) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - var entity = await tableClient.GetEntityAsync(tenantId, sessionId); - - var update = new TableEntity(tenantId, sessionId) - { - ["DiagnosticsBlobName"] = blobName - }; - - await tableClient.UpdateEntityAsync(update, entity.Value.ETag, Azure.Data.Tables.TableUpdateMode.Merge); - _logger.LogInformation($"Stored diagnostics blob name for session {sessionId}: {blobName}"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"Failed to store diagnostics blob name for session {sessionId}"); - } - } - - /// - /// Sets the IsPreProvisioned flag on an existing session (Merge-mode, single field update). - /// - public async Task SetSessionPreProvisionedAsync(string tenantId, string sessionId, bool isPreProvisioned) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - var entity = await tableClient.GetEntityAsync(tenantId, sessionId); - - var update = new TableEntity(tenantId, sessionId) - { - ["IsPreProvisioned"] = isPreProvisioned - }; - - await tableClient.UpdateEntityAsync(update, entity.Value.ETag, Azure.Data.Tables.TableUpdateMode.Merge); - _logger.LogInformation($"Set IsPreProvisioned={isPreProvisioned} for session {sessionId}"); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to set IsPreProvisioned for session {sessionId}"); - } - } + // ===== HELPER METHODS ===== /// /// Safely reads an Int32 property from a TableEntity. @@ -803,1460 +127,6 @@ namespace AutopilotMonitor.Functions.Services } } - /// - /// Deletes a session from storage - /// - public async Task DeleteSessionAsync(string tenantId, string sessionId) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - var response = await tableClient.DeleteEntityAsync(tenantId, sessionId); - _logger.LogInformation($"Deleted session {sessionId} for tenant {tenantId}"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to delete session {sessionId}"); - return false; - } - } - - /// - /// Deletes all events for a session from storage - /// - public async Task DeleteSessionEventsAsync(string tenantId, string sessionId) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); - - // Query all events for this session using the same PartitionKey format as StoreEventAsync - var partitionKey = $"{tenantId}_{sessionId}"; - var filter = $"PartitionKey eq '{partitionKey}'"; - var events = tableClient.QueryAsync(filter); - - int deletedCount = 0; - var batch = new List(); - await foreach (var eventEntity in events) - { - batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, eventEntity)); - deletedCount++; - if (batch.Count >= 100) - { - await tableClient.SubmitTransactionAsync(batch); - batch.Clear(); - } - } - if (batch.Count > 0) - { - await tableClient.SubmitTransactionAsync(batch); - } - - _logger.LogInformation($"Deleted {deletedCount} events for session {sessionId}"); - return deletedCount; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to delete events for session {sessionId}"); - return 0; - } - } - - /// - /// Logs an audit entry - /// - public async Task LogAuditEntryAsync(string tenantId, string action, string entityType, string entityId, string performedBy, Dictionary? details = null) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs); - var timestamp = DateTime.UtcNow; - - var entity = new TableEntity(tenantId, Guid.NewGuid().ToString()) - { - { "Action", action }, - { "EntityType", entityType }, - { "EntityId", entityId }, - { "PerformedBy", performedBy }, - { "Timestamp", timestamp }, - { "Details", details != null ? JsonConvert.SerializeObject(details) : string.Empty } - }; - - await tableClient.AddEntityAsync(entity); - _logger.LogInformation($"Audit log created: {action} on {entityType} {entityId} by {performedBy}"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to create audit log entry"); - return false; - } - } - - /// - /// Gets audit log entries for a tenant - /// - public async Task> GetAuditLogsAsync(string tenantId, int maxResults = 100) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs); - var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{tenantId}'"); - - var logs = new List(); - await foreach (var entity in query) - { - logs.Add(new AuditLogEntry - { - Id = entity.RowKey, - TenantId = entity.PartitionKey, - Action = entity.GetString("Action") ?? string.Empty, - EntityType = entity.GetString("EntityType") ?? string.Empty, - EntityId = entity.GetString("EntityId") ?? string.Empty, - PerformedBy = entity.GetString("PerformedBy") ?? string.Empty, - Timestamp = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.UtcNow, - Details = entity.GetString("Details") ?? string.Empty - }); - - if (logs.Count >= maxResults) break; - } - - // Sort by timestamp descending (most recent first) - return logs.OrderByDescending(l => l.Timestamp).Take(maxResults).ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get audit logs for tenant {tenantId}"); - return new List(); - } - } - - // ===== HISTORICAL METRICS METHODS ===== - - /// - /// Saves a historical metrics snapshot - /// - public async Task SaveUsageMetricsSnapshotAsync(UsageMetricsSnapshot metrics) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics); - - var entity = new TableEntity(metrics.Date, metrics.TenantId) - { - ["ComputedAt"] = metrics.ComputedAt, - ["ComputeDurationMs"] = metrics.ComputeDurationMs, - ["SessionsTotal"] = metrics.SessionsTotal, - ["SessionsSucceeded"] = metrics.SessionsSucceeded, - ["SessionsFailed"] = metrics.SessionsFailed, - ["SessionsInProgress"] = metrics.SessionsInProgress, - ["SessionsSuccessRate"] = metrics.SessionsSuccessRate, - ["AvgDurationMinutes"] = metrics.AvgDurationMinutes, - ["MedianDurationMinutes"] = metrics.MedianDurationMinutes, - ["P95DurationMinutes"] = metrics.P95DurationMinutes, - ["P99DurationMinutes"] = metrics.P99DurationMinutes, - ["UniqueTenants"] = metrics.UniqueTenants, - ["UniqueUsers"] = metrics.UniqueUsers, - ["LoginCount"] = metrics.LoginCount, - ["TopManufacturers"] = metrics.TopManufacturers, - ["TopModels"] = metrics.TopModels - }; - - await tableClient.UpsertEntityAsync(entity); - _logger.LogInformation($"Saved historical metrics for {metrics.Date} / {metrics.TenantId}"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to save historical metrics for {metrics.Date} / {metrics.TenantId}"); - return false; - } - } - - /// - /// Gets historical metrics for a date range - /// - public async Task> GetUsageMetricsSnapshotAsync(string? tenantId = null, string? startDate = null, string? endDate = null, int maxResults = 100) - { - if (!string.IsNullOrEmpty(tenantId)) - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics); - - // Build filter - var filters = new List(); - - if (!string.IsNullOrEmpty(startDate)) - filters.Add($"PartitionKey ge '{startDate}'"); - - if (!string.IsNullOrEmpty(endDate)) - filters.Add($"PartitionKey le '{endDate}'"); - - if (!string.IsNullOrEmpty(tenantId)) - filters.Add($"RowKey eq '{tenantId}'"); - - var filter = filters.Count > 0 ? string.Join(" and ", filters) : null; - var query = tableClient.QueryAsync(filter: filter); - - var results = new List(); - await foreach (var entity in query) - { - results.Add(new UsageMetricsSnapshot - { - Date = entity.PartitionKey, - TenantId = entity.RowKey, - ComputedAt = entity.GetDateTimeOffset("ComputedAt")?.UtcDateTime ?? DateTime.UtcNow, - ComputeDurationMs = entity.GetInt32("ComputeDurationMs") ?? 0, - SessionsTotal = entity.GetInt32("SessionsTotal") ?? 0, - SessionsSucceeded = entity.GetInt32("SessionsSucceeded") ?? 0, - SessionsFailed = entity.GetInt32("SessionsFailed") ?? 0, - SessionsInProgress = entity.GetInt32("SessionsInProgress") ?? 0, - SessionsSuccessRate = entity.GetDouble("SessionsSuccessRate") ?? 0, - AvgDurationMinutes = entity.GetDouble("AvgDurationMinutes") ?? 0, - MedianDurationMinutes = entity.GetDouble("MedianDurationMinutes") ?? 0, - P95DurationMinutes = entity.GetDouble("P95DurationMinutes") ?? 0, - P99DurationMinutes = entity.GetDouble("P99DurationMinutes") ?? 0, - UniqueTenants = entity.GetInt32("UniqueTenants") ?? 0, - UniqueUsers = entity.GetInt32("UniqueUsers") ?? 0, - LoginCount = entity.GetInt32("LoginCount") ?? 0, - TopManufacturers = entity.GetString("TopManufacturers") ?? "[]", - TopModels = entity.GetString("TopModels") ?? "[]" - }); - - if (results.Count >= maxResults) break; - } - - return results.OrderByDescending(m => m.Date).Take(maxResults).ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get historical metrics"); - return new List(); - } - } - - /// - /// Checks if a global usage metrics snapshot exists for a given date. - /// Used by maintenance catch-up to determine which dates need aggregation. - /// - public async Task HasUsageMetricsSnapshotAsync(string date) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics); - await tableClient.GetEntityAsync(date, "global"); - return true; - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - return false; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to check usage metrics snapshot for {date}"); - return false; - } - } - - // ===== DATA RETENTION METHODS ===== - - /// - /// Gets sessions older than a specific date for a tenant - /// - public async Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - - try - { - 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'"; - var query = tableClient.QueryAsync(filter: filter); - - var sessions = new List(); - await foreach (var entity in query) - { - sessions.Add(MapToSessionSummary(entity)); - } - - _logger.LogInformation($"Found {sessions.Count} sessions older than {cutoffDate:yyyy-MM-dd} for tenant {tenantId}"); - return sessions; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get old sessions for tenant {tenantId}"); - return new List(); - } - } - - /// - /// Gets all sessions within a date range, optionally filtered by tenant. - /// Uses server-side filtering to avoid loading all sessions into memory. - /// - public async Task> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null) - { - if (!string.IsNullOrEmpty(tenantId)) - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - - var filter = !string.IsNullOrEmpty(tenantId) - ? $"PartitionKey eq '{tenantId}' and StartedAt ge datetime'{startDate:yyyy-MM-ddTHH:mm:ss}Z' and StartedAt lt datetime'{endDate:yyyy-MM-ddTHH:mm:ss}Z'" - : $"StartedAt ge datetime'{startDate:yyyy-MM-ddTHH:mm:ss}Z' and StartedAt lt datetime'{endDate:yyyy-MM-ddTHH:mm:ss}Z'"; - - var query = tableClient.QueryAsync(filter: filter); - - var sessions = new List(); - await foreach (var entity in query) - { - sessions.Add(MapToSessionSummary(entity)); - } - - _logger.LogInformation($"Found {sessions.Count} sessions between {startDate:yyyy-MM-dd} and {endDate:yyyy-MM-dd}" + - (tenantId != null ? $" for tenant {tenantId}" : "")); - return sessions; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get sessions by date range"); - return new List(); - } - } - - /// - /// Gets stalled sessions (InProgress status, started before cutoff time) for a tenant. - /// Uses server-side filtering to avoid loading all sessions into memory. - /// - public async Task> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - - var filter = $"PartitionKey eq '{tenantId}' and Status eq 'InProgress' and StartedAt lt datetime'{cutoffTime:yyyy-MM-ddTHH:mm:ss}Z'"; - var query = tableClient.QueryAsync(filter: filter); - - var sessions = new List(); - await foreach (var entity in query) - { - sessions.Add(MapToSessionSummary(entity)); - } - - return sessions; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get stalled sessions for tenant {tenantId}"); - return new List(); - } - } - - /// - /// Gets sessions where the device has sent data within the last window period - /// AND the session started before that window cutoff – indicating a device that has been - /// actively sending data for longer than the allowed maximum. - /// Status-independent: detects excessive data senders regardless of session status. - /// Uses LastEventAt (written on every event batch) for the "still active" check. - /// Sessions without LastEventAt (predating this field) are not returned. - /// - public async Task> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - - // LastEventAt gt cutoff → device sent data recently (still active) - // StartedAt lt cutoff → session has been running longer than the allowed window - // IsPreProvisioned ne true → exclude WhiteGlove sessions: a pre-provisioned device - // that resumes after weeks in storage looks like an excessive sender (StartedAt old, - // LastEventAt recent) but is a legitimate resumption, not abuse. - var cutoffStr = windowCutoff.ToString("yyyy-MM-ddTHH:mm:ss"); - var filter = $"PartitionKey eq '{tenantId}' " + - $"and LastEventAt gt datetime'{cutoffStr}Z' " + - $"and StartedAt lt datetime'{cutoffStr}Z' " + - $"and IsPreProvisioned ne true"; - - var query = tableClient.QueryAsync(filter: filter); - - var sessions = new List(); - await foreach (var entity in query) - { - sessions.Add(MapToSessionSummary(entity)); - } - - return sessions; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get excessive data sender sessions for tenant {tenantId}"); - return new List(); - } - } - - /// - /// Gets all unique tenant IDs from sessions table - /// - public async Task> GetAllTenantIdsAsync() - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); - var query = tableClient.QueryAsync(select: new[] { "PartitionKey" }); - - var tenantIds = new HashSet(); - await foreach (var entity in query) - { - tenantIds.Add(entity.PartitionKey); - } - - _logger.LogInformation($"Found {tenantIds.Count} unique tenants"); - return tenantIds.ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get tenant IDs"); - return new List(); - } - } - - // ===== RULE RESULTS METHODS ===== - - /// - /// Stores a rule evaluation result - /// PartitionKey: {TenantId}_{SessionId}, RowKey: RuleId - /// - public async Task StoreRuleResultAsync(RuleResult result) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults); - var partitionKey = $"{result.TenantId}_{result.SessionId}"; - - var entity = new TableEntity(partitionKey, result.RuleId) - { - ["ResultId"] = result.ResultId, - ["SessionId"] = result.SessionId, - ["TenantId"] = result.TenantId, - ["RuleId"] = result.RuleId, - ["RuleTitle"] = result.RuleTitle ?? string.Empty, - ["Severity"] = result.Severity ?? string.Empty, - ["Category"] = result.Category ?? string.Empty, - ["ConfidenceScore"] = result.ConfidenceScore, - ["Explanation"] = result.Explanation ?? string.Empty, - ["RemediationJson"] = JsonConvert.SerializeObject(result.Remediation ?? new List()), - ["RelatedDocsJson"] = JsonConvert.SerializeObject(result.RelatedDocs ?? new List()), - ["MatchedConditionsJson"] = JsonConvert.SerializeObject(result.MatchedConditions ?? new Dictionary()), - ["DetectedAt"] = result.DetectedAt - }; - - await tableClient.UpsertEntityAsync(entity); - _logger.LogInformation($"Stored rule result {result.RuleId} for session {result.SessionId}"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to store rule result {result.RuleId}"); - return false; - } - } - - /// - /// Gets all rule results for a session - /// - public async Task> GetRuleResultsAsync(string tenantId, string sessionId) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults); - var partitionKey = $"{tenantId}_{sessionId}"; - var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{partitionKey}'"); - - var results = new List(); - await foreach (var entity in query) - { - results.Add(new RuleResult - { - ResultId = entity.GetString("ResultId") ?? string.Empty, - SessionId = entity.GetString("SessionId") ?? string.Empty, - TenantId = entity.GetString("TenantId") ?? string.Empty, - RuleId = entity.GetString("RuleId") ?? entity.RowKey, - RuleTitle = entity.GetString("RuleTitle") ?? string.Empty, - Severity = entity.GetString("Severity") ?? string.Empty, - Category = entity.GetString("Category") ?? string.Empty, - ConfidenceScore = entity.GetInt32("ConfidenceScore") ?? 0, - Explanation = entity.GetString("Explanation") ?? string.Empty, - Remediation = DeserializeJson>(entity.GetString("RemediationJson")), - RelatedDocs = DeserializeJson>(entity.GetString("RelatedDocsJson")), - MatchedConditions = DeserializeMatchedConditions(entity.GetString("MatchedConditionsJson")), - DetectedAt = entity.GetDateTimeOffset("DetectedAt")?.UtcDateTime ?? DateTime.UtcNow - }); - } - - return results.OrderByDescending(r => r.ConfidenceScore).ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get rule results for session {sessionId}"); - return new List(); - } - } - - // ===== GATHER RULES METHODS ===== - - /// - /// Stores or updates a gather rule - /// PartitionKey: TenantId (or "global" for built-in), RowKey: RuleId - /// - public async Task StoreGatherRuleAsync(GatherRule rule, string tenantId = "global") - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules); - - var entity = new TableEntity(tenantId, rule.RuleId) - { - ["Title"] = rule.Title ?? string.Empty, - ["Description"] = rule.Description ?? string.Empty, - ["Category"] = rule.Category ?? string.Empty, - ["Version"] = rule.Version ?? "1.0.0", - ["Author"] = rule.Author ?? "Autopilot Monitor", - ["Enabled"] = rule.Enabled, - ["IsBuiltIn"] = rule.IsBuiltIn, - ["IsCommunity"] = rule.IsCommunity, - ["CollectorType"] = rule.CollectorType ?? string.Empty, - ["Target"] = rule.Target ?? string.Empty, - ["ParametersJson"] = JsonConvert.SerializeObject(rule.Parameters ?? new Dictionary()), - ["Trigger"] = rule.Trigger ?? string.Empty, - ["IntervalSeconds"] = rule.IntervalSeconds, - ["TriggerPhase"] = rule.TriggerPhase ?? string.Empty, - ["TriggerEventType"] = rule.TriggerEventType ?? string.Empty, - ["OutputEventType"] = rule.OutputEventType ?? string.Empty, - ["OutputSeverity"] = rule.OutputSeverity ?? "Info", - ["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]), - ["CreatedAt"] = rule.CreatedAt, - ["UpdatedAt"] = rule.UpdatedAt - }; - - await tableClient.UpsertEntityAsync(entity); - _logger.LogDebug($"Stored gather rule {rule.RuleId} for {tenantId}"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to store gather rule {rule.RuleId}"); - return false; - } - } - - /// - /// Gets gather rules for a partition (tenant or "global") - /// - public async Task> GetGatherRulesAsync(string partitionKey) - { - if (partitionKey != "global") - SecurityValidator.EnsureValidGuid(partitionKey, nameof(partitionKey)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules); - var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{partitionKey}'"); - - var rules = new List(); - await foreach (var entity in query) - { - rules.Add(MapToGatherRule(entity)); - } - - return rules; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get gather rules for {partitionKey}"); - return new List(); - } - } - - /// - /// Deletes a gather rule - /// - public async Task DeleteGatherRuleAsync(string tenantId, string ruleId) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules); - await tableClient.DeleteEntityAsync(tenantId, ruleId); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to delete gather rule {ruleId}"); - return false; - } - } - - // ===== RULE STATES METHODS ===== - - /// - /// Stores or updates the enabled/disabled state for a built-in or community rule per tenant - /// PartitionKey: TenantId, RowKey: RuleId - /// - public async Task StoreRuleStateAsync(string tenantId, string ruleId, bool enabled) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates); - - var entity = new TableEntity(tenantId, ruleId) - { - ["Enabled"] = enabled, - ["UpdatedAt"] = DateTime.UtcNow - }; - - await tableClient.UpsertEntityAsync(entity); - _logger.LogDebug($"Stored rule state {ruleId} for {tenantId}: enabled={enabled}"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to store rule state {ruleId} for {tenantId}"); - return false; - } - } - - /// - /// Gets all rule states for a tenant as a dictionary of ruleId → enabled - /// - public async Task> GetRuleStatesAsync(string tenantId) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates); - var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{tenantId}'"); - - var states = new Dictionary(); - await foreach (var entity in query) - { - states[entity.RowKey] = entity.GetBoolean("Enabled") ?? true; - } - - return states; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get rule states for {tenantId}"); - return new Dictionary(); - } - } - - /// - /// Deletes the rule state for a tenant (resets to rule's default enabled state) - /// - public async Task DeleteRuleStateAsync(string tenantId, string ruleId) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates); - await tableClient.DeleteEntityAsync(tenantId, ruleId); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to delete rule state {ruleId} for {tenantId}"); - return false; - } - } - - private GatherRule MapToGatherRule(TableEntity entity) - { - return new GatherRule - { - RuleId = entity.RowKey, - Title = entity.GetString("Title") ?? string.Empty, - Description = entity.GetString("Description") ?? string.Empty, - Category = entity.GetString("Category") ?? string.Empty, - Version = entity.GetString("Version") ?? "1.0.0", - Author = entity.GetString("Author") ?? "Autopilot Monitor", - Enabled = entity.GetBoolean("Enabled") ?? true, - IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false, - IsCommunity = entity.GetBoolean("IsCommunity") ?? false, - CollectorType = entity.GetString("CollectorType") ?? string.Empty, - Target = entity.GetString("Target") ?? string.Empty, - Parameters = DeserializeJson>(entity.GetString("ParametersJson")), - Trigger = entity.GetString("Trigger") ?? string.Empty, - IntervalSeconds = entity.GetInt32("IntervalSeconds"), - TriggerPhase = entity.GetString("TriggerPhase") ?? string.Empty, - TriggerEventType = entity.GetString("TriggerEventType") ?? string.Empty, - OutputEventType = entity.GetString("OutputEventType") ?? string.Empty, - OutputSeverity = entity.GetString("OutputSeverity") ?? "Info", - Tags = DeserializeJsonArray(entity.GetString("TagsJson")), - CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.UtcNow, - UpdatedAt = entity.GetDateTimeOffset("UpdatedAt")?.UtcDateTime ?? DateTime.UtcNow - }; - } - - // ===== ANALYZE RULES METHODS ===== - - /// - /// Stores or updates an analyze rule - /// PartitionKey: TenantId (or "global" for built-in), RowKey: RuleId - /// - public async Task StoreAnalyzeRuleAsync(AnalyzeRule rule, string tenantId = "global") - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules); - - var entity = new TableEntity(tenantId, rule.RuleId) - { - ["Title"] = rule.Title ?? string.Empty, - ["Description"] = rule.Description ?? string.Empty, - ["Severity"] = rule.Severity ?? string.Empty, - ["Category"] = rule.Category ?? string.Empty, - ["Version"] = rule.Version ?? "1.0.0", - ["Author"] = rule.Author ?? "Autopilot Monitor", - ["Enabled"] = rule.Enabled, - ["IsBuiltIn"] = rule.IsBuiltIn, - ["IsCommunity"] = rule.IsCommunity, - ["Trigger"] = rule.Trigger ?? "single", - ["ConditionsJson"] = JsonConvert.SerializeObject(rule.Conditions ?? new List()), - ["BaseConfidence"] = rule.BaseConfidence, - ["ConfidenceFactorsJson"] = JsonConvert.SerializeObject(rule.ConfidenceFactors ?? new List()), - ["ConfidenceThreshold"] = rule.ConfidenceThreshold, - ["Explanation"] = rule.Explanation ?? string.Empty, - ["RemediationJson"] = JsonConvert.SerializeObject(rule.Remediation ?? new List()), - ["RelatedDocsJson"] = JsonConvert.SerializeObject(rule.RelatedDocs ?? new List()), - ["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]), - ["CreatedAt"] = rule.CreatedAt, - ["UpdatedAt"] = rule.UpdatedAt - }; - - await tableClient.UpsertEntityAsync(entity); - _logger.LogDebug($"Stored analyze rule {rule.RuleId} for {tenantId}"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to store analyze rule {rule.RuleId}"); - return false; - } - } - - /// - /// Gets analyze rules for a partition (tenant or "global") - /// - public async Task> GetAnalyzeRulesAsync(string partitionKey) - { - if (partitionKey != "global") - SecurityValidator.EnsureValidGuid(partitionKey, nameof(partitionKey)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules); - var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{partitionKey}'"); - - var rules = new List(); - await foreach (var entity in query) - { - rules.Add(MapToAnalyzeRule(entity)); - } - - return rules; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get analyze rules for {partitionKey}"); - return new List(); - } - } - - /// - /// Deletes an analyze rule - /// - public async Task DeleteAnalyzeRuleAsync(string tenantId, string ruleId) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules); - await tableClient.DeleteEntityAsync(tenantId, ruleId); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to delete analyze rule {ruleId}"); - return false; - } - } - - private AnalyzeRule MapToAnalyzeRule(TableEntity entity) - { - return new AnalyzeRule - { - RuleId = entity.RowKey, - Title = entity.GetString("Title") ?? string.Empty, - Description = entity.GetString("Description") ?? string.Empty, - Severity = entity.GetString("Severity") ?? string.Empty, - Category = entity.GetString("Category") ?? string.Empty, - Version = entity.GetString("Version") ?? "1.0.0", - Author = entity.GetString("Author") ?? "Autopilot Monitor", - Enabled = entity.GetBoolean("Enabled") ?? true, - IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false, - IsCommunity = entity.GetBoolean("IsCommunity") ?? false, - Trigger = entity.GetString("Trigger") ?? "single", - Conditions = DeserializeJson>(entity.GetString("ConditionsJson")), - BaseConfidence = entity.GetInt32("BaseConfidence") ?? 50, - ConfidenceFactors = DeserializeJson>(entity.GetString("ConfidenceFactorsJson")), - ConfidenceThreshold = entity.GetInt32("ConfidenceThreshold") ?? 40, - Explanation = entity.GetString("Explanation") ?? string.Empty, - Remediation = DeserializeJson>(entity.GetString("RemediationJson")), - RelatedDocs = DeserializeJson>(entity.GetString("RelatedDocsJson")), - Tags = DeserializeJsonArray(entity.GetString("TagsJson")), - CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.UtcNow, - UpdatedAt = entity.GetDateTimeOffset("UpdatedAt")?.UtcDateTime ?? DateTime.UtcNow - }; - } - - // ===== IME LOG PATTERNS METHODS ===== - - /// - /// Stores or updates an IME log pattern - /// PartitionKey: TenantId (or "global" for built-in), RowKey: PatternId - /// - public async Task StoreImeLogPatternAsync(ImeLogPattern pattern, string tenantId = "global") - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeLogPatterns); - - var entity = new TableEntity(tenantId, pattern.PatternId) - { - ["Category"] = pattern.Category ?? string.Empty, - ["Pattern"] = pattern.Pattern ?? string.Empty, - ["Action"] = pattern.Action ?? string.Empty, - ["ParametersJson"] = JsonConvert.SerializeObject(pattern.Parameters ?? new Dictionary()), - ["Enabled"] = pattern.Enabled, - ["Description"] = pattern.Description ?? string.Empty, - ["IsBuiltIn"] = pattern.IsBuiltIn - }; - - await tableClient.UpsertEntityAsync(entity); - _logger.LogDebug($"Stored IME log pattern {pattern.PatternId} for {tenantId}"); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to store IME log pattern {pattern.PatternId}"); - return false; - } - } - - /// - /// Gets IME log patterns for a partition (tenant or "global") - /// - public async Task> GetImeLogPatternsAsync(string partitionKey) - { - if (partitionKey != "global") - SecurityValidator.EnsureValidGuid(partitionKey, nameof(partitionKey)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeLogPatterns); - var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{partitionKey}'"); - - var patterns = new List(); - await foreach (var entity in query) - { - patterns.Add(MapToImeLogPattern(entity)); - } - - return patterns; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get IME log patterns for {partitionKey}"); - return new List(); - } - } - - /// - /// Deletes an IME log pattern - /// - public async Task DeleteImeLogPatternAsync(string tenantId, string patternId) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeLogPatterns); - await tableClient.DeleteEntityAsync(tenantId, patternId); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to delete IME log pattern {patternId}"); - return false; - } - } - - private ImeLogPattern MapToImeLogPattern(TableEntity entity) - { - return new ImeLogPattern - { - PatternId = entity.RowKey, - Category = entity.GetString("Category") ?? string.Empty, - Pattern = entity.GetString("Pattern") ?? string.Empty, - Action = entity.GetString("Action") ?? string.Empty, - Parameters = DeserializeJson>(entity.GetString("ParametersJson")), - Enabled = entity.GetBoolean("Enabled") ?? true, - Description = entity.GetString("Description") ?? string.Empty, - IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false - }; - } - - // ===== APP INSTALL SUMMARIES METHODS ===== - - /// - /// Stores or updates an app install summary. - /// Merges with any existing record so StartedAt is never overwritten with a later timestamp. - /// 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}"; - - // Merge with existing record to preserve StartedAt from a prior batch - try - { - var existing = await tableClient.GetEntityAsync(summary.TenantId, rowKey); - var existingStartedAt = existing.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime; - if (existingStartedAt.HasValue && existingStartedAt.Value != DateTime.MinValue) - { - // Keep the earlier StartedAt; recalculate duration if CompletedAt is now known - if (summary.StartedAt == DateTime.MinValue || existingStartedAt.Value < summary.StartedAt) - { - summary.StartedAt = existingStartedAt.Value; - if (summary.CompletedAt.HasValue && summary.CompletedAt.Value >= summary.StartedAt) - { - summary.DurationSeconds = (int)(summary.CompletedAt.Value - summary.StartedAt).TotalSeconds; - } - } - } - } - catch (Azure.RequestFailedException ex) when (ex.Status == 404) - { - // No existing record – nothing to merge - } - - 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) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(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(); - } - } - - /// - /// Gets all app install summaries across all tenants (for galactic admin mode) - /// - public async Task> GetAllAppInstallSummariesAsync() - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); - var query = tableClient.QueryAsync(); - - 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 all app install summaries"); - return new List(); - } - } - - /// - /// Deletes all app install summaries for a session - /// - public async Task DeleteSessionAppInstallSummariesAsync(string tenantId, string sessionId) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); - var query = tableClient.QueryAsync( - filter: $"PartitionKey eq '{tenantId}' and SessionId eq '{sessionId}'"); - - int deletedCount = 0; - var batch = new List(); - await foreach (var entity in query) - { - batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity)); - deletedCount++; - if (batch.Count >= 100) - { - await tableClient.SubmitTransactionAsync(batch); - batch.Clear(); - } - } - if (batch.Count > 0) - { - await tableClient.SubmitTransactionAsync(batch); - } - - 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) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults); - var partitionKey = $"{tenantId}_{sessionId}"; - var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{partitionKey}'"); - - int deletedCount = 0; - var batch = new List(); - await foreach (var entity in query) - { - batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity)); - deletedCount++; - if (batch.Count >= 100) - { - await tableClient.SubmitTransactionAsync(batch); - batch.Clear(); - } - } - if (batch.Count > 0) - { - await tableClient.SubmitTransactionAsync(batch); - } - - 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, - TotalSignedUpTenants = entity.GetInt64("TotalSignedUpTenants") ?? 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, - ["TotalSignedUpTenants"] = stats.TotalSignedUpTenants, - ["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, - ["TotalSignedUpTenants"] = 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 ===== - - /// - /// Records a user login activity - /// PartitionKey: TenantId, RowKey: {invertedTicks}_{Guid} for reverse-chronological ordering - /// - public async Task RecordUserLoginAsync(string tenantId, string upn, string? displayName, string? objectId) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity); - var now = DateTime.UtcNow; - var invertedTicks = (DateTime.MaxValue.Ticks - now.Ticks).ToString("D20"); - - var entity = new TableEntity(tenantId, $"{invertedTicks}_{Guid.NewGuid():N}") - { - ["Upn"] = upn ?? string.Empty, - ["DisplayName"] = displayName ?? string.Empty, - ["ObjectId"] = objectId ?? string.Empty, - ["LoginAt"] = now - }; - - await tableClient.AddEntityAsync(entity); - _logger.LogDebug($"Recorded login for {upn} in tenant {tenantId}"); - } - catch (Exception ex) - { - // Don't fail the login if activity recording fails - _logger.LogWarning(ex, $"Failed to record login activity for {upn}"); - } - } - - /// - /// Gets user activity metrics for a specific tenant - /// - public async Task GetUserActivityMetricsAsync(string tenantId) - { - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity); - var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{tenantId}'"); - - var now = DateTime.UtcNow; - var today = now.Date; - var last7Days = now.AddDays(-7); - var last30Days = now.AddDays(-30); - - var allUpns = new HashSet(StringComparer.OrdinalIgnoreCase); - var todayUpns = new HashSet(StringComparer.OrdinalIgnoreCase); - var last7Upns = new HashSet(StringComparer.OrdinalIgnoreCase); - var last30Upns = new HashSet(StringComparer.OrdinalIgnoreCase); - int todayLogins = 0; - - await foreach (var entity in query) - { - var upn = entity.GetString("Upn") ?? string.Empty; - var loginAt = entity.GetDateTime("LoginAt") ?? DateTime.MinValue; - - if (string.IsNullOrEmpty(upn)) continue; - - allUpns.Add(upn); - - if (loginAt >= last30Days) last30Upns.Add(upn); - if (loginAt >= last7Days) last7Upns.Add(upn); - if (loginAt >= today) - { - todayUpns.Add(upn); - todayLogins++; - } - } - - return new UserActivityMetrics - { - TotalUniqueUsers = allUpns.Count, - DailyLogins = todayLogins, - ActiveUsersLast7Days = last7Upns.Count, - ActiveUsersLast30Days = last30Upns.Count - }; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get user activity metrics for tenant {tenantId}"); - return new UserActivityMetrics(); - } - } - - /// - /// Gets user activity metrics across all tenants (for galactic admin) - /// - public async Task GetAllUserActivityMetricsAsync() - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity); - var query = tableClient.QueryAsync(); - - var now = DateTime.UtcNow; - var today = now.Date; - var last7Days = now.AddDays(-7); - var last30Days = now.AddDays(-30); - - var allUpns = new HashSet(StringComparer.OrdinalIgnoreCase); - var todayUpns = new HashSet(StringComparer.OrdinalIgnoreCase); - var last7Upns = new HashSet(StringComparer.OrdinalIgnoreCase); - var last30Upns = new HashSet(StringComparer.OrdinalIgnoreCase); - int todayLogins = 0; - - await foreach (var entity in query) - { - var upn = entity.GetString("Upn") ?? string.Empty; - var loginAt = entity.GetDateTime("LoginAt") ?? DateTime.MinValue; - - if (string.IsNullOrEmpty(upn)) continue; - - allUpns.Add(upn); - - if (loginAt >= last30Days) last30Upns.Add(upn); - if (loginAt >= last7Days) last7Upns.Add(upn); - if (loginAt >= today) - { - todayUpns.Add(upn); - todayLogins++; - } - } - - return new UserActivityMetrics - { - TotalUniqueUsers = allUpns.Count, - DailyLogins = todayLogins, - ActiveUsersLast7Days = last7Upns.Count, - ActiveUsersLast30Days = last30Upns.Count - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get all user activity metrics"); - return new UserActivityMetrics(); - } - } - - /// - /// Gets user login count for a specific date range (used by daily maintenance) - /// - public async Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date) - { - if (!string.IsNullOrEmpty(tenantId) && tenantId != "global") - SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity); - var startOfDay = date.Date; - var endOfDay = startOfDay.AddDays(1); - - string filter; - if (!string.IsNullOrEmpty(tenantId) && tenantId != "global") - { - filter = $"PartitionKey eq '{tenantId}' and LoginAt ge datetime'{startOfDay:yyyy-MM-ddTHH:mm:ss}Z' and LoginAt lt datetime'{endOfDay:yyyy-MM-ddTHH:mm:ss}Z'"; - } - else - { - filter = $"LoginAt ge datetime'{startOfDay:yyyy-MM-ddTHH:mm:ss}Z' and LoginAt lt datetime'{endOfDay:yyyy-MM-ddTHH:mm:ss}Z'"; - } - - var query = tableClient.QueryAsync(filter: filter); - - var upns = new HashSet(StringComparer.OrdinalIgnoreCase); - int loginCount = 0; - - await foreach (var entity in query) - { - var upn = entity.GetString("Upn") ?? string.Empty; - if (!string.IsNullOrEmpty(upn)) - { - upns.Add(upn); - loginCount++; - } - } - - return (upns.Count, loginCount); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to get user activity for date {date:yyyy-MM-dd}"); - return (0, 0); - } - } - - // ===== HELPER METHODS ===== - private T DeserializeJson(string? json) where T : new() { if (string.IsNullOrEmpty(json)) @@ -2333,37 +203,6 @@ namespace AutopilotMonitor.Functions.Services } } - /// - /// Returns the earliest event timestamp persisted for a session, if any. - /// Events are written with RowKey "{Timestamp}_{Sequence}", so querying the partition - /// and taking the first row yields the earliest event. - /// - private async Task GetEarliestSessionEventTimestampAsync(string tenantId, string sessionId) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); - var partitionKey = $"{tenantId}_{sessionId}"; - var query = tableClient.QueryAsync( - filter: $"PartitionKey eq '{partitionKey}'", - maxPerPage: 1, - select: new[] { "Timestamp", "RowKey" } - ); - - await foreach (var entity in query) - { - return entity.GetDateTimeOffset("Timestamp")?.UtcDateTime - ?? entity.GetDateTime("Timestamp"); - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, $"Could not determine earliest event timestamp for session {sessionId}"); - } - - return null; - } - /// /// Deserializes event data JSON and converts JToken objects to native .NET types /// diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/components/AdminConfigSettingsSection.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/components/AdminConfigSettingsSection.tsx new file mode 100644 index 00000000..5cfac48a --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/components/AdminConfigSettingsSection.tsx @@ -0,0 +1,166 @@ +"use client"; + +interface AdminConfiguration { + partitionKey: string; + rowKey: string; + lastUpdated: string; + updatedBy: string; + globalRateLimitRequestsPerMinute: number; + platformStatsBlobSasUrl?: string; + maxCollectorDurationHours?: number; + maxSessionWindowHours?: number; + maintenanceBlockDurationHours?: number; + diagnosticsGlobalLogPathsJson?: string; + customSettings?: string; +} + +interface AdminConfigSettingsSectionProps { + loadingConfig: boolean; + savingConfig: boolean; + adminConfig: AdminConfiguration | null; + globalRateLimit: number; + setGlobalRateLimit: (value: number) => void; + platformStatsBlobSasUrl: string; + setPlatformStatsBlobSasUrl: (value: string) => void; + maxCollectorDurationHours: number; + setMaxCollectorDurationHours: (value: number) => void; + onSave: () => Promise; + onReset: () => void; +} + +export function AdminConfigSettingsSection({ + loadingConfig, + savingConfig, + adminConfig, + globalRateLimit, + setGlobalRateLimit, + platformStatsBlobSasUrl, + setPlatformStatsBlobSasUrl, + maxCollectorDurationHours, + setMaxCollectorDurationHours, + onSave, + onReset, +}: AdminConfigSettingsSectionProps) { + return ( +
+
+
+ + + +
+

Global Settings

+

Configure global settings for all tenants

+
+
+
+
+ {loadingConfig ? ( +
+
+

Loading configuration...

+
+ ) : ( +
+
+ +
+ +
+ +
+ +
+ +
+ + {adminConfig && ( +
+
+ + + +
+

Configuration Info

+

Last updated: {new Date(adminConfig.lastUpdated).toLocaleString()}

+

Updated by: {adminConfig.updatedBy}

+
+
+
+ )} + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/components/DeviceBlockSection.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/components/DeviceBlockSection.tsx new file mode 100644 index 00000000..d9b00dce --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/components/DeviceBlockSection.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { useState } from "react"; +import { API_BASE_URL } from "@/lib/config"; +import { TenantConfiguration } from "./TenantManagementSection"; + +interface BlockedDevice { + tenantId: string; + serialNumber: string; + blockedAt: string; + unblockAt: string; + blockedByEmail: string; + durationHours: number; + reason?: string; +} + +interface DeviceBlockSectionProps { + tenants: TenantConfiguration[]; + maxSessionWindowHours: number; + setMaxSessionWindowHours: (value: number) => void; + maintenanceBlockDurationHours: number; + setMaintenanceBlockDurationHours: (value: number) => void; + savingConfig: boolean; + adminConfigExists: boolean; + onSaveAdminConfig: () => Promise; + getAccessToken: () => Promise; + setError: (error: string | null) => void; + setSuccessMessage: (message: string | null) => void; +} + +export function DeviceBlockSection({ + tenants, + maxSessionWindowHours, + setMaxSessionWindowHours, + maintenanceBlockDurationHours, + setMaintenanceBlockDurationHours, + savingConfig, + adminConfigExists, + onSaveAdminConfig, + getAccessToken, + setError, + setSuccessMessage, +}: DeviceBlockSectionProps) { + const [blockSerialNumber, setBlockSerialNumber] = useState(""); + const [blockTenantId, setBlockTenantId] = useState(""); + const [blockDurationHours, setBlockDurationHours] = useState(12); + const [blockReason, setBlockReason] = useState(""); + const [blockingDevice, setBlockingDevice] = useState(false); + const [blockedDevices, setBlockedDevices] = useState([]); + const [loadingBlockedDevices, setLoadingBlockedDevices] = useState(false); + const [unblockingDevice, setUnblockingDevice] = useState(null); + const [blockListTenantId, setBlockListTenantId] = useState(""); + + const fetchBlockedDevices = async (tenantId: string) => { + if (!tenantId) return; + try { + setLoadingBlockedDevices(true); + const token = await getAccessToken(); + if (!token) throw new Error("Failed to get access token"); + const response = await fetch(`${API_BASE_URL}/api/devices/blocked?tenantId=${encodeURIComponent(tenantId)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) throw new Error(`Failed to load blocked devices: ${response.statusText}`); + const data = await response.json(); + setBlockedDevices(data.blocked ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load blocked devices"); + } finally { + setLoadingBlockedDevices(false); + } + }; + + const handleBlockDevice = async () => { + if (!blockSerialNumber.trim() || !blockTenantId.trim()) return; + try { + setBlockingDevice(true); + setError(null); + const token = await getAccessToken(); + if (!token) throw new Error("Failed to get access token"); + const response = await fetch(`${API_BASE_URL}/api/devices/block`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + tenantId: blockTenantId, + serialNumber: blockSerialNumber.trim(), + durationHours: blockDurationHours, + reason: blockReason.trim() || undefined, + }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.message || response.statusText); + setSuccessMessage(`Device ${blockSerialNumber.trim()} blocked for ${blockDurationHours}h.`); + setTimeout(() => setSuccessMessage(null), 4000); + setBlockSerialNumber(""); + setBlockReason(""); + if (blockListTenantId === blockTenantId) await fetchBlockedDevices(blockTenantId); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to block device"); + } finally { + setBlockingDevice(false); + } + }; + + const handleUnblockDevice = async (tenantId: string, serialNumber: string) => { + try { + setUnblockingDevice(serialNumber); + setError(null); + const token = await getAccessToken(); + if (!token) throw new Error("Failed to get access token"); + const response = await fetch( + `${API_BASE_URL}/api/devices/block/${encodeURIComponent(serialNumber)}?tenantId=${encodeURIComponent(tenantId)}`, + { method: "DELETE", headers: { Authorization: `Bearer ${token}` } } + ); + const result = await response.json(); + if (!response.ok) throw new Error(result.message || response.statusText); + setSuccessMessage(`Device ${serialNumber} unblocked.`); + setTimeout(() => setSuccessMessage(null), 3000); + setBlockedDevices((prev) => prev.filter((d) => d.serialNumber !== serialNumber || d.tenantId !== tenantId)); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to unblock device"); + } finally { + setUnblockingDevice(null); + } + }; + + return ( +
+
+
+ + + +
+

Device Block Management

+

Temporarily block a rogue device from sending data. The agent will stop uploading when it receives the block signal.

+
+
+
+
+ {/* Maintenance Auto-Block Settings */} +
+
+ + + +

Maintenance Auto-Block Settings

+
+

+ The nightly maintenance function automatically blocks devices that are still actively sending data beyond the configured window. Sessions with a LastEventAt timestamp within the window and a StartedAt older than the window are flagged – regardless of session status. +

+
+
+ + setMaxSessionWindowHours(parseInt(e.target.value) || 0)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" + /> +

0 = disabled

+
+
+ + setMaintenanceBlockDurationHours(parseInt(e.target.value) || 12)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" + /> +

Applied by the maintenance function

+
+
+ +
+ + {/* Block a device form */} +
+

Block a Device

+
+
+ + setBlockSerialNumber(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" + /> +
+
+ + +
+
+ + setBlockDurationHours(parseInt(e.target.value) || 12)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" + /> +
+
+ + setBlockReason(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" + /> +
+
+ +
+ + {/* View active blocks for a tenant */} +
+

Active Blocks

+
+
+ + +
+ +
+ {blockedDevices.length > 0 ? ( +
+ + + + + + + + + + + + + {blockedDevices.map((d) => ( + + + + + + + + + ))} + +
Serial NumberBlocked SinceUnblocks AtBlocked ByReasonAction
{d.serialNumber}{new Date(d.blockedAt).toLocaleString()}{new Date(d.unblockAt).toLocaleString()}{d.blockedByEmail}{d.reason || "\u2014"} + +
+
+ ) : blockListTenantId && !loadingBlockedDevices ? ( +

No active blocks for this tenant.

+ ) : null} +
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/components/DiagnosticsLogPathsSection.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/components/DiagnosticsLogPathsSection.tsx new file mode 100644 index 00000000..69ab63fd --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/components/DiagnosticsLogPathsSection.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; + +export interface DiagnosticsLogPath { + path: string; + description: string; + isBuiltIn: boolean; +} + +interface DiagnosticsLogPathsSectionProps { + globalDiagPaths: DiagnosticsLogPath[]; + setGlobalDiagPaths: React.Dispatch>; + loadingConfig: boolean; + savingDiagPaths: boolean; + adminConfigExists: boolean; + onSave: (paths: DiagnosticsLogPath[]) => Promise; +} + +export function DiagnosticsLogPathsSection({ + globalDiagPaths, + setGlobalDiagPaths, + loadingConfig, + savingDiagPaths, + adminConfigExists, + onSave, +}: DiagnosticsLogPathsSectionProps) { + const [newDiagPath, setNewDiagPath] = useState(""); + const [newDiagDesc, setNewDiagDesc] = useState(""); + + return ( +
+
+
+ + + +
+

Diagnostics Log Paths

+

Global log file paths included in diagnostics packages for all tenants. Tenants may add their own paths in Settings.

+
+
+
+
+ {/* Info box */} +
+ + + +

+ All paths are validated on the agent against an allowlist of safe prefixes (DiagnosticsPathGuards). Wildcards are only allowed in the last path segment. Environment variables are expanded by the agent. +

+
+ + {/* Current paths list */} + {loadingConfig ? ( +
+
+ Loading... +
+ ) : globalDiagPaths.length === 0 ? ( +

No global paths configured yet.

+ ) : ( +
+ {globalDiagPaths.map((entry, idx) => ( +
+
+

{entry.path}

+ {entry.description && ( +

{entry.description}

+ )} +
+ +
+ ))} +
+ )} + + {/* Add new path */} +
+ setNewDiagPath(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500 font-mono" + /> + setNewDiagDesc(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500" + /> + +
+ + {/* Save button */} +
+ +
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/components/MaintenanceSection.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/components/MaintenanceSection.tsx new file mode 100644 index 00000000..aa8e14d7 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/components/MaintenanceSection.tsx @@ -0,0 +1,448 @@ +"use client"; + +import { useState } from "react"; +import { API_BASE_URL } from "@/lib/config"; + +interface MaintenanceSectionProps { + getAccessToken: () => Promise; + setError: (error: string | null) => void; + setSuccessMessage: (message: string | null) => void; +} + +export function MaintenanceSection({ + getAccessToken, + setError, + setSuccessMessage, +}: MaintenanceSectionProps) { + const [triggeringMaintenance, setTriggeringMaintenance] = useState(false); + const [maintenanceDate, setMaintenanceDate] = useState(""); + const [reseedingRules, setReseedingRules] = useState(false); + const [reseedingGatherRules, setReseedingGatherRules] = useState(false); + const [reseedingImePatterns, setReseedingImePatterns] = useState(false); + const [fetchingFromGitHub, setFetchingFromGitHub] = useState(false); + + const handleTriggerMaintenance = async () => { + try { + setTriggeringMaintenance(true); + setError(null); + setSuccessMessage(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const queryParams = maintenanceDate ? `?date=${maintenanceDate}` : ''; + const response = await fetch(`${API_BASE_URL}/api/maintenance/trigger${queryParams}`, { + method: "POST", + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error(`Failed to trigger maintenance: ${response.statusText}`); + } + + const result = await response.json(); + const dateInfo = maintenanceDate ? ` for ${maintenanceDate}` : ''; + setSuccessMessage(`Maintenance job completed successfully${dateInfo}!`); + + // Auto-hide success message after 5 seconds + setTimeout(() => setSuccessMessage(null), 5000); + } catch (err) { + console.error("Error triggering maintenance:", err); + setError(err instanceof Error ? err.message : "Failed to trigger maintenance job"); + } finally { + setTriggeringMaintenance(false); + } + }; + + const handleReseedAnalyzeRules = async () => { + try { + setReseedingRules(true); + setError(null); + setSuccessMessage(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const response = await fetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=analyze`, { + method: "POST", + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to reseed analyze rules: ${response.statusText}`); + } + + const result = await response.json(); + setSuccessMessage(`Analyze rules reseeded from GitHub: ${result.analyze?.deleted ?? 0} deleted, ${result.analyze?.written ?? 0} written`); + setTimeout(() => setSuccessMessage(null), 5000); + } catch (err) { + console.error("Error reseeding analyze rules:", err); + setError(err instanceof Error ? err.message : "Failed to reseed analyze rules"); + } finally { + setReseedingRules(false); + } + }; + + const handleReseedGatherRules = async () => { + try { + setReseedingGatherRules(true); + setError(null); + setSuccessMessage(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const response = await fetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=gather`, { + method: "POST", + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to reseed gather rules: ${response.statusText}`); + } + + const result = await response.json(); + setSuccessMessage(`Gather rules reseeded from GitHub: ${result.gather?.deleted ?? 0} deleted, ${result.gather?.written ?? 0} written`); + setTimeout(() => setSuccessMessage(null), 5000); + } catch (err) { + console.error("Error reseeding gather rules:", err); + setError(err instanceof Error ? err.message : "Failed to reseed gather rules"); + } finally { + setReseedingGatherRules(false); + } + }; + + const handleReseedImePatterns = async () => { + try { + setReseedingImePatterns(true); + setError(null); + setSuccessMessage(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const response = await fetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=ime`, { + method: "POST", + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to reseed IME log patterns: ${response.statusText}`); + } + + const result = await response.json(); + setSuccessMessage(`IME log patterns reseeded from GitHub: ${result.ime?.deleted ?? 0} deleted, ${result.ime?.written ?? 0} written`); + setTimeout(() => setSuccessMessage(null), 5000); + } catch (err) { + console.error("Error reseeding IME log patterns:", err); + setError(err instanceof Error ? err.message : "Failed to reseed IME log patterns"); + } finally { + setReseedingImePatterns(false); + } + }; + + const handleFetchFromGitHub = async () => { + try { + setFetchingFromGitHub(true); + setError(null); + setSuccessMessage(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const response = await fetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=all`, { + method: "POST", + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to reseed from GitHub: ${response.statusText}`); + } + + const result = await response.json(); + setSuccessMessage( + `GitHub Reseed complete: Gather (${result.gather?.deleted ?? 0} deleted, ${result.gather?.written ?? 0} written), ` + + `Analyze (${result.analyze?.deleted ?? 0} deleted, ${result.analyze?.written ?? 0} written), ` + + `IME (${result.ime?.deleted ?? 0} deleted, ${result.ime?.written ?? 0} written)` + ); + setTimeout(() => setSuccessMessage(null), 8000); + } catch (err) { + console.error("Error reseeding from GitHub:", err); + setError(err instanceof Error ? err.message : "Failed to reseed from GitHub"); + } finally { + setFetchingFromGitHub(false); + } + }; + + return ( + <> + {/* Manual Maintenance Trigger */} +
+
+
+ + + +
+

Manual Maintenance Trigger

+

Execute platform-wide maintenance operations

+
+
+
+
+

+ Manually trigger the daily maintenance job which includes: +

+
    +
  • + • + Mark stalled sessions as timed out +
  • +
  • + • + Aggregate metrics into historical snapshots (with automatic catch-up for missed days) +
  • +
  • + • + Clean up old data based on retention policies +
  • +
+
+ + setMaintenanceDate(e.target.value)} + max={new Date(Date.now() - 86400000).toISOString().split('T')[0]} + className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + /> +

+ Leave empty to run the standard maintenance with automatic catch-up (aggregates any missed days within the last 7 days). + Select a specific date to manually aggregate metrics for that day, e.g. to backfill data older than 7 days. +

+
+
+
+ + + +

+ Warning: This operation runs across all tenants and may take several minutes to complete. + Use this only for testing or when immediate cleanup is needed. +

+
+
+
+ +
+
+
+ + {/* Fetch & Reseed from GitHub */} +
+
+
+ + + +
+

Fetch & Reseed from GitHub

+

Fetch the latest rules from the GitHub repository and reseed all rule types

+
+
+
+
+

+ This operation fetches all rule definitions from the GitHub repository and writes them to Azure Table Storage: +

+
    +
  • + • + Fetches Gather Rules, Analyze Rules, and IME Log Patterns from GitHub +
  • +
  • + • + Deletes all existing global built-in entries and writes the latest versions +
  • +
  • + • + Tenant-specific custom rules and overrides are not affected +
  • +
+
+
+ + + +

+ Rules are loaded from raw.githubusercontent.com. After a merge to the repository, it may take up to 5 minutes for GitHub's CDN cache to update. If you see stale rules after reseeding, wait a few minutes and try again. +

+
+
+
+ +
+
+
+ + {/* Individual Reseed Sections */} +
+ {/* Reseed Analyze Rules */} +
+
+

Reseed Analyze Rules

+

Fetch and reseed only analyze rules from GitHub

+
+
+
+ +
+
+
+ + {/* Reseed Gather Rules */} +
+
+

Reseed Gather Rules

+

Fetch and reseed only gather rules from GitHub

+
+
+
+ +
+
+
+ + {/* Reseed IME Log Patterns */} +
+
+

Reseed IME Log Patterns

+

Fetch and reseed only IME log patterns from GitHub

+
+
+
+ +
+
+
+
+ + ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/components/SessionExportSection.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/components/SessionExportSection.tsx new file mode 100644 index 00000000..4362fd89 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/components/SessionExportSection.tsx @@ -0,0 +1,330 @@ +"use client"; + +import { useState } from "react"; +import { API_BASE_URL } from "@/lib/config"; +import { TenantConfiguration } from "./TenantManagementSection"; + +interface SessionExportEvent { + eventId: string; + sessionId: string; + tenantId: string; + timestamp: string; + eventType: string; + severity: string; + source: string; + phase: number; + phaseName?: string; + message: string; + sequence: number; + rowKey?: string; + data?: Record; +} + +const EXPORT_V1_PHASE_NAMES: Record = { + 0: "Start", 1: "Device Preparation", 2: "Device Setup", + 3: "Apps (Device)", 4: "Account Setup", 5: "Apps (User)", + 6: "Finalizing Setup", 7: "Complete", 99: "Failed" +}; +const EXPORT_V2_PHASE_NAMES: Record = { + 0: "Start", 1: "Device Preparation", 2: "Device Setup", + 3: "App Installation", 4: "Account Setup", 5: "Apps (User)", + 6: "Finalizing Setup", 7: "Complete", 99: "Failed" +}; +const EXPORT_V1_PHASE_ORDER = ["Start", "Device Preparation", "Device Setup", + "Apps (Device)", "Account Setup", "Apps (User)", "Finalizing Setup", "Complete", "Failed"]; +const EXPORT_V2_PHASE_ORDER = ["Start", "Device Preparation", "App Installation", + "Finalizing Setup", "Complete", "Failed"]; + +function downloadFile(content: string, filename: string, mimeType: string) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +function generateCsvExport(events: SessionExportEvent[]) { + const isV1 = events.some(e => e.phase === 2); + const phaseNames = isV1 ? EXPORT_V1_PHASE_NAMES : EXPORT_V2_PHASE_NAMES; + const esc = (v: string) => `"${v.replace(/"/g, '""')}"`; + // Sort exactly as Azure Table Storage: by timestamp ascending, then sequence ascending + const sorted = [...events].sort((a, b) => { + const tCmp = (a.timestamp ?? "").localeCompare(b.timestamp ?? ""); + if (tCmp !== 0) return tCmp; + return (a.sequence ?? 0) - (b.sequence ?? 0); + }); + const header = "RowKey,EventId,SessionId,TenantId,Timestamp,EventType,Severity,Source,Phase,PhaseName,Message,Sequence,Data"; + const rows = sorted.map(e => [ + esc(e.rowKey ?? ""), + esc(e.eventId ?? ""), + esc(e.sessionId ?? ""), + esc(e.tenantId ?? ""), + esc(e.timestamp ?? ""), + esc(e.eventType ?? ""), + esc(e.severity ?? ""), + esc(e.source ?? ""), + String(e.phase ?? 0), + esc(phaseNames[e.phase] ?? "Unknown"), + esc(e.message ?? ""), + String(e.sequence ?? 0), + esc(e.data ? JSON.stringify(e.data) : ""), + ].join(",")); + return "\uFEFF" + header + "\n" + rows.join("\n"); +} + +function generateUiExport(events: SessionExportEvent[], sessionId: string, tenantId: string) { + const isV1 = events.some(e => e.phase === 2); + const phaseNames = isV1 ? EXPORT_V1_PHASE_NAMES : EXPORT_V2_PHASE_NAMES; + const phaseOrder = isV1 ? EXPORT_V1_PHASE_ORDER : EXPORT_V2_PHASE_ORDER; + + const sorted = [...events].sort((a, b) => a.sequence - b.sequence); + + // Replicate eventsByPhase useMemo logic: assign Unknown-phase events to active named phase + const grouped: Record = {}; + phaseOrder.forEach(p => { grouped[p] = []; }); + + let lastNamedPhase = phaseOrder[0]; + for (const ev of sorted) { + const name = phaseNames[ev.phase]; + if (name && name !== "Unknown") { + lastNamedPhase = name; + if (grouped[name]) grouped[name].push({ ...ev, phaseName: name }); + } else { + // Unknown phase — insert into currently active named phase + if (grouped[lastNamedPhase]) grouped[lastNamedPhase].push({ ...ev, phaseName: "Unknown" }); + } + } + + const pad = (s: string, len: number) => s.padEnd(len); + const severityLabel = (s: string) => pad(s ?? "Unknown", 7); + + const lines: string[] = []; + lines.push("AUTOPILOT MONITOR \u2014 SESSION EVENT EXPORT"); + lines.push("========================================="); + lines.push(`Session ID : ${sessionId}`); + lines.push(`Tenant ID : ${tenantId}`); + lines.push(`Exported at : ${new Date().toISOString()}`); + lines.push(`Total events : ${events.length}`); + lines.push(`Enrollment : ${isV1 ? "V1" : "V2"}`); + + for (const phase of phaseOrder) { + const phaseEvents = grouped[phase] ?? []; + lines.push(""); + lines.push("\u2550".repeat(43)); + lines.push(` ${phase} (${phaseEvents.length} event${phaseEvents.length !== 1 ? "s" : ""})`); + lines.push("\u2550".repeat(43)); + if (phaseEvents.length === 0) { + lines.push(" (no events)"); + } else { + for (const ev of phaseEvents) { + const ts = ev.timestamp ? new Date(ev.timestamp).toISOString().replace("T", " ").substring(0, 23) : "?"; + lines.push(`[${ts}] [${severityLabel(ev.severity)}] ${ev.eventType} \u2014 ${ev.message}`); + let detail = ` Source: ${ev.source ?? "?"} | Seq: ${ev.sequence ?? "?"} | EventId: ${ev.eventId ?? "?"}`; + if (ev.phaseName === "Unknown") detail += ` | RawPhase: ${ev.phase}`; + lines.push(detail); + if (ev.data && Object.keys(ev.data).length > 0) { + lines.push(` Data: ${JSON.stringify(ev.data)}`); + } + } + } + } + + return lines.join("\n"); +} + +interface SessionExportSectionProps { + tenants: TenantConfiguration[]; + getAccessToken: () => Promise; +} + +export function SessionExportSection({ + tenants, + getAccessToken, +}: SessionExportSectionProps) { + const [exportSessionId, setExportSessionId] = useState(""); + const [exportTenantId, setExportTenantId] = useState(""); + const [exportLoading, setExportLoading] = useState(false); + const [exportError, setExportError] = useState(null); + const [exportedEvents, setExportedEvents] = useState(null); + + const cleanId = (v: string) => v.trim().replace(/^["'\s]+|["'\s]+$/g, ""); + + const handleFetchExportEvents = async () => { + const sid = cleanId(exportSessionId); + const tid = cleanId(exportTenantId); + if (!sid || !tid) { + setExportError("Session ID and Tenant ID are required."); + return; + } + try { + setExportLoading(true); + setExportError(null); + setExportedEvents(null); + const token = await getAccessToken(); + if (!token) throw new Error("Failed to get access token"); + const res = await fetch( + `${API_BASE_URL}/api/sessions/${sid}/events?tenantId=${encodeURIComponent(tid)}`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + const data = await res.json(); + if (!data.success) throw new Error(data.message || "Backend returned error"); + setExportedEvents(data.events ?? []); + } catch (err) { + setExportError(err instanceof Error ? err.message : "Failed to fetch events"); + } finally { + setExportLoading(false); + } + }; + + return ( +
+
+
+ + + +
+

Session Event Export

+

Fetch and export all events directly from storage — use to analyze timeline phase grouping and ordering

+
+
+
+
+
+
+
+ + setExportSessionId(e.target.value)} + className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500" + /> +
+
+ + {tenants.length > 0 ? ( +
+ + setExportTenantId(e.target.value)} + className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500" + /> +
+ ) : ( + setExportTenantId(e.target.value)} + className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500" + /> + )} +
+
+ +
+ +
+ + {exportError && ( +
+ + + + {exportError} +
+ )} + + {exportedEvents !== null && ( +
+
+ + + + + {exportedEvents.length} events loaded + {" \u00B7 "} + {exportedEvents.some(e => e.phase === 2) ? "V1" : "V2"} + {" \u00B7 "} + session {cleanId(exportSessionId).slice(0, 8)} + +
+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/components/TenantAdminSection.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/components/TenantAdminSection.tsx new file mode 100644 index 00000000..4541bc85 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/components/TenantAdminSection.tsx @@ -0,0 +1,374 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { API_BASE_URL } from "@/lib/config"; + +interface TenantAdmin { + tenantId: string; + upn: string; + isEnabled: boolean; + addedDate: string; + addedBy: string; +} + +interface TenantAdminSectionProps { + tenantId: string; + getAccessToken: () => Promise; + setError: (error: string | null) => void; + setSuccessMessage: (message: string | null) => void; +} + +export function TenantAdminSection({ + tenantId, + getAccessToken, + setError, + setSuccessMessage, +}: TenantAdminSectionProps) { + const [tenantAdmins, setTenantAdmins] = useState([]); + const [loadingAdmins, setLoadingAdmins] = useState(false); + const [newAdminEmail, setNewAdminEmail] = useState(""); + const [addingAdmin, setAddingAdmin] = useState(false); + const [removingAdmin, setRemovingAdmin] = useState(null); + const [togglingAdmin, setTogglingAdmin] = useState(null); + const [currentAdminPage, setCurrentAdminPage] = useState(0); + const [adminSearchQuery, setAdminSearchQuery] = useState(""); + const adminsPerPage = 5; + + // Fetch tenant admins when tenantId changes + const fetchTenantAdmins = async (tid: string) => { + try { + setLoadingAdmins(true); + setCurrentAdminPage(0); // Reset to first page when loading new tenant + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const response = await fetch(`${API_BASE_URL}/api/tenants/${tid}/admins`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error(`Failed to load admins: ${response.statusText}`); + } + + const data: TenantAdmin[] = await response.json(); + setTenantAdmins(data); + } catch (err) { + console.error("Error fetching tenant admins:", err); + setError(err instanceof Error ? err.message : "Failed to load tenant admins"); + } finally { + setLoadingAdmins(false); + } + }; + + useEffect(() => { + fetchTenantAdmins(tenantId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tenantId]); + + const handleAddTenantAdmin = async () => { + if (!newAdminEmail.trim()) return; + + try { + setAddingAdmin(true); + setError(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const response = await fetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins`, { + method: "POST", + headers: { + "Content-Type": "application/json", + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ upn: newAdminEmail.trim() }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `Failed to add admin: ${response.statusText}`); + } + + setSuccessMessage(`Admin ${newAdminEmail} added successfully!`); + setNewAdminEmail(""); + + // Refresh admin list + await fetchTenantAdmins(tenantId); + + // Auto-hide success message after 3 seconds + setTimeout(() => setSuccessMessage(null), 3000); + } catch (err) { + console.error("Error adding tenant admin:", err); + setError(err instanceof Error ? err.message : "Failed to add admin"); + } finally { + setAddingAdmin(false); + } + }; + + const handleRemoveTenantAdmin = async (adminUpn: string) => { + if (!confirm(`Are you sure you want to remove ${adminUpn} as an admin?`)) { + return; + } + + try { + setRemovingAdmin(adminUpn); + setError(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const response = await fetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}`, { + method: "DELETE", + headers: { + 'Authorization': `Bearer ${token}` + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `Failed to remove admin: ${response.statusText}`); + } + + setSuccessMessage(`Admin ${adminUpn} removed successfully!`); + + // Refresh admin list + await fetchTenantAdmins(tenantId); + + // Auto-hide success message after 3 seconds + setTimeout(() => setSuccessMessage(null), 3000); + } catch (err) { + console.error("Error removing tenant admin:", err); + setError(err instanceof Error ? err.message : "Failed to remove admin"); + } finally { + setRemovingAdmin(null); + } + }; + + const handleToggleTenantAdmin = async (adminUpn: string, isEnabled: boolean) => { + try { + setTogglingAdmin(adminUpn); + setError(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const action = isEnabled ? 'disable' : 'enable'; + const response = await fetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/${action}`, { + method: "PATCH", + headers: { + 'Authorization': `Bearer ${token}` + }, + }); + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch { + errorData = { error: `Failed to ${action} admin: ${response.statusText}` }; + } + throw new Error(errorData.error || `Failed to ${action} admin: ${response.statusText}`); + } + + setSuccessMessage(`Admin ${adminUpn} ${isEnabled ? 'disabled' : 'enabled'} successfully!`); + + // Refresh admin list + await fetchTenantAdmins(tenantId); + + // Auto-hide success message after 3 seconds + setTimeout(() => setSuccessMessage(null), 3000); + } catch (err) { + console.error("Error toggling tenant admin:", err); + setError(err instanceof Error ? err.message : "Failed to toggle admin"); + } finally { + setTogglingAdmin(null); + } + }; + + // Admin Pagination with Search + const filteredAdmins = tenantAdmins.filter(admin => + admin.upn.toLowerCase().includes(adminSearchQuery.toLowerCase()) + ); + const totalAdminPages = Math.ceil(filteredAdmins.length / adminsPerPage); + const startAdminIndex = currentAdminPage * adminsPerPage; + const endAdminIndex = startAdminIndex + adminsPerPage; + const paginatedAdmins = filteredAdmins.slice(startAdminIndex, endAdminIndex); + + return ( +
+
+ + + +
+

Admin Users Management

+ + {/* Current Admins List */} +
+

+ Current Admins ({tenantAdmins.length}): +

+ + {/* Search Field */} +
+
+ { + setAdminSearchQuery(e.target.value); + setCurrentAdminPage(0); + }} + placeholder="Search by email..." + autoComplete="off" + className="w-full px-3 py-2 pl-9 pr-9 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" + /> + + + + {adminSearchQuery && ( + + )} +
+
+ + {loadingAdmins ? ( +
Loading admins...
+ ) : tenantAdmins.length === 0 ? ( +
No admins configured yet
+ ) : filteredAdmins.length === 0 ? ( +
+ No admins match your search +
+ ) : ( + <> +
+ {paginatedAdmins.map((admin) => ( +
+
+
+
{admin.upn}
+ {!admin.isEnabled && ( + + Disabled + + )} +
+
+ Added {new Date(admin.addedDate).toLocaleDateString()} by {admin.addedBy} +
+
+
+ + +
+
+ ))} +
+ + {/* Pagination */} + {totalAdminPages > 1 && ( +
+ + + Page {currentAdminPage + 1} of {totalAdminPages} ({filteredAdmins.length} admin{filteredAdmins.length !== 1 ? 's' : ''}) + + +
+ )} + + )} +
+ + {/* Add New Admin */} +
+

Add New Admin:

+
+ setNewAdminEmail(e.target.value)} + placeholder="admin@tenant.com" + autoComplete="off" + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTenantAdmin(); + } + }} + /> + +
+
+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/components/TenantManagementSection.tsx new file mode 100644 index 00000000..3f73088b --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/components/TenantManagementSection.tsx @@ -0,0 +1,621 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { API_BASE_URL } from "@/lib/config"; +import { TenantAdminSection } from "./TenantAdminSection"; + +export interface TenantConfiguration { + tenantId: string; + domainName: string; + lastUpdated: string; + updatedBy: string; + disabled: boolean; + disabledReason?: string; + disabledUntil?: string; + rateLimitRequestsPerMinute: number; + manufacturerWhitelist: string; + modelWhitelist: string; + validateAutopilotDevice: boolean; + allowInsecureAgentRequests?: boolean; + dataRetentionDays: number; + sessionTimeoutHours: number; + maxNdjsonPayloadSizeMB: number; +} + +export interface TenantManagementSectionProps { + tenants: TenantConfiguration[]; + loadingTenants: boolean; + fetchTenants: () => void; + previewApproved: Set; + setPreviewApproved: React.Dispatch>>; + setTenants: React.Dispatch>; + getAccessToken: () => Promise; + setError: (error: string | null) => void; + setSuccessMessage: (message: string | null) => void; +} + +export function TenantManagementSection({ + tenants, + loadingTenants, + fetchTenants, + previewApproved, + setPreviewApproved, + setTenants, + getAccessToken, + setError, + setSuccessMessage, +}: TenantManagementSectionProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [showOnlyWaitlist, setShowOnlyWaitlist] = useState(false); + const [tenantSectionExpanded, setTenantSectionExpanded] = useState(false); + const [editingTenant, setEditingTenant] = useState(null); + const [savingTenant, setSavingTenant] = useState(false); + const [togglingSecurityBypassTenant, setTogglingSecurityBypassTenant] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const tenantsPerPage = tenantSectionExpanded ? 7 : 3; + + // Preview Whitelist state + const [togglingPreviewTenant, setTogglingPreviewTenant] = useState(null); + + // Filter and sort tenants + const filteredTenants = tenants.filter(t => { + const matchesSearch = + t.tenantId.toLowerCase().includes(searchQuery.toLowerCase()) || + t.domainName.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesWaitlist = !showOnlyWaitlist || !previewApproved.has(t.tenantId); + return matchesSearch && matchesWaitlist; + }); + + // Statistics (always over all tenants, not filtered) + const readyCount = tenants.filter(t => t.validateAutopilotDevice).length; + const waitlistCount = tenants.filter(t => !previewApproved.has(t.tenantId)).length; + const totalCount = tenants.length; + + // Pagination + const totalPages = Math.ceil(filteredTenants.length / tenantsPerPage); + const startIndex = currentPage * tenantsPerPage; + const endIndex = startIndex + tenantsPerPage; + const paginatedTenants = filteredTenants.slice(startIndex, endIndex); + + // Reset to first page when search changes + useEffect(() => { + setCurrentPage(0); + }, [searchQuery, showOnlyWaitlist]); + + const handleSaveTenant = async (tenant: TenantConfiguration) => { + try { + setSavingTenant(true); + setError(null); + setSuccessMessage(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const response = await fetch(`${API_BASE_URL}/api/config/${tenant.tenantId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(tenant), + }); + + if (!response.ok) { + throw new Error(`Failed to save tenant configuration: ${response.statusText}`); + } + + const result = await response.json(); + + // Update tenant in list + setTenants(prev => prev.map(t => t.tenantId === tenant.tenantId ? result.config : t)); + setEditingTenant(null); + setSuccessMessage(`Tenant ${tenant.tenantId} configuration saved successfully!`); + + // Auto-hide success message after 3 seconds + setTimeout(() => setSuccessMessage(null), 3000); + } catch (err) { + console.error("Error saving tenant configuration:", err); + setError(err instanceof Error ? err.message : "Failed to save tenant configuration"); + } finally { + setSavingTenant(false); + } + }; + + const handleToggleSecurityBypass = async (tenant: TenantConfiguration) => { + try { + setTogglingSecurityBypassTenant(tenant.tenantId); + setError(null); + setSuccessMessage(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const newValue = !tenant.allowInsecureAgentRequests; + const response = await fetch(`${API_BASE_URL}/api/config/${tenant.tenantId}/security-bypass`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ allowInsecureAgentRequests: newValue }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `Failed to update security bypass: ${response.statusText}`); + } + + setTenants(prev => prev.map(t => + t.tenantId === tenant.tenantId + ? { ...t, allowInsecureAgentRequests: newValue } + : t + )); + + setEditingTenant(prev => prev && prev.tenantId === tenant.tenantId + ? { ...prev, allowInsecureAgentRequests: newValue } + : prev); + + setSuccessMessage( + newValue + ? `Security bypass enabled for tenant ${tenant.tenantId}.` + : `Security bypass disabled for tenant ${tenant.tenantId}.` + ); + setTimeout(() => setSuccessMessage(null), 4000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update security bypass"); + } finally { + setTogglingSecurityBypassTenant(null); + } + }; + + const handleTogglePreview = async (tenantId: string) => { + try { + setTogglingPreviewTenant(tenantId); + setError(null); + setSuccessMessage(null); + + const token = await getAccessToken(); + if (!token) { + throw new Error('Failed to get access token'); + } + + const isCurrentlyApproved = previewApproved.has(tenantId); + + const response = await fetch(`${API_BASE_URL}/api/preview-whitelist/${tenantId}`, { + method: isCurrentlyApproved ? "DELETE" : "POST", + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `Failed to update preview access: ${response.statusText}`); + } + + setPreviewApproved(prev => { + const next = new Set(prev); + if (isCurrentlyApproved) { + next.delete(tenantId); + } else { + next.add(tenantId); + } + return next; + }); + + setSuccessMessage( + isCurrentlyApproved + ? `Preview access revoked for tenant ${tenantId}` + : `Preview access granted for tenant ${tenantId}` + ); + setTimeout(() => setSuccessMessage(null), 4000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update preview access"); + } finally { + setTogglingPreviewTenant(null); + } + }; + + return ( + <> +
+
{ setTenantSectionExpanded(v => !v); setCurrentPage(0); }} + > +
+
+ + + +
+

Tenant Management

+

View and manage all tenant configurations

+
+
+
+ + + + +
+
+
+
+ {loadingTenants ? ( +
+
+

Loading tenants...

+
+ ) : ( +
+ {/* Search */} +
+
+ + + + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" + /> +
+ + {filteredTenants.length} tenant(s) +
+ + {/* Tenant List */} +
+ {paginatedTenants.length === 0 ? ( +
+ {showOnlyWaitlist ? "No waitlist tenants found" : searchQuery ? "No tenants found matching your search" : "No tenants registered yet"} +
+ ) : ( + <> + {paginatedTenants.map((tenant) => ( +
+
+
+

+ {tenant.domainName || tenant.tenantId} +

+

+ Tenant ID: {tenant.tenantId} +

+
+
+
+ {tenant.disabled && ( + + Suspended + + )} + {previewApproved.has(tenant.tenantId) ? ( + + Preview + + ) : ( + + Waitlist + + )} + {tenant.validateAutopilotDevice && ( + + Ready + + )} +
+
+ + +
+
+
+
+ ))} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {currentPage + 1} of {totalPages} + + +
+ )} + + )} +
+ + {/* Statistics */} + {totalCount > 0 && ( +
+ + {readyCount} + {' '}of{' '} + {totalCount} + {' '}Tenant(s) are Ready + + + {waitlistCount} + {' '}of{' '} + {totalCount} + {' '}Tenant(s) are on the Waitlist + +
+ )} +
+ )} +
+
+ + {/* Edit Tenant Modal */} + {editingTenant && ( +
+
+
+

Edit Tenant Configuration

+

{editingTenant.tenantId}

+
+ +
+ {/* Tenant Suspension */} +
+

Tenant Suspension

+
+ + + {editingTenant.disabled && ( + <> +
+ + setEditingTenant({ ...editingTenant, disabledReason: e.target.value })} + placeholder="Optional: Why is this tenant suspended?" + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500" + /> +
+
+ + setEditingTenant({ ...editingTenant, disabledUntil: e.target.value ? new Date(e.target.value).toISOString() : undefined })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500" + /> +

Optional: Auto-enable after this date/time

+
+ + )} +
+
+ + {/* Admin Users Info */} + + + {/* Rate Limit */} +
+ + setEditingTenant({ ...editingTenant, rateLimitRequestsPerMinute: parseInt(e.target.value) || 100 })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" + /> +
+ + {/* Hardware Whitelist */} +
+ + setEditingTenant({ ...editingTenant, manufacturerWhitelist: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" + /> +
+ +
+ + setEditingTenant({ ...editingTenant, modelWhitelist: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" + /> +
+ + + +
+

Galactic Admin Test Bypass

+

+ Allows agent requests even when Autopilot device validation is disabled. Use only for temporary test tenants. +

+
+ + {editingTenant.allowInsecureAgentRequests ? "Bypass is ENABLED" : "Bypass is DISABLED"} + + +
+
+ + {/* Data Management */} +
+ + setEditingTenant({ ...editingTenant, dataRetentionDays: parseInt(e.target.value) || 90 })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" + /> +
+ +
+ + setEditingTenant({ ...editingTenant, sessionTimeoutHours: parseInt(e.target.value) || 5 })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" + /> +
+ +
+ + setEditingTenant({ ...editingTenant, maxNdjsonPayloadSizeMB: parseInt(e.target.value) || 5 })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" + /> +
+
+ + {/* Modal Actions */} +
+ + +
+
+
+ )} + + ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx index 32c83b4c..4cf72af4 100644 --- a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx @@ -5,43 +5,12 @@ import { useRouter } from "next/navigation"; import { ProtectedRoute } from "../../components/ProtectedRoute"; import { useAuth } from "../../contexts/AuthContext"; import { API_BASE_URL } from "@/lib/config"; - -interface SessionExportEvent { - eventId: string; - sessionId: string; - tenantId: string; - timestamp: string; - eventType: string; - severity: string; - source: string; - phase: number; - phaseName?: string; - message: string; - sequence: number; - rowKey?: string; - data?: Record; -} - -const EXPORT_V1_PHASE_NAMES: Record = { - 0: "Start", 1: "Device Preparation", 2: "Device Setup", - 3: "Apps (Device)", 4: "Account Setup", 5: "Apps (User)", - 6: "Finalizing Setup", 7: "Complete", 99: "Failed" -}; -const EXPORT_V2_PHASE_NAMES: Record = { - 0: "Start", 1: "Device Preparation", 2: "Device Setup", - 3: "App Installation", 4: "Account Setup", 5: "Apps (User)", - 6: "Finalizing Setup", 7: "Complete", 99: "Failed" -}; -const EXPORT_V1_PHASE_ORDER = ["Start", "Device Preparation", "Device Setup", - "Apps (Device)", "Account Setup", "Apps (User)", "Finalizing Setup", "Complete", "Failed"]; -const EXPORT_V2_PHASE_ORDER = ["Start", "Device Preparation", "App Installation", - "Finalizing Setup", "Complete", "Failed"]; - -interface DiagnosticsLogPath { - path: string; - description: string; - isBuiltIn: boolean; -} +import { TenantManagementSection, TenantConfiguration } from "./components/TenantManagementSection"; +import { MaintenanceSection } from "./components/MaintenanceSection"; +import { DiagnosticsLogPathsSection, DiagnosticsLogPath } from "./components/DiagnosticsLogPathsSection"; +import { DeviceBlockSection } from "./components/DeviceBlockSection"; +import { SessionExportSection } from "./components/SessionExportSection"; +import { AdminConfigSettingsSection } from "./components/AdminConfigSettingsSection"; interface AdminConfiguration { partitionKey: string; @@ -57,56 +26,12 @@ interface AdminConfiguration { customSettings?: string; } -interface TenantConfiguration { - tenantId: string; - domainName: string; - lastUpdated: string; - updatedBy: string; - disabled: boolean; - disabledReason?: string; - disabledUntil?: string; - rateLimitRequestsPerMinute: number; - manufacturerWhitelist: string; - modelWhitelist: string; - validateAutopilotDevice: boolean; - allowInsecureAgentRequests?: boolean; - dataRetentionDays: number; - sessionTimeoutHours: number; - maxNdjsonPayloadSizeMB: number; -} - -interface TenantAdmin { - tenantId: string; - upn: string; - isEnabled: boolean; - addedDate: string; - addedBy: string; -} - -interface BlockedDevice { - tenantId: string; - serialNumber: string; - blockedAt: string; - unblockAt: string; - blockedByEmail: string; - durationHours: number; - reason?: string; -} - export default function AdminConfigurationPage() { const router = useRouter(); const { getAccessToken } = useAuth(); const [galacticAdminMode, setGalacticAdminMode] = useState(false); - const [triggeringMaintenance, setTriggeringMaintenance] = useState(false); - const [maintenanceDate, setMaintenanceDate] = useState(""); - const [reseedingRules, setReseedingRules] = useState(false); - const [reseedingGatherRules, setReseedingGatherRules] = useState(false); - const [reseedingImePatterns, setReseedingImePatterns] = useState(false); - const [fetchingFromGitHub, setFetchingFromGitHub] = useState(false); // Diagnostics Log Paths state const [globalDiagPaths, setGlobalDiagPaths] = useState([]); - const [newDiagPath, setNewDiagPath] = useState(""); - const [newDiagDesc, setNewDiagDesc] = useState(""); const [savingDiagPaths, setSavingDiagPaths] = useState(false); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); @@ -121,51 +46,12 @@ export default function AdminConfigurationPage() { const [maxSessionWindowHours, setMaxSessionWindowHours] = useState(24); const [maintenanceBlockDurationHours, setMaintenanceBlockDurationHours] = useState(12); - // Session Event Export state - const [exportSessionId, setExportSessionId] = useState(""); - const [exportTenantId, setExportTenantId] = useState(""); - const [exportLoading, setExportLoading] = useState(false); - const [exportError, setExportError] = useState(null); - const [exportedEvents, setExportedEvents] = useState(null); - - // Device Block state - const [blockSerialNumber, setBlockSerialNumber] = useState(""); - const [blockTenantId, setBlockTenantId] = useState(""); - const [blockDurationHours, setBlockDurationHours] = useState(12); - const [blockReason, setBlockReason] = useState(""); - const [blockingDevice, setBlockingDevice] = useState(false); - const [blockedDevices, setBlockedDevices] = useState([]); - const [loadingBlockedDevices, setLoadingBlockedDevices] = useState(false); - const [unblockingDevice, setUnblockingDevice] = useState(null); - const [blockListTenantId, setBlockListTenantId] = useState(""); - // Tenant Management state const [tenants, setTenants] = useState([]); const [loadingTenants, setLoadingTenants] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [showOnlyWaitlist, setShowOnlyWaitlist] = useState(false); - const [tenantSectionExpanded, setTenantSectionExpanded] = useState(false); - const [editingTenant, setEditingTenant] = useState(null); - const [savingTenant, setSavingTenant] = useState(false); - const [togglingSecurityBypassTenant, setTogglingSecurityBypassTenant] = useState(null); - const [currentPage, setCurrentPage] = useState(0); - const tenantsPerPage = tenantSectionExpanded ? 7 : 3; // Preview Whitelist state const [previewApproved, setPreviewApproved] = useState>(new Set()); - const [loadingPreview, setLoadingPreview] = useState(false); - const [togglingPreviewTenant, setTogglingPreviewTenant] = useState(null); - - // Admin Management state (for Edit Modal) - const [tenantAdmins, setTenantAdmins] = useState([]); - const [loadingAdmins, setLoadingAdmins] = useState(false); - const [newAdminEmail, setNewAdminEmail] = useState(""); - const [addingAdmin, setAddingAdmin] = useState(false); - const [removingAdmin, setRemovingAdmin] = useState(null); - const [togglingAdmin, setTogglingAdmin] = useState(null); - const [currentAdminPage, setCurrentAdminPage] = useState(0); - const [adminSearchQuery, setAdminSearchQuery] = useState(""); - const adminsPerPage = 5; // Load galactic admin mode from localStorage useEffect(() => { @@ -187,7 +73,7 @@ export default function AdminConfigurationPage() { setLoadingConfig(true); setError(null); - + const token = await getAccessToken(); if (!token) { throw new Error('Failed to get access token'); @@ -272,184 +158,6 @@ export default function AdminConfigurationPage() { fetchTenants(); }, [fetchTenants]); - const handleTriggerMaintenance = async () => { - try { - setTriggeringMaintenance(true); - setError(null); - setSuccessMessage(null); - - - const token = await getAccessToken(); - if (!token) { - throw new Error('Failed to get access token'); - } - - const queryParams = maintenanceDate ? `?date=${maintenanceDate}` : ''; - const response = await fetch(`${API_BASE_URL}/api/maintenance/trigger${queryParams}`, { - method: "POST", - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - throw new Error(`Failed to trigger maintenance: ${response.statusText}`); - } - - const result = await response.json(); - const dateInfo = maintenanceDate ? ` for ${maintenanceDate}` : ''; - setSuccessMessage(`Maintenance job completed successfully${dateInfo}!`); - - // Auto-hide success message after 5 seconds - setTimeout(() => setSuccessMessage(null), 5000); - } catch (err) { - console.error("Error triggering maintenance:", err); - setError(err instanceof Error ? err.message : "Failed to trigger maintenance job"); - } finally { - setTriggeringMaintenance(false); - } - }; - - const handleReseedAnalyzeRules = async () => { - try { - setReseedingRules(true); - setError(null); - setSuccessMessage(null); - - const token = await getAccessToken(); - if (!token) { - throw new Error('Failed to get access token'); - } - - const response = await fetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=analyze`, { - method: "POST", - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Failed to reseed analyze rules: ${response.statusText}`); - } - - const result = await response.json(); - setSuccessMessage(`Analyze rules reseeded from GitHub: ${result.analyze?.deleted ?? 0} deleted, ${result.analyze?.written ?? 0} written`); - setTimeout(() => setSuccessMessage(null), 5000); - } catch (err) { - console.error("Error reseeding analyze rules:", err); - setError(err instanceof Error ? err.message : "Failed to reseed analyze rules"); - } finally { - setReseedingRules(false); - } - }; - - const handleReseedGatherRules = async () => { - try { - setReseedingGatherRules(true); - setError(null); - setSuccessMessage(null); - - const token = await getAccessToken(); - if (!token) { - throw new Error('Failed to get access token'); - } - - const response = await fetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=gather`, { - method: "POST", - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Failed to reseed gather rules: ${response.statusText}`); - } - - const result = await response.json(); - setSuccessMessage(`Gather rules reseeded from GitHub: ${result.gather?.deleted ?? 0} deleted, ${result.gather?.written ?? 0} written`); - setTimeout(() => setSuccessMessage(null), 5000); - } catch (err) { - console.error("Error reseeding gather rules:", err); - setError(err instanceof Error ? err.message : "Failed to reseed gather rules"); - } finally { - setReseedingGatherRules(false); - } - }; - - const handleReseedImePatterns = async () => { - try { - setReseedingImePatterns(true); - setError(null); - setSuccessMessage(null); - - const token = await getAccessToken(); - if (!token) { - throw new Error('Failed to get access token'); - } - - const response = await fetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=ime`, { - method: "POST", - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Failed to reseed IME log patterns: ${response.statusText}`); - } - - const result = await response.json(); - setSuccessMessage(`IME log patterns reseeded from GitHub: ${result.ime?.deleted ?? 0} deleted, ${result.ime?.written ?? 0} written`); - setTimeout(() => setSuccessMessage(null), 5000); - } catch (err) { - console.error("Error reseeding IME log patterns:", err); - setError(err instanceof Error ? err.message : "Failed to reseed IME log patterns"); - } finally { - setReseedingImePatterns(false); - } - }; - - const handleFetchFromGitHub = async () => { - try { - setFetchingFromGitHub(true); - setError(null); - setSuccessMessage(null); - - const token = await getAccessToken(); - if (!token) { - throw new Error('Failed to get access token'); - } - - const response = await fetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=all`, { - method: "POST", - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Failed to reseed from GitHub: ${response.statusText}`); - } - - const result = await response.json(); - setSuccessMessage( - `GitHub Reseed complete: Gather (${result.gather?.deleted ?? 0} deleted, ${result.gather?.written ?? 0} written), ` + - `Analyze (${result.analyze?.deleted ?? 0} deleted, ${result.analyze?.written ?? 0} written), ` + - `IME (${result.ime?.deleted ?? 0} deleted, ${result.ime?.written ?? 0} written)` - ); - setTimeout(() => setSuccessMessage(null), 8000); - } catch (err) { - console.error("Error reseeding from GitHub:", err); - setError(err instanceof Error ? err.message : "Failed to reseed from GitHub"); - } finally { - setFetchingFromGitHub(false); - } - }; - const handleSaveDiagPaths = async (paths: DiagnosticsLogPath[]) => { if (!adminConfig) return; try { @@ -550,534 +258,6 @@ export default function AdminConfigurationPage() { setError(null); }; - const handleSaveTenant = async (tenant: TenantConfiguration) => { - try { - setSavingTenant(true); - setError(null); - setSuccessMessage(null); - - const token = await getAccessToken(); - if (!token) { - throw new Error('Failed to get access token'); - } - - const response = await fetch(`${API_BASE_URL}/api/config/${tenant.tenantId}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(tenant), - }); - - if (!response.ok) { - throw new Error(`Failed to save tenant configuration: ${response.statusText}`); - } - - const result = await response.json(); - - // Update tenant in list - setTenants(tenants.map(t => t.tenantId === tenant.tenantId ? result.config : t)); - setEditingTenant(null); - setSuccessMessage(`Tenant ${tenant.tenantId} configuration saved successfully!`); - - // Auto-hide success message after 3 seconds - setTimeout(() => setSuccessMessage(null), 3000); - } catch (err) { - console.error("Error saving tenant configuration:", err); - setError(err instanceof Error ? err.message : "Failed to save tenant configuration"); - } finally { - setSavingTenant(false); - } - }; - - const handleToggleSecurityBypass = async (tenant: TenantConfiguration) => { - try { - setTogglingSecurityBypassTenant(tenant.tenantId); - setError(null); - setSuccessMessage(null); - - const token = await getAccessToken(); - if (!token) { - throw new Error('Failed to get access token'); - } - - const newValue = !tenant.allowInsecureAgentRequests; - const response = await fetch(`${API_BASE_URL}/api/config/${tenant.tenantId}/security-bypass`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ allowInsecureAgentRequests: newValue }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `Failed to update security bypass: ${response.statusText}`); - } - - setTenants(prev => prev.map(t => - t.tenantId === tenant.tenantId - ? { ...t, allowInsecureAgentRequests: newValue } - : t - )); - - setEditingTenant(prev => prev && prev.tenantId === tenant.tenantId - ? { ...prev, allowInsecureAgentRequests: newValue } - : prev); - - setSuccessMessage( - newValue - ? `Security bypass enabled for tenant ${tenant.tenantId}.` - : `Security bypass disabled for tenant ${tenant.tenantId}.` - ); - setTimeout(() => setSuccessMessage(null), 4000); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to update security bypass"); - } finally { - setTogglingSecurityBypassTenant(null); - } - }; - - const handleTogglePreview = async (tenantId: string) => { - try { - setTogglingPreviewTenant(tenantId); - setError(null); - setSuccessMessage(null); - - const token = await getAccessToken(); - if (!token) { - throw new Error('Failed to get access token'); - } - - const isCurrentlyApproved = previewApproved.has(tenantId); - - const response = await fetch(`${API_BASE_URL}/api/preview-whitelist/${tenantId}`, { - method: isCurrentlyApproved ? "DELETE" : "POST", - headers: { 'Authorization': `Bearer ${token}` } - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `Failed to update preview access: ${response.statusText}`); - } - - setPreviewApproved(prev => { - const next = new Set(prev); - if (isCurrentlyApproved) { - next.delete(tenantId); - } else { - next.add(tenantId); - } - return next; - }); - - setSuccessMessage( - isCurrentlyApproved - ? `Preview access revoked for tenant ${tenantId}` - : `Preview access granted for tenant ${tenantId}` - ); - setTimeout(() => setSuccessMessage(null), 4000); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to update preview access"); - } finally { - setTogglingPreviewTenant(null); - } - }; - - // Fetch tenant admins for editing - const fetchTenantAdmins = async (tenantId: string) => { - try { - setLoadingAdmins(true); - setCurrentAdminPage(0); // Reset to first page when loading new tenant - - const token = await getAccessToken(); - if (!token) { - throw new Error('Failed to get access token'); - } - - const response = await fetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins`, { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - throw new Error(`Failed to load admins: ${response.statusText}`); - } - - const data: TenantAdmin[] = await response.json(); - setTenantAdmins(data); - } catch (err) { - console.error("Error fetching tenant admins:", err); - setError(err instanceof Error ? err.message : "Failed to load tenant admins"); - } finally { - setLoadingAdmins(false); - } - }; - - const handleAddTenantAdmin = async (tenantId: string) => { - if (!newAdminEmail.trim()) return; - - try { - setAddingAdmin(true); - setError(null); - - const token = await getAccessToken(); - if (!token) { - throw new Error('Failed to get access token'); - } - - const response = await fetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins`, { - method: "POST", - headers: { - "Content-Type": "application/json", - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ upn: newAdminEmail.trim() }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || `Failed to add admin: ${response.statusText}`); - } - - setSuccessMessage(`Admin ${newAdminEmail} added successfully!`); - setNewAdminEmail(""); - - // Refresh admin list - await fetchTenantAdmins(tenantId); - - // Auto-hide success message after 3 seconds - setTimeout(() => setSuccessMessage(null), 3000); - } catch (err) { - console.error("Error adding tenant admin:", err); - setError(err instanceof Error ? err.message : "Failed to add admin"); - } finally { - setAddingAdmin(false); - } - }; - - const handleRemoveTenantAdmin = async (tenantId: string, adminUpn: string) => { - if (!confirm(`Are you sure you want to remove ${adminUpn} as an admin?`)) { - return; - } - - try { - setRemovingAdmin(adminUpn); - setError(null); - - const token = await getAccessToken(); - if (!token) { - throw new Error('Failed to get access token'); - } - - const response = await fetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}`, { - method: "DELETE", - headers: { - 'Authorization': `Bearer ${token}` - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || `Failed to remove admin: ${response.statusText}`); - } - - setSuccessMessage(`Admin ${adminUpn} removed successfully!`); - - // Refresh admin list - await fetchTenantAdmins(tenantId); - - // Auto-hide success message after 3 seconds - setTimeout(() => setSuccessMessage(null), 3000); - } catch (err) { - console.error("Error removing tenant admin:", err); - setError(err instanceof Error ? err.message : "Failed to remove admin"); - } finally { - setRemovingAdmin(null); - } - }; - - const handleToggleTenantAdmin = async (tenantId: string, adminUpn: string, isEnabled: boolean) => { - try { - setTogglingAdmin(adminUpn); - setError(null); - - const token = await getAccessToken(); - if (!token) { - throw new Error('Failed to get access token'); - } - - const action = isEnabled ? 'disable' : 'enable'; - const response = await fetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/${action}`, { - method: "PATCH", - headers: { - 'Authorization': `Bearer ${token}` - }, - }); - - if (!response.ok) { - let errorData; - try { - errorData = await response.json(); - } catch { - errorData = { error: `Failed to ${action} admin: ${response.statusText}` }; - } - throw new Error(errorData.error || `Failed to ${action} admin: ${response.statusText}`); - } - - setSuccessMessage(`Admin ${adminUpn} ${isEnabled ? 'disabled' : 'enabled'} successfully!`); - - // Refresh admin list - await fetchTenantAdmins(tenantId); - - // Auto-hide success message after 3 seconds - setTimeout(() => setSuccessMessage(null), 3000); - } catch (err) { - console.error("Error toggling tenant admin:", err); - setError(err instanceof Error ? err.message : "Failed to toggle admin"); - } finally { - setTogglingAdmin(null); - } - }; - - // Filter and sort tenants - const filteredTenants = tenants.filter(t => { - const matchesSearch = - t.tenantId.toLowerCase().includes(searchQuery.toLowerCase()) || - t.domainName.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesWaitlist = !showOnlyWaitlist || !previewApproved.has(t.tenantId); - return matchesSearch && matchesWaitlist; - }); - - // Statistics (always over all tenants, not filtered) - const readyCount = tenants.filter(t => t.validateAutopilotDevice).length; - const waitlistCount = tenants.filter(t => !previewApproved.has(t.tenantId)).length; - const totalCount = tenants.length; - - // Pagination - const totalPages = Math.ceil(filteredTenants.length / tenantsPerPage); - const startIndex = currentPage * tenantsPerPage; - const endIndex = startIndex + tenantsPerPage; - const paginatedTenants = filteredTenants.slice(startIndex, endIndex); - - // Reset to first page when search changes - useEffect(() => { - setCurrentPage(0); - }, [searchQuery, showOnlyWaitlist]); - - // Admin Pagination with Search - const filteredAdmins = tenantAdmins.filter(admin => - admin.upn.toLowerCase().includes(adminSearchQuery.toLowerCase()) - ); - const totalAdminPages = Math.ceil(filteredAdmins.length / adminsPerPage); - const startAdminIndex = currentAdminPage * adminsPerPage; - const endAdminIndex = startAdminIndex + adminsPerPage; - const paginatedAdmins = filteredAdmins.slice(startAdminIndex, endAdminIndex); - - const cleanId = (v: string) => v.trim().replace(/^["'\s]+|["'\s]+$/g, ""); - - const handleFetchExportEvents = async () => { - const sid = cleanId(exportSessionId); - const tid = cleanId(exportTenantId); - if (!sid || !tid) { - setExportError("Session ID and Tenant ID are required."); - return; - } - try { - setExportLoading(true); - setExportError(null); - setExportedEvents(null); - const token = await getAccessToken(); - if (!token) throw new Error("Failed to get access token"); - const res = await fetch( - `${API_BASE_URL}/api/sessions/${sid}/events?tenantId=${encodeURIComponent(tid)}`, - { headers: { Authorization: `Bearer ${token}` } } - ); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); - const data = await res.json(); - if (!data.success) throw new Error(data.message || "Backend returned error"); - setExportedEvents(data.events ?? []); - } catch (err) { - setExportError(err instanceof Error ? err.message : "Failed to fetch events"); - } finally { - setExportLoading(false); - } - }; - - const downloadFile = (content: string, filename: string, mimeType: string) => { - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - }; - - const generateCsvExport = (events: SessionExportEvent[]) => { - const isV1 = events.some(e => e.phase === 2); - const phaseNames = isV1 ? EXPORT_V1_PHASE_NAMES : EXPORT_V2_PHASE_NAMES; - const esc = (v: string) => `"${v.replace(/"/g, '""')}"`; - // Sort exactly as Azure Table Storage: by timestamp ascending, then sequence ascending - const sorted = [...events].sort((a, b) => { - const tCmp = (a.timestamp ?? "").localeCompare(b.timestamp ?? ""); - if (tCmp !== 0) return tCmp; - return (a.sequence ?? 0) - (b.sequence ?? 0); - }); - const header = "RowKey,EventId,SessionId,TenantId,Timestamp,EventType,Severity,Source,Phase,PhaseName,Message,Sequence,Data"; - const rows = sorted.map(e => [ - esc(e.rowKey ?? ""), - esc(e.eventId ?? ""), - esc(e.sessionId ?? ""), - esc(e.tenantId ?? ""), - esc(e.timestamp ?? ""), - esc(e.eventType ?? ""), - esc(e.severity ?? ""), - esc(e.source ?? ""), - String(e.phase ?? 0), - esc(phaseNames[e.phase] ?? "Unknown"), - esc(e.message ?? ""), - String(e.sequence ?? 0), - esc(e.data ? JSON.stringify(e.data) : ""), - ].join(",")); - return "\uFEFF" + header + "\n" + rows.join("\n"); - }; - - const generateUiExport = (events: SessionExportEvent[], sessionId: string, tenantId: string) => { - const isV1 = events.some(e => e.phase === 2); - const phaseNames = isV1 ? EXPORT_V1_PHASE_NAMES : EXPORT_V2_PHASE_NAMES; - const phaseOrder = isV1 ? EXPORT_V1_PHASE_ORDER : EXPORT_V2_PHASE_ORDER; - - const sorted = [...events].sort((a, b) => a.sequence - b.sequence); - - // Replicate eventsByPhase useMemo logic: assign Unknown-phase events to active named phase - const grouped: Record = {}; - phaseOrder.forEach(p => { grouped[p] = []; }); - - let lastNamedPhase = phaseOrder[0]; - for (const ev of sorted) { - const name = phaseNames[ev.phase]; - if (name && name !== "Unknown") { - lastNamedPhase = name; - if (grouped[name]) grouped[name].push({ ...ev, phaseName: name }); - } else { - // Unknown phase — insert into currently active named phase - if (grouped[lastNamedPhase]) grouped[lastNamedPhase].push({ ...ev, phaseName: "Unknown" }); - } - } - - const pad = (s: string, len: number) => s.padEnd(len); - const severityLabel = (s: string) => pad(s ?? "Unknown", 7); - - const lines: string[] = []; - lines.push("AUTOPILOT MONITOR \u2014 SESSION EVENT EXPORT"); - lines.push("========================================="); - lines.push(`Session ID : ${sessionId}`); - lines.push(`Tenant ID : ${tenantId}`); - lines.push(`Exported at : ${new Date().toISOString()}`); - lines.push(`Total events : ${events.length}`); - lines.push(`Enrollment : ${isV1 ? "V1" : "V2"}`); - - for (const phase of phaseOrder) { - const phaseEvents = grouped[phase] ?? []; - lines.push(""); - lines.push("\u2550".repeat(43)); - lines.push(` ${phase} (${phaseEvents.length} event${phaseEvents.length !== 1 ? "s" : ""})`); - lines.push("\u2550".repeat(43)); - if (phaseEvents.length === 0) { - lines.push(" (no events)"); - } else { - for (const ev of phaseEvents) { - const ts = ev.timestamp ? new Date(ev.timestamp).toISOString().replace("T", " ").substring(0, 23) : "?"; - lines.push(`[${ts}] [${severityLabel(ev.severity)}] ${ev.eventType} \u2014 ${ev.message}`); - let detail = ` Source: ${ev.source ?? "?"} | Seq: ${ev.sequence ?? "?"} | EventId: ${ev.eventId ?? "?"}`; - if (ev.phaseName === "Unknown") detail += ` | RawPhase: ${ev.phase}`; - lines.push(detail); - if (ev.data && Object.keys(ev.data).length > 0) { - lines.push(` Data: ${JSON.stringify(ev.data)}`); - } - } - } - } - - return lines.join("\n"); - }; - - const fetchBlockedDevices = async (tenantId: string) => { - if (!tenantId) return; - try { - setLoadingBlockedDevices(true); - const token = await getAccessToken(); - if (!token) throw new Error("Failed to get access token"); - const response = await fetch(`${API_BASE_URL}/api/devices/blocked?tenantId=${encodeURIComponent(tenantId)}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!response.ok) throw new Error(`Failed to load blocked devices: ${response.statusText}`); - const data = await response.json(); - setBlockedDevices(data.blocked ?? []); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load blocked devices"); - } finally { - setLoadingBlockedDevices(false); - } - }; - - const handleBlockDevice = async () => { - if (!blockSerialNumber.trim() || !blockTenantId.trim()) return; - try { - setBlockingDevice(true); - setError(null); - const token = await getAccessToken(); - if (!token) throw new Error("Failed to get access token"); - const response = await fetch(`${API_BASE_URL}/api/devices/block`, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, - body: JSON.stringify({ - tenantId: blockTenantId, - serialNumber: blockSerialNumber.trim(), - durationHours: blockDurationHours, - reason: blockReason.trim() || undefined, - }), - }); - const result = await response.json(); - if (!response.ok) throw new Error(result.message || response.statusText); - setSuccessMessage(`Device ${blockSerialNumber.trim()} blocked for ${blockDurationHours}h.`); - setTimeout(() => setSuccessMessage(null), 4000); - setBlockSerialNumber(""); - setBlockReason(""); - if (blockListTenantId === blockTenantId) await fetchBlockedDevices(blockTenantId); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to block device"); - } finally { - setBlockingDevice(false); - } - }; - - const handleUnblockDevice = async (tenantId: string, serialNumber: string) => { - try { - setUnblockingDevice(serialNumber); - setError(null); - const token = await getAccessToken(); - if (!token) throw new Error("Failed to get access token"); - const response = await fetch( - `${API_BASE_URL}/api/devices/block/${encodeURIComponent(serialNumber)}?tenantId=${encodeURIComponent(tenantId)}`, - { method: "DELETE", headers: { Authorization: `Bearer ${token}` } } - ); - const result = await response.json(); - if (!response.ok) throw new Error(result.message || response.statusText); - setSuccessMessage(`Device ${serialNumber} unblocked.`); - setTimeout(() => setSuccessMessage(null), 3000); - setBlockedDevices((prev) => prev.filter((d) => d.serialNumber !== serialNumber || d.tenantId !== tenantId)); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to unblock device"); - } finally { - setUnblockingDevice(null); - } - }; - if (!galacticAdminMode) { return null; } @@ -1129,1379 +309,70 @@ export default function AdminConfigurationPage() { )} {/* Tenant Management */} -
-
{ setTenantSectionExpanded(v => !v); setCurrentPage(0); }} - > -
-
- - - -
-

Tenant Management

-

View and manage all tenant configurations

-
-
-
- - - - -
-
-
-
- {loadingTenants ? ( -
-
-

Loading tenants...

-
- ) : ( -
- {/* Search */} -
-
- - - - setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" - /> -
- - {filteredTenants.length} tenant(s) -
+ - {/* Tenant List */} -
- {paginatedTenants.length === 0 ? ( -
- {showOnlyWaitlist ? "No waitlist tenants found" : searchQuery ? "No tenants found matching your search" : "No tenants registered yet"} -
- ) : ( - <> - {paginatedTenants.map((tenant) => ( -
-
-
-

- {tenant.domainName || tenant.tenantId} -

-

- Tenant ID: {tenant.tenantId} -

-
-
-
- {tenant.disabled && ( - - Suspended - - )} - {previewApproved.has(tenant.tenantId) ? ( - - Preview - - ) : ( - - Waitlist - - )} - {tenant.validateAutopilotDevice && ( - - Ready - - )} -
-
- - -
-
-
-
- ))} - - {/* Pagination */} - {totalPages > 1 && ( -
- - - Page {currentPage + 1} of {totalPages} - - -
- )} - - )} -
- - {/* Statistics */} - {totalCount > 0 && ( -
- - {readyCount} - {' '}of{' '} - {totalCount} - {' '}Tenant(s) are Ready - - - {waitlistCount} - {' '}of{' '} - {totalCount} - {' '}Tenant(s) are on the Waitlist - -
- )} -
- )} -
-
- - {/* Manual Maintenance Trigger */} -
-
-
- - - -
-

Manual Maintenance Trigger

-

Execute platform-wide maintenance operations

-
-
-
-
-

- Manually trigger the daily maintenance job which includes: -

-
    -
  • - • - Mark stalled sessions as timed out -
  • -
  • - • - Aggregate metrics into historical snapshots (with automatic catch-up for missed days) -
  • -
  • - • - Clean up old data based on retention policies -
  • -
-
- - setMaintenanceDate(e.target.value)} - max={new Date(Date.now() - 86400000).toISOString().split('T')[0]} - className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500" - /> -

- Leave empty to run the standard maintenance with automatic catch-up (aggregates any missed days within the last 7 days). - Select a specific date to manually aggregate metrics for that day, e.g. to backfill data older than 7 days. -

-
-
-
- - - -

- Warning: This operation runs across all tenants and may take several minutes to complete. - Use this only for testing or when immediate cleanup is needed. -

-
-
-
- -
-
-
- - {/* Fetch & Reseed from GitHub */} -
-
-
- - - -
-

Fetch & Reseed from GitHub

-

Fetch the latest rules from the GitHub repository and reseed all rule types

-
-
-
-
-

- This operation fetches all rule definitions from the GitHub repository and writes them to Azure Table Storage: -

-
    -
  • - • - Fetches Gather Rules, Analyze Rules, and IME Log Patterns from GitHub -
  • -
  • - • - Deletes all existing global built-in entries and writes the latest versions -
  • -
  • - • - Tenant-specific custom rules and overrides are not affected -
  • -
-
-
- - - -

- Rules are loaded from raw.githubusercontent.com. After a merge to the repository, it may take up to 5 minutes for GitHub's CDN cache to update. If you see stale rules after reseeding, wait a few minutes and try again. -

-
-
-
- -
-
-
- - {/* Individual Reseed Sections */} -
- {/* Reseed Analyze Rules */} -
-
-

Reseed Analyze Rules

-

Fetch and reseed only analyze rules from GitHub

-
-
-
- -
-
-
- - {/* Reseed Gather Rules */} -
-
-

Reseed Gather Rules

-

Fetch and reseed only gather rules from GitHub

-
-
-
- -
-
-
- - {/* Reseed IME Log Patterns */} -
-
-

Reseed IME Log Patterns

-

Fetch and reseed only IME log patterns from GitHub

-
-
-
- -
-
-
-
+ {/* Maintenance + Reseed Sections */} + {/* Diagnostics Log Paths */} -
-
-
- - - -
-

Diagnostics Log Paths

-

Global log file paths included in diagnostics packages for all tenants. Tenants may add their own paths in Settings.

-
-
-
-
- {/* Info box */} -
- - - -

- All paths are validated on the agent against an allowlist of safe prefixes (DiagnosticsPathGuards). Wildcards are only allowed in the last path segment. Environment variables are expanded by the agent. -

-
- - {/* Current paths list */} - {loadingConfig ? ( -
-
- Loading... -
- ) : globalDiagPaths.length === 0 ? ( -

No global paths configured yet.

- ) : ( -
- {globalDiagPaths.map((entry, idx) => ( -
-
-

{entry.path}

- {entry.description && ( -

{entry.description}

- )} -
- -
- ))} -
- )} - - {/* Add new path */} -
- setNewDiagPath(e.target.value)} - className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500 font-mono" - /> - setNewDiagDesc(e.target.value)} - className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500" - /> - -
- - {/* Save button */} -
- -
-
-
+ {/* Device Block Management */} -
-
-
- - - -
-

Device Block Management

-

Temporarily block a rogue device from sending data. The agent will stop uploading when it receives the block signal.

-
-
-
-
- {/* Maintenance Auto-Block Settings */} -
-
- - - -

Maintenance Auto-Block Settings

-
-

- The nightly maintenance function automatically blocks devices that are still actively sending data beyond the configured window. Sessions with a LastEventAt timestamp within the window and a StartedAt older than the window are flagged – regardless of session status. -

-
-
- - setMaxSessionWindowHours(parseInt(e.target.value) || 0)} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" - /> -

0 = disabled

-
-
- - setMaintenanceBlockDurationHours(parseInt(e.target.value) || 12)} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" - /> -

Applied by the maintenance function

-
-
- -
+ - {/* Block a device form */} -
-

Block a Device

-
-
- - setBlockSerialNumber(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" - /> -
-
- - -
-
- - setBlockDurationHours(parseInt(e.target.value) || 12)} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" - /> -
-
- - setBlockReason(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" - /> -
-
- -
+ {/* Session Event Export */} + - {/* View active blocks for a tenant */} -
-

Active Blocks

-
-
- - -
- -
- {blockedDevices.length > 0 ? ( -
- - - - - - - - - - - - - {blockedDevices.map((d) => ( - - - - - - - - - ))} - -
Serial NumberBlocked SinceUnblocks AtBlocked ByReasonAction
{d.serialNumber}{new Date(d.blockedAt).toLocaleString()}{new Date(d.unblockAt).toLocaleString()}{d.blockedByEmail}{d.reason || "—"} - -
-
- ) : blockListTenantId && !loadingBlockedDevices ? ( -

No active blocks for this tenant.

- ) : null} -
-
-
- - {/* Session Event Export Tool */} -
-
-
- - - -
-

Session Event Export

-

Fetch and export all events directly from storage — use to analyze timeline phase grouping and ordering

-
-
-
-
-
-
-
- - setExportSessionId(e.target.value)} - className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500" - /> -
-
- - {tenants.length > 0 ? ( -
- - setExportTenantId(e.target.value)} - className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500" - /> -
- ) : ( - setExportTenantId(e.target.value)} - className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500" - /> - )} -
-
- -
- -
- - {exportError && ( -
- - - - {exportError} -
- )} - - {exportedEvents !== null && ( -
-
- - - - - {exportedEvents.length} events loaded - {" \u00B7 "} - {exportedEvents.some(e => e.phase === 2) ? "V1" : "V2"} - {" \u00B7 "} - session {cleanId(exportSessionId).slice(0, 8)} - -
-
- - -
-
- )} -
-
-
- - {/* Global Rate Limiting Configuration */} -
-
-
- - - -
-

Global Settings

-

Configure global settings for all tenants

-
-
-
-
- {loadingConfig ? ( -
-
-

Loading configuration...

-
- ) : ( -
-
- -
- -
- -
- -
- -
- - {adminConfig && ( -
-
- - - -
-

Configuration Info

-

Last updated: {new Date(adminConfig.lastUpdated).toLocaleString()}

-

Updated by: {adminConfig.updatedBy}

-
-
-
- )} - -
- - -
-
- )} -
-
- - {/* Edit Tenant Modal */} - {editingTenant && ( -
-
-
-

Edit Tenant Configuration

-

{editingTenant.tenantId}

-
- -
- {/* Tenant Suspension */} -
-

Tenant Suspension

-
- - - {editingTenant.disabled && ( - <> -
- - setEditingTenant({ ...editingTenant, disabledReason: e.target.value })} - placeholder="Optional: Why is this tenant suspended?" - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500" - /> -
-
- - setEditingTenant({ ...editingTenant, disabledUntil: e.target.value ? new Date(e.target.value).toISOString() : undefined })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500" - /> -

Optional: Auto-enable after this date/time

-
- - )} -
-
- - {/* Admin Users Info */} -
-
- - - -
-

Admin Users Management

- - {/* Current Admins List */} -
-

- Current Admins ({tenantAdmins.length}): -

- - {/* Search Field */} -
-
- { - setAdminSearchQuery(e.target.value); - setCurrentAdminPage(0); - }} - placeholder="Search by email..." - autoComplete="off" - className="w-full px-3 py-2 pl-9 pr-9 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" - /> - - - - {adminSearchQuery && ( - - )} -
-
- - {loadingAdmins ? ( -
Loading admins...
- ) : tenantAdmins.length === 0 ? ( -
No admins configured yet
- ) : filteredAdmins.length === 0 ? ( -
- No admins match your search -
- ) : ( - <> -
- {paginatedAdmins.map((admin) => ( -
-
-
-
{admin.upn}
- {!admin.isEnabled && ( - - Disabled - - )} -
-
- Added {new Date(admin.addedDate).toLocaleDateString()} by {admin.addedBy} -
-
-
- - -
-
- ))} -
- - {/* Pagination */} - {totalAdminPages > 1 && ( -
- - - Page {currentAdminPage + 1} of {totalAdminPages} ({filteredAdmins.length} admin{filteredAdmins.length !== 1 ? 's' : ''}) - - -
- )} - - )} -
- - {/* Add New Admin */} -
-

Add New Admin:

-
- setNewAdminEmail(e.target.value)} - placeholder="admin@tenant.com" - autoComplete="off" - className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleAddTenantAdmin(editingTenant.tenantId); - } - }} - /> - -
-
-
-
-
- - {/* Rate Limit */} -
- - setEditingTenant({ ...editingTenant, rateLimitRequestsPerMinute: parseInt(e.target.value) || 100 })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" - /> -
- - {/* Hardware Whitelist */} -
- - setEditingTenant({ ...editingTenant, manufacturerWhitelist: e.target.value })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" - /> -
- -
- - setEditingTenant({ ...editingTenant, modelWhitelist: e.target.value })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" - /> -
- - - -
-

Galactic Admin Test Bypass

-

- Allows agent requests even when Autopilot device validation is disabled. Use only for temporary test tenants. -

-
- - {editingTenant.allowInsecureAgentRequests ? "Bypass is ENABLED" : "Bypass is DISABLED"} - - -
-
- - {/* Data Management */} -
- - setEditingTenant({ ...editingTenant, dataRetentionDays: parseInt(e.target.value) || 90 })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" - /> -
- -
- - setEditingTenant({ ...editingTenant, sessionTimeoutHours: parseInt(e.target.value) || 5 })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" - /> -
- -
- - setEditingTenant({ ...editingTenant, maxNdjsonPayloadSizeMB: parseInt(e.target.value) || 5 })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" - /> -
-
- - {/* Modal Actions */} -
- - -
-
-
- )} + {/* Global Settings */} + 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 e0141e52..724a85d3 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -7,6 +7,11 @@ import { ProtectedRoute } from "../../components/ProtectedRoute"; import { useTenant } from "../../contexts/TenantContext"; import { useAuth } from "../../contexts/AuthContext"; import { API_BASE_URL } from "@/lib/config"; +import { downloadAsJson, stripInternalFields, bumpVersion } from "@/lib/rulePageHelpers"; +import { StatCard } from "@/components/rules/StatCard"; +import { RuleFilterBar } from "@/components/rules/RuleFilterBar"; +import { EmptyState } from "@/components/rules/EmptyState"; +import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJsonToggle"; interface RelatedDoc { title: string; @@ -103,22 +108,6 @@ const CATEGORY_COLORS: Record = { enrollment: { bg: "bg-indigo-100", text: "text-indigo-700" }, }; -function downloadAsJson(data: unknown, filename: string) { - const json = JSON.stringify(data, null, 2); - const blob = new Blob([json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); -} - -function stripInternalFields(rule: AnalyzeRule) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { isBuiltIn, isCommunity, createdAt, updatedAt, ...rest } = rule; - return rest; -} function getSeverityColor(severity: string) { return SEVERITY_COLORS[severity.toLowerCase()] || SEVERITY_COLORS.info; @@ -166,13 +155,6 @@ const EMPTY_FORM: RuleForm = { relatedDocs: [], }; -function bumpVersion(v: string): string { - const parts = (v ?? "1.0").split("."); - const major = parts[0] ?? "1"; - const minor = parseInt(parts[1] ?? "0", 10); - return `${major}.${minor + 1}`; -} - function ruleToForm(rule: AnalyzeRule): RuleForm { return { ruleId: rule.ruleId, @@ -811,81 +793,58 @@ export default function AnalyzeRulesPage() {
{/* Summary Stats */}
-
-

Total

-

{totalRules}

-
-
-

Active

-

{activeRules}

-
-
-

Critical

-

{criticalCount}

-
-
-

High

-

{highCount}

-
-
-

Warning

-

{warningCount}

-
-
-

Info

-

{infoCount}

-
+ + + + + +
{/* Filter Bar + Create Button */} -
-
-
-
- - setSearchQuery(e.target.value)} className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" /> - {searchQuery && ( - - )} -
- - - -
-
- - -
-
-
- Showing {filteredRules.length} of {totalRules} rule{totalRules !== 1 ? "s" : ""} -
-
+ ({ value: cat, label: cat.charAt(0).toUpperCase() + cat.slice(1) })), + ], + }, + { + label: "Type", + value: typeFilter, + onChange: setTypeFilter, + options: [ + { value: "all", label: "All Types" }, + { value: "builtin", label: "Built-in" }, + { value: "community", label: "Community" }, + { value: "custom", label: "Custom" }, + ], + }, + ]} + onExportAll={handleExportAll} + onCreateNew={() => { setShowCreateForm(!showCreateForm); if (showCreateForm) setNewRule({ ...EMPTY_FORM, conditions: [{ ...EMPTY_CONDITION }] }); }} + createLabel="Create Custom Rule" + showCreateForm={showCreateForm} + /> {/* Create Custom Rule Form */} {showCreateForm && ( @@ -900,41 +859,43 @@ export default function AnalyzeRulesPage() {
{/* JSON Mode Toggle */} -
- - -
+ { + if (mode) { setJsonText(JSON.stringify(newRule, null, 2)); } + setJsonModeCreate(mode); + setJsonError(null); + }} + />
- {jsonModeCreate ? ( -
-
-

Edit the rule as JSON. All fields are supported including event_correlation condition properties.

- -
- {jsonError &&

{jsonError}

} -