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