refactor: reorganize project structure

This commit is contained in:
Oliver Kieselbach
2026-02-28 19:25:34 +01:00
parent 4ce531191b
commit 13a781bd34
40 changed files with 7791 additions and 6901 deletions
@@ -1 +1 @@
107
111
@@ -0,0 +1,380 @@
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services
{
public partial class TableStorageService
{
// ===== AUDIT LOG METHODS =====
/// <summary>
/// Logs an audit entry
/// </summary>
public async Task<bool> LogAuditEntryAsync(string tenantId, string action, string entityType, string entityId, string performedBy, Dictionary<string, string>? details = null)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
var timestamp = DateTime.UtcNow;
var entity = new TableEntity(tenantId, Guid.NewGuid().ToString())
{
{ "Action", action },
{ "EntityType", entityType },
{ "EntityId", entityId },
{ "PerformedBy", performedBy },
{ "Timestamp", timestamp },
{ "Details", details != null ? JsonConvert.SerializeObject(details) : string.Empty }
};
await tableClient.AddEntityAsync(entity);
_logger.LogInformation($"Audit log created: {action} on {entityType} {entityId} by {performedBy}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create audit log entry");
return false;
}
}
/// <summary>
/// Gets audit log entries for a tenant
/// </summary>
public async Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, int maxResults = 100)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
var logs = new List<AuditLogEntry>();
await foreach (var entity in query)
{
logs.Add(new AuditLogEntry
{
Id = entity.RowKey,
TenantId = entity.PartitionKey,
Action = entity.GetString("Action") ?? string.Empty,
EntityType = entity.GetString("EntityType") ?? string.Empty,
EntityId = entity.GetString("EntityId") ?? string.Empty,
PerformedBy = entity.GetString("PerformedBy") ?? string.Empty,
Timestamp = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.UtcNow,
Details = entity.GetString("Details") ?? string.Empty
});
if (logs.Count >= maxResults) break;
}
// Sort by timestamp descending (most recent first)
return logs.OrderByDescending(l => l.Timestamp).Take(maxResults).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get audit logs for tenant {tenantId}");
return new List<AuditLogEntry>();
}
}
// ===== DATA RETENTION METHODS =====
/// <summary>
/// Gets sessions older than a specific date for a tenant
/// </summary>
public async Task<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
// Query sessions for this tenant older than cutoff date
var filter = $"PartitionKey eq '{tenantId}' and StartedAt lt datetime'{cutoffDate:yyyy-MM-ddTHH:mm:ss}Z'";
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var sessions = new List<SessionSummary>();
await foreach (var entity in query)
{
sessions.Add(MapToSessionSummary(entity));
}
_logger.LogInformation($"Found {sessions.Count} sessions older than {cutoffDate:yyyy-MM-dd} for tenant {tenantId}");
return sessions;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get old sessions for tenant {tenantId}");
return new List<SessionSummary>();
}
}
/// <summary>
/// Gets all sessions within a date range, optionally filtered by tenant.
/// Uses server-side filtering to avoid loading all sessions into memory.
/// </summary>
public async Task<List<SessionSummary>> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null)
{
if (!string.IsNullOrEmpty(tenantId))
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var filter = !string.IsNullOrEmpty(tenantId)
? $"PartitionKey eq '{tenantId}' and StartedAt ge datetime'{startDate:yyyy-MM-ddTHH:mm:ss}Z' and StartedAt lt datetime'{endDate:yyyy-MM-ddTHH:mm:ss}Z'"
: $"StartedAt ge datetime'{startDate:yyyy-MM-ddTHH:mm:ss}Z' and StartedAt lt datetime'{endDate:yyyy-MM-ddTHH:mm:ss}Z'";
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var sessions = new List<SessionSummary>();
await foreach (var entity in query)
{
sessions.Add(MapToSessionSummary(entity));
}
_logger.LogInformation($"Found {sessions.Count} sessions between {startDate:yyyy-MM-dd} and {endDate:yyyy-MM-dd}" +
(tenantId != null ? $" for tenant {tenantId}" : ""));
return sessions;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get sessions by date range");
return new List<SessionSummary>();
}
}
/// <summary>
/// Gets stalled sessions (InProgress status, started before cutoff time) for a tenant.
/// Uses server-side filtering to avoid loading all sessions into memory.
/// </summary>
public async Task<List<SessionSummary>> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var filter = $"PartitionKey eq '{tenantId}' and Status eq 'InProgress' and StartedAt lt datetime'{cutoffTime:yyyy-MM-ddTHH:mm:ss}Z'";
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var sessions = new List<SessionSummary>();
await foreach (var entity in query)
{
sessions.Add(MapToSessionSummary(entity));
}
return sessions;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get stalled sessions for tenant {tenantId}");
return new List<SessionSummary>();
}
}
/// <summary>
/// Gets sessions where the device has sent data within the last window period
/// AND the session started before that window cutoff – indicating a device that has been
/// actively sending data for longer than the allowed maximum.
/// Status-independent: detects excessive data senders regardless of session status.
/// Uses LastEventAt (written on every event batch) for the "still active" check.
/// Sessions without LastEventAt (predating this field) are not returned.
/// </summary>
public async Task<List<SessionSummary>> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
// LastEventAt gt cutoff → device sent data recently (still active)
// StartedAt lt cutoff → session has been running longer than the allowed window
// IsPreProvisioned ne true → exclude WhiteGlove sessions: a pre-provisioned device
// that resumes after weeks in storage looks like an excessive sender (StartedAt old,
// LastEventAt recent) but is a legitimate resumption, not abuse.
var cutoffStr = windowCutoff.ToString("yyyy-MM-ddTHH:mm:ss");
var filter = $"PartitionKey eq '{tenantId}' " +
$"and LastEventAt gt datetime'{cutoffStr}Z' " +
$"and StartedAt lt datetime'{cutoffStr}Z' " +
$"and IsPreProvisioned ne true";
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var sessions = new List<SessionSummary>();
await foreach (var entity in query)
{
sessions.Add(MapToSessionSummary(entity));
}
return sessions;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get excessive data sender sessions for tenant {tenantId}");
return new List<SessionSummary>();
}
}
/// <summary>
/// Gets all unique tenant IDs from sessions table
/// </summary>
public async Task<List<string>> GetAllTenantIdsAsync()
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var query = tableClient.QueryAsync<TableEntity>(select: new[] { "PartitionKey" });
var tenantIds = new HashSet<string>();
await foreach (var entity in query)
{
tenantIds.Add(entity.PartitionKey);
}
_logger.LogInformation($"Found {tenantIds.Count} unique tenants");
return tenantIds.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get tenant IDs");
return new List<string>();
}
}
// ===== DELETION HELPERS =====
/// <summary>
/// Deletes all events for a session from storage
/// </summary>
public async Task<int> DeleteSessionEventsAsync(string tenantId, string sessionId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events);
// Query all events for this session using the same PartitionKey format as StoreEventAsync
var partitionKey = $"{tenantId}_{sessionId}";
var filter = $"PartitionKey eq '{partitionKey}'";
var events = tableClient.QueryAsync<TableEntity>(filter);
int deletedCount = 0;
var batch = new List<TableTransactionAction>();
await foreach (var eventEntity in events)
{
batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, eventEntity));
deletedCount++;
if (batch.Count >= 100)
{
await tableClient.SubmitTransactionAsync(batch);
batch.Clear();
}
}
if (batch.Count > 0)
{
await tableClient.SubmitTransactionAsync(batch);
}
_logger.LogInformation($"Deleted {deletedCount} events for session {sessionId}");
return deletedCount;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete events for session {sessionId}");
return 0;
}
}
/// <summary>
/// Deletes all rule results for a session
/// </summary>
public async Task<int> DeleteSessionRuleResultsAsync(string tenantId, string sessionId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults);
var partitionKey = $"{tenantId}_{sessionId}";
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
int deletedCount = 0;
var batch = new List<TableTransactionAction>();
await foreach (var entity in query)
{
batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity));
deletedCount++;
if (batch.Count >= 100)
{
await tableClient.SubmitTransactionAsync(batch);
batch.Clear();
}
}
if (batch.Count > 0)
{
await tableClient.SubmitTransactionAsync(batch);
}
return deletedCount;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete rule results for session {sessionId}");
return 0;
}
}
/// <summary>
/// Deletes all app install summaries for a session
/// </summary>
public async Task<int> DeleteSessionAppInstallSummariesAsync(string tenantId, string sessionId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries);
var query = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{tenantId}' and SessionId eq '{sessionId}'");
int deletedCount = 0;
var batch = new List<TableTransactionAction>();
await foreach (var entity in query)
{
batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity));
deletedCount++;
if (batch.Count >= 100)
{
await tableClient.SubmitTransactionAsync(batch);
batch.Clear();
}
}
if (batch.Count > 0)
{
await tableClient.SubmitTransactionAsync(batch);
}
return deletedCount;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete app install summaries for session {sessionId}");
return 0;
}
}
}
}
@@ -0,0 +1,581 @@
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
public partial class TableStorageService
{
// ===== HISTORICAL METRICS METHODS =====
/// <summary>
/// Saves a historical metrics snapshot
/// </summary>
public async Task<bool> SaveUsageMetricsSnapshotAsync(UsageMetricsSnapshot metrics)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics);
var entity = new TableEntity(metrics.Date, metrics.TenantId)
{
["ComputedAt"] = metrics.ComputedAt,
["ComputeDurationMs"] = metrics.ComputeDurationMs,
["SessionsTotal"] = metrics.SessionsTotal,
["SessionsSucceeded"] = metrics.SessionsSucceeded,
["SessionsFailed"] = metrics.SessionsFailed,
["SessionsInProgress"] = metrics.SessionsInProgress,
["SessionsSuccessRate"] = metrics.SessionsSuccessRate,
["AvgDurationMinutes"] = metrics.AvgDurationMinutes,
["MedianDurationMinutes"] = metrics.MedianDurationMinutes,
["P95DurationMinutes"] = metrics.P95DurationMinutes,
["P99DurationMinutes"] = metrics.P99DurationMinutes,
["UniqueTenants"] = metrics.UniqueTenants,
["UniqueUsers"] = metrics.UniqueUsers,
["LoginCount"] = metrics.LoginCount,
["TopManufacturers"] = metrics.TopManufacturers,
["TopModels"] = metrics.TopModels
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogInformation($"Saved historical metrics for {metrics.Date} / {metrics.TenantId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to save historical metrics for {metrics.Date} / {metrics.TenantId}");
return false;
}
}
/// <summary>
/// Gets historical metrics for a date range
/// </summary>
public async Task<List<UsageMetricsSnapshot>> GetUsageMetricsSnapshotAsync(string? tenantId = null, string? startDate = null, string? endDate = null, int maxResults = 100)
{
if (!string.IsNullOrEmpty(tenantId))
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics);
// Build filter
var filters = new List<string>();
if (!string.IsNullOrEmpty(startDate))
filters.Add($"PartitionKey ge '{startDate}'");
if (!string.IsNullOrEmpty(endDate))
filters.Add($"PartitionKey le '{endDate}'");
if (!string.IsNullOrEmpty(tenantId))
filters.Add($"RowKey eq '{tenantId}'");
var filter = filters.Count > 0 ? string.Join(" and ", filters) : null;
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var results = new List<UsageMetricsSnapshot>();
await foreach (var entity in query)
{
results.Add(new UsageMetricsSnapshot
{
Date = entity.PartitionKey,
TenantId = entity.RowKey,
ComputedAt = entity.GetDateTimeOffset("ComputedAt")?.UtcDateTime ?? DateTime.UtcNow,
ComputeDurationMs = entity.GetInt32("ComputeDurationMs") ?? 0,
SessionsTotal = entity.GetInt32("SessionsTotal") ?? 0,
SessionsSucceeded = entity.GetInt32("SessionsSucceeded") ?? 0,
SessionsFailed = entity.GetInt32("SessionsFailed") ?? 0,
SessionsInProgress = entity.GetInt32("SessionsInProgress") ?? 0,
SessionsSuccessRate = entity.GetDouble("SessionsSuccessRate") ?? 0,
AvgDurationMinutes = entity.GetDouble("AvgDurationMinutes") ?? 0,
MedianDurationMinutes = entity.GetDouble("MedianDurationMinutes") ?? 0,
P95DurationMinutes = entity.GetDouble("P95DurationMinutes") ?? 0,
P99DurationMinutes = entity.GetDouble("P99DurationMinutes") ?? 0,
UniqueTenants = entity.GetInt32("UniqueTenants") ?? 0,
UniqueUsers = entity.GetInt32("UniqueUsers") ?? 0,
LoginCount = entity.GetInt32("LoginCount") ?? 0,
TopManufacturers = entity.GetString("TopManufacturers") ?? "[]",
TopModels = entity.GetString("TopModels") ?? "[]"
});
if (results.Count >= maxResults) break;
}
return results.OrderByDescending(m => m.Date).Take(maxResults).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get historical metrics");
return new List<UsageMetricsSnapshot>();
}
}
/// <summary>
/// Checks if a global usage metrics snapshot exists for a given date.
/// Used by maintenance catch-up to determine which dates need aggregation.
/// </summary>
public async Task<bool> HasUsageMetricsSnapshotAsync(string date)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics);
await tableClient.GetEntityAsync<TableEntity>(date, "global");
return true;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to check usage metrics snapshot for {date}");
return false;
}
}
// ===== APP INSTALL SUMMARIES METHODS =====
/// <summary>
/// Stores or updates an app install summary.
/// Merges with any existing record so StartedAt is never overwritten with a later timestamp.
/// PartitionKey: TenantId, RowKey: {SessionId}_{AppName}
/// </summary>
public async Task<bool> StoreAppInstallSummaryAsync(AppInstallSummary summary)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries);
var rowKey = $"{summary.SessionId}_{summary.AppName}";
// Merge with existing record to preserve StartedAt from a prior batch
try
{
var existing = await tableClient.GetEntityAsync<TableEntity>(summary.TenantId, rowKey);
var existingStartedAt = existing.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime;
if (existingStartedAt.HasValue && existingStartedAt.Value != DateTime.MinValue)
{
// Keep the earlier StartedAt; recalculate duration if CompletedAt is now known
if (summary.StartedAt == DateTime.MinValue || existingStartedAt.Value < summary.StartedAt)
{
summary.StartedAt = existingStartedAt.Value;
if (summary.CompletedAt.HasValue && summary.CompletedAt.Value >= summary.StartedAt)
{
summary.DurationSeconds = (int)(summary.CompletedAt.Value - summary.StartedAt).TotalSeconds;
}
}
}
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
// No existing record – nothing to merge
}
var entity = new TableEntity(summary.TenantId, rowKey)
{
["AppName"] = summary.AppName ?? string.Empty,
["SessionId"] = summary.SessionId ?? string.Empty,
["TenantId"] = summary.TenantId ?? string.Empty,
["Status"] = summary.Status ?? "InProgress",
["DurationSeconds"] = summary.DurationSeconds,
["DownloadBytes"] = summary.DownloadBytes,
["DownloadDurationSeconds"] = summary.DownloadDurationSeconds,
["FailureCode"] = summary.FailureCode ?? string.Empty,
["FailureMessage"] = summary.FailureMessage ?? string.Empty,
["StartedAt"] = summary.StartedAt,
["CompletedAt"] = summary.CompletedAt
};
await tableClient.UpsertEntityAsync(entity);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store app install summary for {summary.AppName}");
return false;
}
}
/// <summary>
/// Gets all app install summaries for a tenant (fleet-level metrics)
/// </summary>
public async Task<List<AppInstallSummary>> GetAppInstallSummariesByTenantAsync(string tenantId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(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>
/// Gets all app install summaries across all tenants (for galactic admin mode)
/// </summary>
public async Task<List<AppInstallSummary>> GetAllAppInstallSummariesAsync()
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries);
var query = tableClient.QueryAsync<TableEntity>();
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 all app install summaries");
return new List<AppInstallSummary>();
}
}
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,
TotalSignedUpTenants = entity.GetInt64("TotalSignedUpTenants") ?? 0,
UniqueDeviceModels = entity.GetInt64("UniqueDeviceModels") ?? 0,
TotalEventsProcessed = entity.GetInt64("TotalEventsProcessed") ?? 0,
SuccessfulEnrollments = entity.GetInt64("SuccessfulEnrollments") ?? 0,
IssuesDetected = entity.GetInt64("IssuesDetected") ?? 0,
LastFullCompute = entity.GetDateTimeOffset("LastFullCompute")?.UtcDateTime ?? DateTime.MinValue,
LastUpdated = entity.GetDateTimeOffset("LastUpdated")?.UtcDateTime ?? DateTime.MinValue
};
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get platform stats");
return null;
}
}
/// <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,
["TotalSignedUpTenants"] = stats.TotalSignedUpTenants,
["UniqueDeviceModels"] = stats.UniqueDeviceModels,
["TotalEventsProcessed"] = stats.TotalEventsProcessed,
["SuccessfulEnrollments"] = stats.SuccessfulEnrollments,
["IssuesDetected"] = stats.IssuesDetected,
["LastFullCompute"] = stats.LastFullCompute,
["LastUpdated"] = stats.LastUpdated
};
await tableClient.UpsertEntityAsync(entity);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save platform stats");
return false;
}
}
/// <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,
["TotalSignedUpTenants"] = 0L,
["UniqueDeviceModels"] = 0L,
["TotalEventsProcessed"] = 0L,
["SuccessfulEnrollments"] = 0L,
["IssuesDetected"] = 0L,
["LastFullCompute"] = DateTime.MinValue,
["LastUpdated"] = DateTime.UtcNow
};
}
var current = entity.GetInt64(field) ?? 0;
entity[field] = current + amount;
entity["LastUpdated"] = DateTime.UtcNow;
await tableClient.UpsertEntityAsync(entity);
}
catch (Exception ex)
{
// Non-fatal: don't break the caller if stats update fails
_logger.LogWarning(ex, $"Failed to increment platform stat {field}");
}
}
// ===== USER ACTIVITY METHODS =====
/// <summary>
/// Records a user login activity
/// PartitionKey: TenantId, RowKey: {invertedTicks}_{Guid} for reverse-chronological ordering
/// </summary>
public async Task RecordUserLoginAsync(string tenantId, string upn, string? displayName, string? objectId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
var now = DateTime.UtcNow;
var invertedTicks = (DateTime.MaxValue.Ticks - now.Ticks).ToString("D20");
var entity = new TableEntity(tenantId, $"{invertedTicks}_{Guid.NewGuid():N}")
{
["Upn"] = upn ?? string.Empty,
["DisplayName"] = displayName ?? string.Empty,
["ObjectId"] = objectId ?? string.Empty,
["LoginAt"] = now
};
await tableClient.AddEntityAsync(entity);
_logger.LogDebug($"Recorded login for {upn} in tenant {tenantId}");
}
catch (Exception ex)
{
// Don't fail the login if activity recording fails
_logger.LogWarning(ex, $"Failed to record login activity for {upn}");
}
}
/// <summary>
/// Gets user activity metrics for a specific tenant
/// </summary>
public async Task<UserActivityMetrics> GetUserActivityMetricsAsync(string tenantId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
var now = DateTime.UtcNow;
var today = now.Date;
var last7Days = now.AddDays(-7);
var last30Days = now.AddDays(-30);
var allUpns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var todayUpns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var last7Upns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var last30Upns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
int todayLogins = 0;
await foreach (var entity in query)
{
var upn = entity.GetString("Upn") ?? string.Empty;
var loginAt = entity.GetDateTime("LoginAt") ?? DateTime.MinValue;
if (string.IsNullOrEmpty(upn)) continue;
allUpns.Add(upn);
if (loginAt >= last30Days) last30Upns.Add(upn);
if (loginAt >= last7Days) last7Upns.Add(upn);
if (loginAt >= today)
{
todayUpns.Add(upn);
todayLogins++;
}
}
return new UserActivityMetrics
{
TotalUniqueUsers = allUpns.Count,
DailyLogins = todayLogins,
ActiveUsersLast7Days = last7Upns.Count,
ActiveUsersLast30Days = last30Upns.Count
};
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get user activity metrics for tenant {tenantId}");
return new UserActivityMetrics();
}
}
/// <summary>
/// Gets user activity metrics across all tenants (for galactic admin)
/// </summary>
public async Task<UserActivityMetrics> GetAllUserActivityMetricsAsync()
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
var query = tableClient.QueryAsync<TableEntity>();
var now = DateTime.UtcNow;
var today = now.Date;
var last7Days = now.AddDays(-7);
var last30Days = now.AddDays(-30);
var allUpns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var todayUpns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var last7Upns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var last30Upns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
int todayLogins = 0;
await foreach (var entity in query)
{
var upn = entity.GetString("Upn") ?? string.Empty;
var loginAt = entity.GetDateTime("LoginAt") ?? DateTime.MinValue;
if (string.IsNullOrEmpty(upn)) continue;
allUpns.Add(upn);
if (loginAt >= last30Days) last30Upns.Add(upn);
if (loginAt >= last7Days) last7Upns.Add(upn);
if (loginAt >= today)
{
todayUpns.Add(upn);
todayLogins++;
}
}
return new UserActivityMetrics
{
TotalUniqueUsers = allUpns.Count,
DailyLogins = todayLogins,
ActiveUsersLast7Days = last7Upns.Count,
ActiveUsersLast30Days = last30Upns.Count
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get all user activity metrics");
return new UserActivityMetrics();
}
}
/// <summary>
/// Gets user login count for a specific date range (used by daily maintenance)
/// </summary>
public async Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date)
{
if (!string.IsNullOrEmpty(tenantId) && tenantId != "global")
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
var startOfDay = date.Date;
var endOfDay = startOfDay.AddDays(1);
string filter;
if (!string.IsNullOrEmpty(tenantId) && tenantId != "global")
{
filter = $"PartitionKey eq '{tenantId}' and LoginAt ge datetime'{startOfDay:yyyy-MM-ddTHH:mm:ss}Z' and LoginAt lt datetime'{endOfDay:yyyy-MM-ddTHH:mm:ss}Z'";
}
else
{
filter = $"LoginAt ge datetime'{startOfDay:yyyy-MM-ddTHH:mm:ss}Z' and LoginAt lt datetime'{endOfDay:yyyy-MM-ddTHH:mm:ss}Z'";
}
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var upns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
int loginCount = 0;
await foreach (var entity in query)
{
var upn = entity.GetString("Upn") ?? string.Empty;
if (!string.IsNullOrEmpty(upn))
{
upns.Add(upn);
loginCount++;
}
}
return (upns.Count, loginCount);
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get user activity for date {date:yyyy-MM-dd}");
return (0, 0);
}
}
}
}
@@ -0,0 +1,512 @@
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services
{
public partial class TableStorageService
{
// ===== RULE RESULTS METHODS =====
/// <summary>
/// Stores a rule evaluation result
/// PartitionKey: {TenantId}_{SessionId}, RowKey: RuleId
/// </summary>
public async Task<bool> StoreRuleResultAsync(RuleResult result)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults);
var partitionKey = $"{result.TenantId}_{result.SessionId}";
var entity = new TableEntity(partitionKey, result.RuleId)
{
["ResultId"] = result.ResultId,
["SessionId"] = result.SessionId,
["TenantId"] = result.TenantId,
["RuleId"] = result.RuleId,
["RuleTitle"] = result.RuleTitle ?? string.Empty,
["Severity"] = result.Severity ?? string.Empty,
["Category"] = result.Category ?? string.Empty,
["ConfidenceScore"] = result.ConfidenceScore,
["Explanation"] = result.Explanation ?? string.Empty,
["RemediationJson"] = JsonConvert.SerializeObject(result.Remediation ?? new List<RemediationStep>()),
["RelatedDocsJson"] = JsonConvert.SerializeObject(result.RelatedDocs ?? new List<RelatedDoc>()),
["MatchedConditionsJson"] = JsonConvert.SerializeObject(result.MatchedConditions ?? new Dictionary<string, object>()),
["DetectedAt"] = result.DetectedAt
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogInformation($"Stored rule result {result.RuleId} for session {result.SessionId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store rule result {result.RuleId}");
return false;
}
}
/// <summary>
/// Gets all rule results for a session
/// </summary>
public async Task<List<RuleResult>> GetRuleResultsAsync(string tenantId, string sessionId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleResults);
var partitionKey = $"{tenantId}_{sessionId}";
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
var results = new List<RuleResult>();
await foreach (var entity in query)
{
results.Add(new RuleResult
{
ResultId = entity.GetString("ResultId") ?? string.Empty,
SessionId = entity.GetString("SessionId") ?? string.Empty,
TenantId = entity.GetString("TenantId") ?? string.Empty,
RuleId = entity.GetString("RuleId") ?? entity.RowKey,
RuleTitle = entity.GetString("RuleTitle") ?? string.Empty,
Severity = entity.GetString("Severity") ?? string.Empty,
Category = entity.GetString("Category") ?? string.Empty,
ConfidenceScore = entity.GetInt32("ConfidenceScore") ?? 0,
Explanation = entity.GetString("Explanation") ?? string.Empty,
Remediation = DeserializeJson<List<RemediationStep>>(entity.GetString("RemediationJson")),
RelatedDocs = DeserializeJson<List<RelatedDoc>>(entity.GetString("RelatedDocsJson")),
MatchedConditions = DeserializeMatchedConditions(entity.GetString("MatchedConditionsJson")),
DetectedAt = entity.GetDateTimeOffset("DetectedAt")?.UtcDateTime ?? DateTime.UtcNow
});
}
return results.OrderByDescending(r => r.ConfidenceScore).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get rule results for session {sessionId}");
return new List<RuleResult>();
}
}
// ===== GATHER RULES METHODS =====
/// <summary>
/// Stores or updates a gather rule
/// PartitionKey: TenantId (or "global" for built-in), RowKey: RuleId
/// </summary>
public async Task<bool> StoreGatherRuleAsync(GatherRule rule, string tenantId = "global")
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules);
var entity = new TableEntity(tenantId, rule.RuleId)
{
["Title"] = rule.Title ?? string.Empty,
["Description"] = rule.Description ?? string.Empty,
["Category"] = rule.Category ?? string.Empty,
["Version"] = rule.Version ?? "1.0.0",
["Author"] = rule.Author ?? "Autopilot Monitor",
["Enabled"] = rule.Enabled,
["IsBuiltIn"] = rule.IsBuiltIn,
["IsCommunity"] = rule.IsCommunity,
["CollectorType"] = rule.CollectorType ?? string.Empty,
["Target"] = rule.Target ?? string.Empty,
["ParametersJson"] = JsonConvert.SerializeObject(rule.Parameters ?? new Dictionary<string, string>()),
["Trigger"] = rule.Trigger ?? string.Empty,
["IntervalSeconds"] = rule.IntervalSeconds,
["TriggerPhase"] = rule.TriggerPhase ?? string.Empty,
["TriggerEventType"] = rule.TriggerEventType ?? string.Empty,
["OutputEventType"] = rule.OutputEventType ?? string.Empty,
["OutputSeverity"] = rule.OutputSeverity ?? "Info",
["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]),
["CreatedAt"] = rule.CreatedAt,
["UpdatedAt"] = rule.UpdatedAt
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogDebug($"Stored gather rule {rule.RuleId} for {tenantId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store gather rule {rule.RuleId}");
return false;
}
}
/// <summary>
/// Gets gather rules for a partition (tenant or "global")
/// </summary>
public async Task<List<GatherRule>> GetGatherRulesAsync(string partitionKey)
{
if (partitionKey != "global")
SecurityValidator.EnsureValidGuid(partitionKey, nameof(partitionKey));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
var rules = new List<GatherRule>();
await foreach (var entity in query)
{
rules.Add(MapToGatherRule(entity));
}
return rules;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get gather rules for {partitionKey}");
return new List<GatherRule>();
}
}
/// <summary>
/// Deletes a gather rule
/// </summary>
public async Task<bool> DeleteGatherRuleAsync(string tenantId, string ruleId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules);
await tableClient.DeleteEntityAsync(tenantId, ruleId);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete gather rule {ruleId}");
return false;
}
}
private GatherRule MapToGatherRule(TableEntity entity)
{
return new GatherRule
{
RuleId = entity.RowKey,
Title = entity.GetString("Title") ?? string.Empty,
Description = entity.GetString("Description") ?? string.Empty,
Category = entity.GetString("Category") ?? string.Empty,
Version = entity.GetString("Version") ?? "1.0.0",
Author = entity.GetString("Author") ?? "Autopilot Monitor",
Enabled = entity.GetBoolean("Enabled") ?? true,
IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false,
IsCommunity = entity.GetBoolean("IsCommunity") ?? false,
CollectorType = entity.GetString("CollectorType") ?? string.Empty,
Target = entity.GetString("Target") ?? string.Empty,
Parameters = DeserializeJson<Dictionary<string, string>>(entity.GetString("ParametersJson")),
Trigger = entity.GetString("Trigger") ?? string.Empty,
IntervalSeconds = entity.GetInt32("IntervalSeconds"),
TriggerPhase = entity.GetString("TriggerPhase") ?? string.Empty,
TriggerEventType = entity.GetString("TriggerEventType") ?? string.Empty,
OutputEventType = entity.GetString("OutputEventType") ?? string.Empty,
OutputSeverity = entity.GetString("OutputSeverity") ?? "Info",
Tags = DeserializeJsonArray(entity.GetString("TagsJson")),
CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.UtcNow,
UpdatedAt = entity.GetDateTimeOffset("UpdatedAt")?.UtcDateTime ?? DateTime.UtcNow
};
}
// ===== RULE STATES METHODS =====
/// <summary>
/// Stores or updates the enabled/disabled state for a built-in or community rule per tenant
/// PartitionKey: TenantId, RowKey: RuleId
/// </summary>
public async Task<bool> StoreRuleStateAsync(string tenantId, string ruleId, bool enabled)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates);
var entity = new TableEntity(tenantId, ruleId)
{
["Enabled"] = enabled,
["UpdatedAt"] = DateTime.UtcNow
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogDebug($"Stored rule state {ruleId} for {tenantId}: enabled={enabled}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store rule state {ruleId} for {tenantId}");
return false;
}
}
/// <summary>
/// Gets all rule states for a tenant as a dictionary of ruleId → enabled
/// </summary>
public async Task<Dictionary<string, bool>> GetRuleStatesAsync(string tenantId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
var states = new Dictionary<string, bool>();
await foreach (var entity in query)
{
states[entity.RowKey] = entity.GetBoolean("Enabled") ?? true;
}
return states;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get rule states for {tenantId}");
return new Dictionary<string, bool>();
}
}
/// <summary>
/// Deletes the rule state for a tenant (resets to rule's default enabled state)
/// </summary>
public async Task<bool> DeleteRuleStateAsync(string tenantId, string ruleId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates);
await tableClient.DeleteEntityAsync(tenantId, ruleId);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete rule state {ruleId} for {tenantId}");
return false;
}
}
// ===== ANALYZE RULES METHODS =====
/// <summary>
/// Stores or updates an analyze rule
/// PartitionKey: TenantId (or "global" for built-in), RowKey: RuleId
/// </summary>
public async Task<bool> StoreAnalyzeRuleAsync(AnalyzeRule rule, string tenantId = "global")
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules);
var entity = new TableEntity(tenantId, rule.RuleId)
{
["Title"] = rule.Title ?? string.Empty,
["Description"] = rule.Description ?? string.Empty,
["Severity"] = rule.Severity ?? string.Empty,
["Category"] = rule.Category ?? string.Empty,
["Version"] = rule.Version ?? "1.0.0",
["Author"] = rule.Author ?? "Autopilot Monitor",
["Enabled"] = rule.Enabled,
["IsBuiltIn"] = rule.IsBuiltIn,
["IsCommunity"] = rule.IsCommunity,
["Trigger"] = rule.Trigger ?? "single",
["ConditionsJson"] = JsonConvert.SerializeObject(rule.Conditions ?? new List<RuleCondition>()),
["BaseConfidence"] = rule.BaseConfidence,
["ConfidenceFactorsJson"] = JsonConvert.SerializeObject(rule.ConfidenceFactors ?? new List<ConfidenceFactor>()),
["ConfidenceThreshold"] = rule.ConfidenceThreshold,
["Explanation"] = rule.Explanation ?? string.Empty,
["RemediationJson"] = JsonConvert.SerializeObject(rule.Remediation ?? new List<RemediationStep>()),
["RelatedDocsJson"] = JsonConvert.SerializeObject(rule.RelatedDocs ?? new List<RelatedDoc>()),
["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]),
["CreatedAt"] = rule.CreatedAt,
["UpdatedAt"] = rule.UpdatedAt
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogDebug($"Stored analyze rule {rule.RuleId} for {tenantId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store analyze rule {rule.RuleId}");
return false;
}
}
/// <summary>
/// Gets analyze rules for a partition (tenant or "global")
/// </summary>
public async Task<List<AnalyzeRule>> GetAnalyzeRulesAsync(string partitionKey)
{
if (partitionKey != "global")
SecurityValidator.EnsureValidGuid(partitionKey, nameof(partitionKey));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
var rules = new List<AnalyzeRule>();
await foreach (var entity in query)
{
rules.Add(MapToAnalyzeRule(entity));
}
return rules;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get analyze rules for {partitionKey}");
return new List<AnalyzeRule>();
}
}
/// <summary>
/// Deletes an analyze rule
/// </summary>
public async Task<bool> DeleteAnalyzeRuleAsync(string tenantId, string ruleId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules);
await tableClient.DeleteEntityAsync(tenantId, ruleId);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete analyze rule {ruleId}");
return false;
}
}
private AnalyzeRule MapToAnalyzeRule(TableEntity entity)
{
return new AnalyzeRule
{
RuleId = entity.RowKey,
Title = entity.GetString("Title") ?? string.Empty,
Description = entity.GetString("Description") ?? string.Empty,
Severity = entity.GetString("Severity") ?? string.Empty,
Category = entity.GetString("Category") ?? string.Empty,
Version = entity.GetString("Version") ?? "1.0.0",
Author = entity.GetString("Author") ?? "Autopilot Monitor",
Enabled = entity.GetBoolean("Enabled") ?? true,
IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false,
IsCommunity = entity.GetBoolean("IsCommunity") ?? false,
Trigger = entity.GetString("Trigger") ?? "single",
Conditions = DeserializeJson<List<RuleCondition>>(entity.GetString("ConditionsJson")),
BaseConfidence = entity.GetInt32("BaseConfidence") ?? 50,
ConfidenceFactors = DeserializeJson<List<ConfidenceFactor>>(entity.GetString("ConfidenceFactorsJson")),
ConfidenceThreshold = entity.GetInt32("ConfidenceThreshold") ?? 40,
Explanation = entity.GetString("Explanation") ?? string.Empty,
Remediation = DeserializeJson<List<RemediationStep>>(entity.GetString("RemediationJson")),
RelatedDocs = DeserializeJson<List<RelatedDoc>>(entity.GetString("RelatedDocsJson")),
Tags = DeserializeJsonArray(entity.GetString("TagsJson")),
CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.UtcNow,
UpdatedAt = entity.GetDateTimeOffset("UpdatedAt")?.UtcDateTime ?? DateTime.UtcNow
};
}
// ===== IME LOG PATTERNS METHODS =====
/// <summary>
/// Stores or updates an IME log pattern
/// PartitionKey: TenantId (or "global" for built-in), RowKey: PatternId
/// </summary>
public async Task<bool> StoreImeLogPatternAsync(ImeLogPattern pattern, string tenantId = "global")
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeLogPatterns);
var entity = new TableEntity(tenantId, pattern.PatternId)
{
["Category"] = pattern.Category ?? string.Empty,
["Pattern"] = pattern.Pattern ?? string.Empty,
["Action"] = pattern.Action ?? string.Empty,
["ParametersJson"] = JsonConvert.SerializeObject(pattern.Parameters ?? new Dictionary<string, string>()),
["Enabled"] = pattern.Enabled,
["Description"] = pattern.Description ?? string.Empty,
["IsBuiltIn"] = pattern.IsBuiltIn
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogDebug($"Stored IME log pattern {pattern.PatternId} for {tenantId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store IME log pattern {pattern.PatternId}");
return false;
}
}
/// <summary>
/// Gets IME log patterns for a partition (tenant or "global")
/// </summary>
public async Task<List<ImeLogPattern>> GetImeLogPatternsAsync(string partitionKey)
{
if (partitionKey != "global")
SecurityValidator.EnsureValidGuid(partitionKey, nameof(partitionKey));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeLogPatterns);
var query = tableClient.QueryAsync<TableEntity>(filter: $"PartitionKey eq '{partitionKey}'");
var patterns = new List<ImeLogPattern>();
await foreach (var entity in query)
{
patterns.Add(MapToImeLogPattern(entity));
}
return patterns;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get IME log patterns for {partitionKey}");
return new List<ImeLogPattern>();
}
}
/// <summary>
/// Deletes an IME log pattern
/// </summary>
public async Task<bool> DeleteImeLogPatternAsync(string tenantId, string patternId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeLogPatterns);
await tableClient.DeleteEntityAsync(tenantId, patternId);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete IME log pattern {patternId}");
return false;
}
}
private ImeLogPattern MapToImeLogPattern(TableEntity entity)
{
return new ImeLogPattern
{
PatternId = entity.RowKey,
Category = entity.GetString("Category") ?? string.Empty,
Pattern = entity.GetString("Pattern") ?? string.Empty,
Action = entity.GetString("Action") ?? string.Empty,
Parameters = DeserializeJson<Dictionary<string, string>>(entity.GetString("ParametersJson")),
Enabled = entity.GetBoolean("Enabled") ?? true,
Description = entity.GetString("Description") ?? string.Empty,
IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false
};
}
}
}
@@ -0,0 +1,753 @@
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services
{
public partial class TableStorageService
{
// ===== SESSION MANAGEMENT METHODS =====
/// <summary>
/// Stores a session registration
/// </summary>
public async Task<bool> StoreSessionAsync(SessionRegistration registration)
{
SecurityValidator.EnsureValidGuid(registration.TenantId, "TenantId");
SecurityValidator.EnsureValidGuid(registration.SessionId, "SessionId");
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
// If the agent restarts with the same session ID, preserve timeline/progress fields
// from the existing session row instead of resetting them to "fresh start".
DateTime startedAt = registration.StartedAt;
int currentPhase = (int)EnrollmentPhase.Start;
string status = SessionStatus.InProgress.ToString();
int eventCount = 0;
DateTime? completedAt = null;
string failureReason = string.Empty;
try
{
var existing = await tableClient.GetEntityAsync<TableEntity>(registration.TenantId, registration.SessionId);
var existingEntity = existing.Value;
var existingStartedAt = existingEntity.GetDateTimeOffset("StartedAt")?.UtcDateTime;
if (existingStartedAt.HasValue && existingStartedAt.Value < startedAt)
startedAt = existingStartedAt.Value;
currentPhase = existingEntity.GetInt32("CurrentPhase") ?? currentPhase;
status = existingEntity.GetString("Status") ?? status;
eventCount = existingEntity.GetInt32("EventCount") ?? eventCount;
completedAt = existingEntity.GetDateTimeOffset("CompletedAt")?.UtcDateTime;
failureReason = existingEntity.GetString("FailureReason") ?? string.Empty;
// WhiteGlove resumption: if the existing session was in Pending state,
// this re-registration means the user has received the device and booted it.
// Transition back to InProgress for Part 2 of enrollment.
if (status == SessionStatus.Pending.ToString())
{
_logger.LogInformation($"Session {registration.SessionId} resuming from Pending (WhiteGlove Part 2)");
status = SessionStatus.InProgress.ToString();
}
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// New session row - use defaults above.
}
// If events were ingested before session registration succeeded, align StartedAt
// with the earliest event we already have for this session.
var earliestEventTimestamp = await GetEarliestSessionEventTimestampAsync(registration.TenantId, registration.SessionId);
if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < startedAt)
{
startedAt = earliestEventTimestamp.Value;
}
var entity = new TableEntity(registration.TenantId, registration.SessionId)
{
["SerialNumber"] = registration.SerialNumber ?? string.Empty,
["Manufacturer"] = registration.Manufacturer ?? string.Empty,
["Model"] = registration.Model ?? string.Empty,
["DeviceName"] = registration.DeviceName ?? string.Empty,
["OsBuild"] = registration.OsBuild ?? string.Empty,
["OsEdition"] = registration.OsEdition ?? string.Empty,
["OsLanguage"] = registration.OsLanguage ?? string.Empty,
["AutopilotProfileName"] = registration.AutopilotProfileName ?? string.Empty,
["AutopilotProfileId"] = registration.AutopilotProfileId ?? string.Empty,
["IsUserDriven"] = registration.IsUserDriven,
["IsPreProvisioned"] = registration.IsPreProvisioned,
["StartedAt"] = startedAt,
["AgentVersion"] = registration.AgentVersion ?? string.Empty,
["EnrollmentType"] = registration.EnrollmentType ?? "v1",
["CurrentPhase"] = currentPhase,
["Status"] = status,
["EventCount"] = eventCount
};
if (completedAt.HasValue)
entity["CompletedAt"] = completedAt.Value;
if (!string.IsNullOrWhiteSpace(failureReason))
entity["FailureReason"] = failureReason;
await tableClient.UpsertEntityAsync(entity);
_logger.LogInformation($"Stored session {registration.SessionId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store session {registration.SessionId}");
return false;
}
}
// ===== EVENT MANAGEMENT METHODS =====
/// <summary>
/// Stores an event
/// </summary>
public async Task<bool> StoreEventAsync(EnrollmentEvent evt)
{
SecurityValidator.EnsureValidGuid(evt.TenantId, "TenantId");
SecurityValidator.EnsureValidGuid(evt.SessionId, "SessionId");
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events);
// PartitionKey: TenantId_SessionId for efficient querying
// RowKey: Timestamp_Sequence for ordering
var partitionKey = $"{evt.TenantId}_{evt.SessionId}";
var rowKey = $"{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence:D10}";
var entity = new TableEntity(partitionKey, rowKey)
{
["EventId"] = evt.EventId,
["SessionId"] = evt.SessionId,
["TenantId"] = evt.TenantId,
["Timestamp"] = evt.Timestamp,
["EventType"] = evt.EventType ?? string.Empty,
["Severity"] = (int)evt.Severity,
["Source"] = evt.Source ?? string.Empty,
["Phase"] = (int)evt.Phase,
["Message"] = evt.Message ?? string.Empty,
["Sequence"] = evt.Sequence,
["DataJson"] = evt.Data != null && evt.Data.Count > 0
? JsonConvert.SerializeObject(evt.Data)
: string.Empty
};
await tableClient.UpsertEntityAsync(entity);
_logger.LogDebug($"Stored event {evt.EventId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to store event {evt.EventId}");
return false;
}
}
/// <summary>
/// Stores multiple events as batch transactions (Entity Group Transactions).
/// All events must share the same PartitionKey (TenantId_SessionId).
/// Azure Table Storage allows max 100 entities per transaction.
/// Falls back to individual writes if a batch fails.
/// </summary>
/// <returns>List of successfully stored events</returns>
public async Task<List<EnrollmentEvent>> StoreEventsBatchAsync(List<EnrollmentEvent> events)
{
if (events == null || events.Count == 0)
return new List<EnrollmentEvent>();
// Validate all events upfront
foreach (var evt in events)
{
SecurityValidator.EnsureValidGuid(evt.TenantId, "TenantId");
SecurityValidator.EnsureValidGuid(evt.SessionId, "SessionId");
}
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events);
var storedEvents = new List<EnrollmentEvent>();
// Group by PartitionKey (should be the same for all events in a request, but be safe)
var groups = events.GroupBy(e => $"{e.TenantId}_{e.SessionId}");
foreach (var group in groups)
{
// Chunk into batches of 100 (Azure Table Storage limit)
var chunks = group.Select((evt, index) => new { evt, index })
.GroupBy(x => x.index / 100)
.Select(g => g.Select(x => x.evt).ToList());
foreach (var chunk in chunks)
{
try
{
var actions = chunk.Select(evt =>
{
var partitionKey = $"{evt.TenantId}_{evt.SessionId}";
var rowKey = $"{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence:D10}";
var entity = new TableEntity(partitionKey, rowKey)
{
["EventId"] = evt.EventId,
["SessionId"] = evt.SessionId,
["TenantId"] = evt.TenantId,
["Timestamp"] = evt.Timestamp,
["EventType"] = evt.EventType ?? string.Empty,
["Severity"] = (int)evt.Severity,
["Source"] = evt.Source ?? string.Empty,
["Phase"] = (int)evt.Phase,
["Message"] = evt.Message ?? string.Empty,
["Sequence"] = evt.Sequence,
["DataJson"] = evt.Data != null && evt.Data.Count > 0
? JsonConvert.SerializeObject(evt.Data)
: string.Empty
};
return new TableTransactionAction(TableTransactionActionType.UpsertReplace, entity);
}).ToList();
await tableClient.SubmitTransactionAsync(actions);
storedEvents.AddRange(chunk);
_logger.LogDebug($"Batch stored {chunk.Count} events for partition {group.Key}");
}
catch (Exception ex)
{
// Batch failed - fall back to individual writes for this chunk
_logger.LogWarning(ex, $"Batch write failed for {chunk.Count} events, falling back to individual writes");
foreach (var evt in chunk)
{
if (await StoreEventAsync(evt))
{
storedEvents.Add(evt);
}
}
}
}
}
return storedEvents;
}
/// <summary>
/// Gets all sessions for a tenant
/// </summary>
public async Task<List<SessionSummary>> GetSessionsAsync(string tenantId, int maxResults = 100)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var sessions = new List<SessionSummary>();
// Query sessions by tenant (PartitionKey)
// maxPerPage capped at 1000 (Azure Table Storage limit), pagination is automatic
var query = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{tenantId}'",
maxPerPage: Math.Min(maxResults, 1000)
);
await foreach (var entity in query)
{
sessions.Add(MapToSessionSummary(entity));
if (sessions.Count >= maxResults) break;
}
// Sort by StartedAt descending (most recent first)
return sessions.OrderByDescending(s => s.StartedAt).ToList();
}
catch (Exception ex)
{
_logger.LogError("Failed to get sessions for tenant {TenantId}: {ExType}: {ExMessage}\n{StackTrace}",
tenantId, ex.GetType().Name, ex.Message, ex.StackTrace);
return new List<SessionSummary>();
}
}
/// <summary>
/// Gets all sessions across all tenants (for galactic admin mode)
/// </summary>
public async Task<List<SessionSummary>> GetAllSessionsAsync(int maxResults = 100)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var sessions = new List<SessionSummary>();
// Query all sessions without tenant filter
// maxPerPage is capped at 1000 (Azure Table Storage limit); pagination is automatic
var query = tableClient.QueryAsync<TableEntity>(maxPerPage: Math.Min(maxResults, 1000));
await foreach (var entity in query)
{
sessions.Add(MapToSessionSummary(entity));
if (sessions.Count >= maxResults) break;
}
// Sort by StartedAt descending (most recent first)
return sessions.OrderByDescending(s => s.StartedAt).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get all sessions");
return new List<SessionSummary>();
}
}
/// <summary>
/// Gets a specific session
/// </summary>
public async Task<SessionSummary?> GetSessionAsync(string tenantId, string sessionId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
return MapToSessionSummary(entity.Value);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get session {sessionId}");
return null;
}
}
/// <summary>
/// Updates the session status and current phase
/// </summary>
public async Task<bool> UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
const int maxRetries = 3;
int retryCount = 0;
while (retryCount < maxRetries)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
// Get the existing entity
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
var session = entity.Value;
// Idempotency: if the session is already in a terminal state (Succeeded/Failed),
// do not overwrite it with another terminal state to prevent duplicate notifications.
var existingStatusStr = session.GetString("Status");
if (status == SessionStatus.Succeeded || status == SessionStatus.Failed)
{
if (existingStatusStr == SessionStatus.Succeeded.ToString() || existingStatusStr == SessionStatus.Failed.ToString())
{
_logger.LogInformation($"Session {sessionId} already in terminal state '{existingStatusStr}', skipping status update to '{status}'");
return false;
}
}
// Status promotion rule: a Pending session (WhiteGlove pre-provisioning complete)
// must not regress to InProgress via phase-change events from the resumed boot.
// The Pending → InProgress transition happens exclusively in StoreSessionAsync
// (agent re-registration at Boot 2).
if (status == SessionStatus.InProgress && existingStatusStr == SessionStatus.Pending.ToString())
{
_logger.LogInformation($"Session {sessionId} is Pending (WhiteGlove), preserving status (InProgress blocked by promotion rule)");
// Phase, timestamps, and event count are still updated below.
}
else
{
// Update status
session["Status"] = status.ToString();
}
// Update current phase if provided
if (currentPhase.HasValue)
{
session["CurrentPhase"] = (int)currentPhase.Value;
}
var currentStartedAt = session.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue;
var effectiveEarliest = earliestEventTimestamp;
// Self-heal StartedAt by also looking at persisted events in case an older event
// arrived in a previous request/batch.
var earliestStoredEventTimestamp = await GetEarliestSessionEventTimestampAsync(tenantId, sessionId);
if (earliestStoredEventTimestamp.HasValue &&
(!effectiveEarliest.HasValue || earliestStoredEventTimestamp.Value < effectiveEarliest.Value))
{
effectiveEarliest = earliestStoredEventTimestamp.Value;
}
if (effectiveEarliest.HasValue && effectiveEarliest.Value < currentStartedAt)
{
session["StartedAt"] = effectiveEarliest.Value;
}
// Set completion time if succeeded or failed
if (status == SessionStatus.Succeeded || status == SessionStatus.Failed)
{
// Use the provided completedAt timestamp (from event) if available, otherwise use current time
var effectiveCompletedAt = completedAt ?? DateTime.UtcNow;
session["CompletedAt"] = effectiveCompletedAt;
// Store accurate DurationSeconds based on first event timestamp (not registration StartedAt)
// The agent registers before events arrive, so registration StartedAt is always earlier
// than the actual enrollment start — using the first event gives the true enrollment duration.
var durationStart = effectiveEarliest ?? currentStartedAt;
if (durationStart < effectiveCompletedAt)
session["DurationSeconds"] = (int)(effectiveCompletedAt - durationStart).TotalSeconds;
}
// Track the most recent event timestamp for excessive data sender detection
if (latestEventTimestamp.HasValue)
{
var currentLastEventAt = session.GetDateTimeOffset("LastEventAt")?.UtcDateTime;
if (!currentLastEventAt.HasValue || latestEventTimestamp.Value > currentLastEventAt.Value)
session["LastEventAt"] = latestEventTimestamp.Value;
}
// Set failure reason if failed
if (status == SessionStatus.Failed && !string.IsNullOrEmpty(failureReason))
{
session["FailureReason"] = failureReason;
}
// Update event count (full recount for accuracy on status changes)
var eventsTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events);
var partitionKey = $"{tenantId}_{sessionId}";
var eventCountFilter = $"PartitionKey eq '{partitionKey}'";
var eventCount = 0;
await foreach (var _ in eventsTableClient.QueryAsync<TableEntity>(eventCountFilter, select: new[] { "RowKey" }))
{
eventCount++;
}
session["EventCount"] = eventCount;
// Update entity with optimistic concurrency
await tableClient.UpdateEntityAsync(session, session.ETag, TableUpdateMode.Replace);
_logger.LogInformation($"Updated session {sessionId} status to {status}");
return true;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 412) // Precondition Failed (ETag conflict)
{
retryCount++;
if (retryCount >= maxRetries)
{
_logger.LogWarning($"Failed to update session {sessionId} status after {maxRetries} retries due to ETag conflicts");
return false;
}
// Exponential backoff: 50ms, 100ms, 200ms
await Task.Delay(50 * (int)Math.Pow(2, retryCount - 1));
_logger.LogDebug($"Retrying session {sessionId} update (attempt {retryCount}/{maxRetries})");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to update session {sessionId} status");
return false;
}
}
return false;
}
/// <summary>
/// Increments the session event count without touching status or phase fields.
/// Uses Merge mode to safely handle concurrent updates.
/// </summary>
public async Task IncrementSessionEventCountAsync(string tenantId, string sessionId, int increment, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
const int maxRetries = 3;
int retryCount = 0;
while (retryCount < maxRetries)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
var currentCount = entity.Value.GetInt32("EventCount") ?? 0;
var update = new TableEntity(tenantId, sessionId)
{
["EventCount"] = currentCount + increment
};
var currentStartedAt = entity.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue;
var effectiveEarliest = earliestEventTimestamp;
// Self-heal StartedAt by checking already persisted events for this session.
var earliestStoredEventTimestamp = await GetEarliestSessionEventTimestampAsync(tenantId, sessionId);
if (earliestStoredEventTimestamp.HasValue &&
(!effectiveEarliest.HasValue || earliestStoredEventTimestamp.Value < effectiveEarliest.Value))
{
effectiveEarliest = earliestStoredEventTimestamp.Value;
}
if (effectiveEarliest.HasValue && effectiveEarliest.Value < currentStartedAt)
{
update["StartedAt"] = effectiveEarliest.Value;
}
// Track the most recent event timestamp for excessive data sender detection
if (latestEventTimestamp.HasValue)
{
var currentLastEventAt = entity.Value.GetDateTimeOffset("LastEventAt")?.UtcDateTime;
if (!currentLastEventAt.HasValue || latestEventTimestamp.Value > currentLastEventAt.Value)
update["LastEventAt"] = latestEventTimestamp.Value;
}
await tableClient.UpdateEntityAsync(update, entity.Value.ETag, TableUpdateMode.Merge);
return;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 412)
{
retryCount++;
if (retryCount >= maxRetries)
{
_logger.LogWarning($"Failed to increment event count for session {sessionId} after {maxRetries} retries due to ETag conflicts");
return;
}
await Task.Delay(50 * (int)Math.Pow(2, retryCount - 1));
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to increment event count for session {sessionId}");
return;
}
}
}
/// <summary>
/// Gets all events for a specific session
/// </summary>
public async Task<List<EnrollmentEvent>> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events);
var events = new List<EnrollmentEvent>();
// Events are stored with PartitionKey = "{TenantId}_{SessionId}"
var partitionKey = $"{tenantId}_{sessionId}";
var query = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{partitionKey}'",
maxPerPage: maxResults
);
await foreach (var entity in query)
{
events.Add(MapToEnrollmentEvent(entity));
}
// Sort by Timestamp ascending (chronological order)
return events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get events for session {sessionId}");
return new List<EnrollmentEvent>();
}
}
/// <summary>
/// Stores the diagnostics blob name on an existing session (Merge-mode, single field update).
/// </summary>
public async Task UpdateSessionDiagnosticsBlobAsync(string tenantId, string sessionId, string blobName)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
var update = new TableEntity(tenantId, sessionId)
{
["DiagnosticsBlobName"] = blobName
};
await tableClient.UpdateEntityAsync(update, entity.Value.ETag, Azure.Data.Tables.TableUpdateMode.Merge);
_logger.LogInformation($"Stored diagnostics blob name for session {sessionId}: {blobName}");
}
catch (Exception ex)
{
_logger.LogWarning(ex, $"Failed to store diagnostics blob name for session {sessionId}");
}
}
/// <summary>
/// Sets the IsPreProvisioned flag on an existing session (Merge-mode, single field update).
/// </summary>
public async Task SetSessionPreProvisionedAsync(string tenantId, string sessionId, bool isPreProvisioned)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
var update = new TableEntity(tenantId, sessionId)
{
["IsPreProvisioned"] = isPreProvisioned
};
await tableClient.UpdateEntityAsync(update, entity.Value.ETag, Azure.Data.Tables.TableUpdateMode.Merge);
_logger.LogInformation($"Set IsPreProvisioned={isPreProvisioned} for session {sessionId}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to set IsPreProvisioned for session {sessionId}");
}
}
/// <summary>
/// Deletes a session from storage
/// </summary>
public async Task<bool> DeleteSessionAsync(string tenantId, string sessionId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var response = await tableClient.DeleteEntityAsync(tenantId, sessionId);
_logger.LogInformation($"Deleted session {sessionId} for tenant {tenantId}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to delete session {sessionId}");
return false;
}
}
// ===== SESSION/EVENT MAPPING HELPERS =====
/// <summary>
/// Maps a TableEntity to EnrollmentEvent
/// </summary>
private EnrollmentEvent MapToEnrollmentEvent(TableEntity entity)
{
return new EnrollmentEvent
{
EventId = entity.GetString("EventId") ?? string.Empty,
SessionId = entity.GetString("SessionId") ?? string.Empty,
TenantId = entity.PartitionKey,
Timestamp = DateTime.SpecifyKind(
entity.GetDateTimeOffset("Timestamp")?.UtcDateTime
?? entity.GetDateTime("Timestamp")
?? DateTime.UtcNow, DateTimeKind.Utc),
EventType = entity.GetString("EventType") ?? string.Empty,
Severity = (EventSeverity)(entity.GetInt32("Severity") ?? 0),
Source = entity.GetString("Source") ?? string.Empty,
Phase = (EnrollmentPhase)(entity.GetInt32("Phase") ?? 0),
Message = entity.GetString("Message") ?? string.Empty,
Sequence = entity.GetInt64("Sequence") ?? 0,
Data = DeserializeEventData(entity.GetString("DataJson")),
RowKey = entity.RowKey
};
}
/// <summary>
/// Maps a TableEntity to SessionSummary
/// </summary>
private SessionSummary MapToSessionSummary(TableEntity entity)
{
// All typed getters (GetInt32, GetDateTime, etc.) throw InvalidOperationException
// when a property exists but has a different type (e.g. legacy data stored as string
// instead of int). Use safe helpers to handle type mismatches gracefully.
var startedAt = SafeGetDateTime(entity, "StartedAt") ?? DateTime.UtcNow;
var completedAt = SafeGetDateTime(entity, "CompletedAt");
// Parse status with error handling and case-insensitivity
var statusString = entity.GetString("Status") ?? "InProgress";
if (!Enum.TryParse<SessionStatus>(statusString, ignoreCase: true, out var status))
{
_logger.LogWarning($"Failed to parse status '{statusString}' for session {entity.RowKey}, defaulting to Unknown");
status = SessionStatus.Unknown;
}
return new SessionSummary
{
SessionId = entity.RowKey,
TenantId = entity.PartitionKey,
SerialNumber = entity.GetString("SerialNumber") ?? string.Empty,
DeviceName = entity.GetString("DeviceName") ?? string.Empty,
Manufacturer = entity.GetString("Manufacturer") ?? string.Empty,
Model = entity.GetString("Model") ?? string.Empty,
StartedAt = startedAt,
CompletedAt = completedAt,
CurrentPhase = SafeGetInt32(entity, "CurrentPhase") ?? 0,
CurrentPhaseDetail = entity.GetString("CurrentPhaseDetail") ?? string.Empty,
Status = status,
FailureReason = entity.GetString("FailureReason") ?? string.Empty,
EventCount = SafeGetInt32(entity, "EventCount") ?? 0,
DurationSeconds = SafeGetInt32(entity, "DurationSeconds")
?? (completedAt.HasValue
? (int)(completedAt.Value - startedAt).TotalSeconds
: (int)(DateTime.UtcNow - startedAt).TotalSeconds),
EnrollmentType = entity.GetString("EnrollmentType") ?? "v1",
DiagnosticsBlobName = entity.GetString("DiagnosticsBlobName"),
LastEventAt = SafeGetDateTime(entity, "LastEventAt"),
IsPreProvisioned = entity.GetBoolean("IsPreProvisioned") ?? false
};
}
/// <summary>
/// Returns the earliest event timestamp persisted for a session, if any.
/// Events are written with RowKey "{Timestamp}_{Sequence}", so querying the partition
/// and taking the first row yields the earliest event.
/// </summary>
private async Task<DateTime?> GetEarliestSessionEventTimestampAsync(string tenantId, string sessionId)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events);
var partitionKey = $"{tenantId}_{sessionId}";
var query = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{partitionKey}'",
maxPerPage: 1,
select: new[] { "Timestamp", "RowKey" }
);
await foreach (var entity in query)
{
return entity.GetDateTimeOffset("Timestamp")?.UtcDateTime
?? entity.GetDateTime("Timestamp");
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, $"Could not determine earliest event timestamp for session {sessionId}");
}
return null;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
"use client";
interface AdminConfiguration {
partitionKey: string;
rowKey: string;
lastUpdated: string;
updatedBy: string;
globalRateLimitRequestsPerMinute: number;
platformStatsBlobSasUrl?: string;
maxCollectorDurationHours?: number;
maxSessionWindowHours?: number;
maintenanceBlockDurationHours?: number;
diagnosticsGlobalLogPathsJson?: string;
customSettings?: string;
}
interface AdminConfigSettingsSectionProps {
loadingConfig: boolean;
savingConfig: boolean;
adminConfig: AdminConfiguration | null;
globalRateLimit: number;
setGlobalRateLimit: (value: number) => void;
platformStatsBlobSasUrl: string;
setPlatformStatsBlobSasUrl: (value: string) => void;
maxCollectorDurationHours: number;
setMaxCollectorDurationHours: (value: number) => void;
onSave: () => Promise<void>;
onReset: () => void;
}
export function AdminConfigSettingsSection({
loadingConfig,
savingConfig,
adminConfig,
globalRateLimit,
setGlobalRateLimit,
platformStatsBlobSasUrl,
setPlatformStatsBlobSasUrl,
maxCollectorDurationHours,
setMaxCollectorDurationHours,
onSave,
onReset,
}: AdminConfigSettingsSectionProps) {
return (
<div className="bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-gray-800 dark:to-gray-800 border-2 border-indigo-300 dark:border-indigo-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-indigo-200 dark:border-indigo-700 bg-gradient-to-r from-indigo-100 to-blue-100 dark:from-indigo-900/40 dark:to-blue-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-indigo-900 dark:text-indigo-100">Global Settings</h2>
<p className="text-sm text-indigo-600 dark:text-indigo-300 mt-1">Configure global settings for all tenants</p>
</div>
</div>
</div>
<div className="p-6">
{loadingConfig ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 dark:border-indigo-400 mx-auto"></div>
<p className="mt-3 text-indigo-800 dark:text-indigo-200 text-sm">Loading configuration...</p>
</div>
) : (
<div className="space-y-4">
<div>
<label className="block">
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Global Rate Limit (Requests per Minute per Device)</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mb-2">
Configure default DoS protection limits for all tenants. Normal enrollment generates ~10-30 requests/min.
<br />
<strong className="text-indigo-900 dark:text-indigo-100">Note:</strong> Tenants cannot change this value. Only Galactic Admins can override per tenant in tenant management section.
</p>
<input
type="number"
min="1"
max="1000"
value={globalRateLimit}
onChange={(e) => setGlobalRateLimit(parseInt(e.target.value) || 100)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</label>
</div>
<div>
<label className="block">
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Platform Stats Blob Container SAS URL</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mb-2">
Maintenance publishes two files into this container:
<code className="ml-1 mr-1 text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">platform-stats.json</code>
and
<code className="ml-1 text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">platform-stats.YYYY-MM-DD.json</code>.
The upload is best-effort and does not fail the maintenance run.
</p>
<input
type="url"
value={platformStatsBlobSasUrl}
onChange={(e) => setPlatformStatsBlobSasUrl(e.target.value)}
placeholder="https://storageaccount.blob.core.windows.net/publicstats?sv=...&sig=..."
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors font-mono text-sm"
/>
</label>
</div>
<div>
<label className="block">
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Max Collector Duration (Hours)</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mb-2">
Interval-based collectors (e.g. Performance Collector) stop automatically after this many hours per session.
Prevents excessive backend traffic when a device is stuck during enrollment.
Set to <strong>0</strong> to disable the limit (collectors run indefinitely).
</p>
<input
type="number"
min="0"
max="168"
value={maxCollectorDurationHours}
onChange={(e) => setMaxCollectorDurationHours(parseInt(e.target.value) || 0)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
/>
</label>
</div>
{adminConfig && (
<div className="bg-blue-50 dark:bg-gray-700 border border-blue-200 dark:border-indigo-600 rounded-lg p-3">
<div className="flex items-start space-x-2">
<svg className="w-5 h-5 text-blue-600 dark:text-indigo-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-800 dark:text-gray-200">
<p className="font-medium">Configuration Info</p>
<p className="mt-1">Last updated: {new Date(adminConfig.lastUpdated).toLocaleString()}</p>
<p>Updated by: {adminConfig.updatedBy}</p>
</div>
</div>
</div>
)}
<div className="flex items-center justify-end space-x-3 pt-2">
<button
onClick={onReset}
disabled={savingConfig}
className="px-5 py-2 border border-indigo-300 dark:border-indigo-600 rounded-md text-indigo-800 dark:text-indigo-200 bg-white dark:bg-gray-700 hover:bg-indigo-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Reset
</button>
<button
onClick={onSave}
disabled={savingConfig}
className="px-5 py-2 bg-gradient-to-r from-indigo-600 to-blue-600 text-white rounded-md hover:from-indigo-700 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg flex items-center space-x-2"
>
{savingConfig ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Saving...</span>
</>
) : (
<span>Save Configuration</span>
)}
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,323 @@
"use client";
import { useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { TenantConfiguration } from "./TenantManagementSection";
interface BlockedDevice {
tenantId: string;
serialNumber: string;
blockedAt: string;
unblockAt: string;
blockedByEmail: string;
durationHours: number;
reason?: string;
}
interface DeviceBlockSectionProps {
tenants: TenantConfiguration[];
maxSessionWindowHours: number;
setMaxSessionWindowHours: (value: number) => void;
maintenanceBlockDurationHours: number;
setMaintenanceBlockDurationHours: (value: number) => void;
savingConfig: boolean;
adminConfigExists: boolean;
onSaveAdminConfig: () => Promise<void>;
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
}
export function DeviceBlockSection({
tenants,
maxSessionWindowHours,
setMaxSessionWindowHours,
maintenanceBlockDurationHours,
setMaintenanceBlockDurationHours,
savingConfig,
adminConfigExists,
onSaveAdminConfig,
getAccessToken,
setError,
setSuccessMessage,
}: DeviceBlockSectionProps) {
const [blockSerialNumber, setBlockSerialNumber] = useState("");
const [blockTenantId, setBlockTenantId] = useState("");
const [blockDurationHours, setBlockDurationHours] = useState(12);
const [blockReason, setBlockReason] = useState("");
const [blockingDevice, setBlockingDevice] = useState(false);
const [blockedDevices, setBlockedDevices] = useState<BlockedDevice[]>([]);
const [loadingBlockedDevices, setLoadingBlockedDevices] = useState(false);
const [unblockingDevice, setUnblockingDevice] = useState<string | null>(null);
const [blockListTenantId, setBlockListTenantId] = useState("");
const fetchBlockedDevices = async (tenantId: string) => {
if (!tenantId) return;
try {
setLoadingBlockedDevices(true);
const token = await getAccessToken();
if (!token) throw new Error("Failed to get access token");
const response = await fetch(`${API_BASE_URL}/api/devices/blocked?tenantId=${encodeURIComponent(tenantId)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) throw new Error(`Failed to load blocked devices: ${response.statusText}`);
const data = await response.json();
setBlockedDevices(data.blocked ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load blocked devices");
} finally {
setLoadingBlockedDevices(false);
}
};
const handleBlockDevice = async () => {
if (!blockSerialNumber.trim() || !blockTenantId.trim()) return;
try {
setBlockingDevice(true);
setError(null);
const token = await getAccessToken();
if (!token) throw new Error("Failed to get access token");
const response = await fetch(`${API_BASE_URL}/api/devices/block`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({
tenantId: blockTenantId,
serialNumber: blockSerialNumber.trim(),
durationHours: blockDurationHours,
reason: blockReason.trim() || undefined,
}),
});
const result = await response.json();
if (!response.ok) throw new Error(result.message || response.statusText);
setSuccessMessage(`Device ${blockSerialNumber.trim()} blocked for ${blockDurationHours}h.`);
setTimeout(() => setSuccessMessage(null), 4000);
setBlockSerialNumber("");
setBlockReason("");
if (blockListTenantId === blockTenantId) await fetchBlockedDevices(blockTenantId);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to block device");
} finally {
setBlockingDevice(false);
}
};
const handleUnblockDevice = async (tenantId: string, serialNumber: string) => {
try {
setUnblockingDevice(serialNumber);
setError(null);
const token = await getAccessToken();
if (!token) throw new Error("Failed to get access token");
const response = await fetch(
`${API_BASE_URL}/api/devices/block/${encodeURIComponent(serialNumber)}?tenantId=${encodeURIComponent(tenantId)}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${token}` } }
);
const result = await response.json();
if (!response.ok) throw new Error(result.message || response.statusText);
setSuccessMessage(`Device ${serialNumber} unblocked.`);
setTimeout(() => setSuccessMessage(null), 3000);
setBlockedDevices((prev) => prev.filter((d) => d.serialNumber !== serialNumber || d.tenantId !== tenantId));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to unblock device");
} finally {
setUnblockingDevice(null);
}
};
return (
<div className="bg-gradient-to-br from-red-50 to-orange-50 dark:from-gray-800 dark:to-gray-800 border-2 border-red-300 dark:border-red-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-red-200 dark:border-red-700 bg-gradient-to-r from-red-100 to-orange-100 dark:from-red-900/40 dark:to-orange-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<div>
<h2 className="text-xl font-semibold text-red-900 dark:text-red-100">Device Block Management</h2>
<p className="text-sm text-red-600 dark:text-red-300 mt-1">Temporarily block a rogue device from sending data. The agent will stop uploading when it receives the block signal.</p>
</div>
</div>
</div>
<div className="p-6 space-y-6">
{/* Maintenance Auto-Block Settings */}
<div className="border border-red-200 dark:border-red-700 rounded-lg p-4 bg-red-50/50 dark:bg-red-900/10">
<div className="flex items-center space-x-2 mb-3">
<svg className="w-4 h-4 text-red-500 dark:text-red-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="text-sm font-semibold text-red-900 dark:text-red-100">Maintenance Auto-Block Settings</h3>
</div>
<p className="text-xs text-red-600 dark:text-red-400 mb-4">
The nightly maintenance function automatically blocks devices that are still actively sending data beyond the configured window. Sessions with a <code className="bg-red-100 dark:bg-red-900/40 px-1 rounded">LastEventAt</code> timestamp within the window <em>and</em> a <code className="bg-red-100 dark:bg-red-900/40 px-1 rounded">StartedAt</code> older than the window are flagged – regardless of session status.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Session Window (Hours)
</label>
<input
type="number"
min={0}
max={168}
value={maxSessionWindowHours}
onChange={(e) => setMaxSessionWindowHours(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">0 = disabled</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Maintenance Block Duration (Hours)
</label>
<input
type="number"
min={1}
max={720}
value={maintenanceBlockDurationHours}
onChange={(e) => setMaintenanceBlockDurationHours(parseInt(e.target.value) || 12)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Applied by the maintenance function</p>
</div>
</div>
<button
onClick={onSaveAdminConfig}
disabled={savingConfig || !adminConfigExists}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50 flex items-center space-x-2"
>
{savingConfig ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>Saving...</span></>
) : (
<span>Save Maintenance Settings</span>
)}
</button>
</div>
{/* Block a device form */}
<div>
<h3 className="text-sm font-semibold text-red-900 dark:text-red-100 mb-3">Block a Device</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Serial Number</label>
<input
type="text"
placeholder="e.g. 1234ABCD"
value={blockSerialNumber}
onChange={(e) => setBlockSerialNumber(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tenant ID</label>
<select
value={blockTenantId}
onChange={(e) => setBlockTenantId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
>
<option value="">&mdash; select tenant &mdash;</option>
{tenants.map((t) => (
<option key={t.tenantId} value={t.tenantId}>{t.domainName ? `${t.domainName} (${t.tenantId})` : t.tenantId}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Duration (Hours)</label>
<input
type="number"
min={1}
max={720}
value={blockDurationHours}
onChange={(e) => setBlockDurationHours(parseInt(e.target.value) || 12)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
<input
type="text"
placeholder="e.g. Excessive data volume"
value={blockReason}
onChange={(e) => setBlockReason(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
</div>
<button
onClick={handleBlockDevice}
disabled={blockingDevice || !blockSerialNumber.trim() || !blockTenantId}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50 flex items-center space-x-2"
>
{blockingDevice ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>Blocking...</span></>
) : (
<span>Block Device</span>
)}
</button>
</div>
{/* View active blocks for a tenant */}
<div className="border-t border-red-200 dark:border-red-700 pt-6">
<h3 className="text-sm font-semibold text-red-900 dark:text-red-100 mb-3">Active Blocks</h3>
<div className="flex items-end gap-3 mb-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tenant</label>
<select
value={blockListTenantId}
onChange={(e) => setBlockListTenantId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
>
<option value="">&mdash; select tenant &mdash;</option>
{tenants.map((t) => (
<option key={t.tenantId} value={t.tenantId}>{t.domainName ? `${t.domainName} (${t.tenantId})` : t.tenantId}</option>
))}
</select>
</div>
<button
onClick={() => fetchBlockedDevices(blockListTenantId)}
disabled={!blockListTenantId || loadingBlockedDevices}
className="px-4 py-2 bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200 border border-red-300 dark:border-red-600 rounded-lg text-sm font-medium hover:bg-red-200 disabled:opacity-50"
>
{loadingBlockedDevices ? "Loading..." : "Load"}
</button>
</div>
{blockedDevices.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-red-800 dark:text-red-200 uppercase bg-red-100 dark:bg-red-900/30">
<tr>
<th className="px-3 py-2">Serial Number</th>
<th className="px-3 py-2">Blocked Since</th>
<th className="px-3 py-2">Unblocks At</th>
<th className="px-3 py-2">Blocked By</th>
<th className="px-3 py-2">Reason</th>
<th className="px-3 py-2">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-red-100 dark:divide-red-900/30">
{blockedDevices.map((d) => (
<tr key={d.serialNumber} className="bg-white dark:bg-gray-800">
<td className="px-3 py-2 font-mono font-medium text-gray-900 dark:text-gray-100">{d.serialNumber}</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{new Date(d.blockedAt).toLocaleString()}</td>
<td className="px-3 py-2 text-orange-600 dark:text-orange-400 font-medium">{new Date(d.unblockAt).toLocaleString()}</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{d.blockedByEmail}</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{d.reason || "\u2014"}</td>
<td className="px-3 py-2">
<button
onClick={() => handleUnblockDevice(d.tenantId, d.serialNumber)}
disabled={unblockingDevice === d.serialNumber}
className="px-3 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50"
>
{unblockingDevice === d.serialNumber ? "Unblocking..." : "Unblock"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : blockListTenantId && !loadingBlockedDevices ? (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">No active blocks for this tenant.</p>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
export interface DiagnosticsLogPath {
path: string;
description: string;
isBuiltIn: boolean;
}
interface DiagnosticsLogPathsSectionProps {
globalDiagPaths: DiagnosticsLogPath[];
setGlobalDiagPaths: React.Dispatch<React.SetStateAction<DiagnosticsLogPath[]>>;
loadingConfig: boolean;
savingDiagPaths: boolean;
adminConfigExists: boolean;
onSave: (paths: DiagnosticsLogPath[]) => Promise<void>;
}
export function DiagnosticsLogPathsSection({
globalDiagPaths,
setGlobalDiagPaths,
loadingConfig,
savingDiagPaths,
adminConfigExists,
onSave,
}: DiagnosticsLogPathsSectionProps) {
const [newDiagPath, setNewDiagPath] = useState("");
const [newDiagDesc, setNewDiagDesc] = useState("");
return (
<div className="bg-gradient-to-br from-teal-50 to-cyan-50 dark:from-gray-800 dark:to-gray-800 border-2 border-teal-300 dark:border-teal-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-teal-200 dark:border-teal-700 bg-gradient-to-r from-teal-100 to-cyan-100 dark:from-teal-900/40 dark:to-cyan-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-teal-600 dark:text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-teal-900 dark:text-teal-100">Diagnostics Log Paths</h2>
<p className="text-sm text-teal-600 dark:text-teal-300 mt-1">Global log file paths included in diagnostics packages for all tenants. Tenants may add their own paths in Settings.</p>
</div>
</div>
</div>
<div className="p-6 space-y-4">
{/* Info box */}
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-700 rounded-lg p-3 flex items-start space-x-2">
<svg className="w-4 h-4 text-teal-600 dark:text-teal-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-xs text-teal-700 dark:text-teal-300">
All paths are validated on the agent against an allowlist of safe prefixes (DiagnosticsPathGuards). Wildcards are only allowed in the last path segment. Environment variables are expanded by the agent.
</p>
</div>
{/* Current paths list */}
{loadingConfig ? (
<div className="flex items-center space-x-2 text-sm text-gray-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-teal-600"></div>
<span>Loading...</span>
</div>
) : globalDiagPaths.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">No global paths configured yet.</p>
) : (
<div className="space-y-2">
{globalDiagPaths.map((entry, idx) => (
<div key={idx} className="flex items-start justify-between bg-teal-100 dark:bg-teal-900/40 border border-teal-300 dark:border-teal-700 rounded-lg px-3 py-2">
<div className="min-w-0 flex-1">
<p className="font-mono text-sm text-teal-900 dark:text-teal-100 break-all">{entry.path}</p>
{entry.description && (
<p className="text-xs text-teal-600 dark:text-teal-400 mt-0.5">{entry.description}</p>
)}
</div>
<button
onClick={() => {
const updated = globalDiagPaths.filter((_, i) => i !== idx);
setGlobalDiagPaths(updated);
}}
className="ml-3 flex-shrink-0 text-teal-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title="Remove"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
{/* Add new path */}
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
placeholder="Path or wildcard (e.g. C:\Windows\Panther\*.log)"
value={newDiagPath}
onChange={(e) => setNewDiagPath(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500 font-mono"
/>
<input
type="text"
placeholder="Description (optional)"
value={newDiagDesc}
onChange={(e) => setNewDiagDesc(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
<button
onClick={() => {
const p = newDiagPath.trim();
if (!p) return;
setGlobalDiagPaths([...globalDiagPaths, { path: p, description: newDiagDesc.trim(), isBuiltIn: true }]);
setNewDiagPath("");
setNewDiagDesc("");
}}
disabled={!newDiagPath.trim()}
className="px-4 py-2 bg-teal-600 text-white rounded-lg text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
>
Add
</button>
</div>
{/* Save button */}
<div className="flex justify-end pt-2">
<button
onClick={() => onSave(globalDiagPaths)}
disabled={savingDiagPaths || !adminConfigExists}
className="px-6 py-2 bg-gradient-to-r from-teal-600 to-cyan-600 text-white rounded-lg text-sm font-medium hover:from-teal-700 hover:to-cyan-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg flex items-center space-x-2"
>
{savingDiagPaths ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div><span>Saving...</span></>
) : (
<span>Save Global Paths</span>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,448 @@
"use client";
import { useState } from "react";
import { API_BASE_URL } from "@/lib/config";
interface MaintenanceSectionProps {
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
}
export function MaintenanceSection({
getAccessToken,
setError,
setSuccessMessage,
}: MaintenanceSectionProps) {
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
const [reseedingRules, setReseedingRules] = useState(false);
const [reseedingGatherRules, setReseedingGatherRules] = useState(false);
const [reseedingImePatterns, setReseedingImePatterns] = useState(false);
const [fetchingFromGitHub, setFetchingFromGitHub] = useState(false);
const handleTriggerMaintenance = async () => {
try {
setTriggeringMaintenance(true);
setError(null);
setSuccessMessage(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const queryParams = maintenanceDate ? `?date=${maintenanceDate}` : '';
const response = await fetch(`${API_BASE_URL}/api/maintenance/trigger${queryParams}`, {
method: "POST",
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Failed to trigger maintenance: ${response.statusText}`);
}
const result = await response.json();
const dateInfo = maintenanceDate ? ` for ${maintenanceDate}` : '';
setSuccessMessage(`Maintenance job completed successfully${dateInfo}!`);
// Auto-hide success message after 5 seconds
setTimeout(() => setSuccessMessage(null), 5000);
} catch (err) {
console.error("Error triggering maintenance:", err);
setError(err instanceof Error ? err.message : "Failed to trigger maintenance job");
} finally {
setTriggeringMaintenance(false);
}
};
const handleReseedAnalyzeRules = async () => {
try {
setReseedingRules(true);
setError(null);
setSuccessMessage(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=analyze`, {
method: "POST",
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Failed to reseed analyze rules: ${response.statusText}`);
}
const result = await response.json();
setSuccessMessage(`Analyze rules reseeded from GitHub: ${result.analyze?.deleted ?? 0} deleted, ${result.analyze?.written ?? 0} written`);
setTimeout(() => setSuccessMessage(null), 5000);
} catch (err) {
console.error("Error reseeding analyze rules:", err);
setError(err instanceof Error ? err.message : "Failed to reseed analyze rules");
} finally {
setReseedingRules(false);
}
};
const handleReseedGatherRules = async () => {
try {
setReseedingGatherRules(true);
setError(null);
setSuccessMessage(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=gather`, {
method: "POST",
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Failed to reseed gather rules: ${response.statusText}`);
}
const result = await response.json();
setSuccessMessage(`Gather rules reseeded from GitHub: ${result.gather?.deleted ?? 0} deleted, ${result.gather?.written ?? 0} written`);
setTimeout(() => setSuccessMessage(null), 5000);
} catch (err) {
console.error("Error reseeding gather rules:", err);
setError(err instanceof Error ? err.message : "Failed to reseed gather rules");
} finally {
setReseedingGatherRules(false);
}
};
const handleReseedImePatterns = async () => {
try {
setReseedingImePatterns(true);
setError(null);
setSuccessMessage(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=ime`, {
method: "POST",
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Failed to reseed IME log patterns: ${response.statusText}`);
}
const result = await response.json();
setSuccessMessage(`IME log patterns reseeded from GitHub: ${result.ime?.deleted ?? 0} deleted, ${result.ime?.written ?? 0} written`);
setTimeout(() => setSuccessMessage(null), 5000);
} catch (err) {
console.error("Error reseeding IME log patterns:", err);
setError(err instanceof Error ? err.message : "Failed to reseed IME log patterns");
} finally {
setReseedingImePatterns(false);
}
};
const handleFetchFromGitHub = async () => {
try {
setFetchingFromGitHub(true);
setError(null);
setSuccessMessage(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/rules/reseed-from-github?type=all`, {
method: "POST",
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Failed to reseed from GitHub: ${response.statusText}`);
}
const result = await response.json();
setSuccessMessage(
`GitHub Reseed complete: Gather (${result.gather?.deleted ?? 0} deleted, ${result.gather?.written ?? 0} written), ` +
`Analyze (${result.analyze?.deleted ?? 0} deleted, ${result.analyze?.written ?? 0} written), ` +
`IME (${result.ime?.deleted ?? 0} deleted, ${result.ime?.written ?? 0} written)`
);
setTimeout(() => setSuccessMessage(null), 8000);
} catch (err) {
console.error("Error reseeding from GitHub:", err);
setError(err instanceof Error ? err.message : "Failed to reseed from GitHub");
} finally {
setFetchingFromGitHub(false);
}
};
return (
<>
{/* Manual Maintenance Trigger */}
<div className="bg-gradient-to-br from-purple-50 to-violet-50 dark:from-gray-800 dark:to-gray-800 border-2 border-purple-300 dark:border-purple-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-purple-200 dark:border-purple-700 bg-gradient-to-r from-purple-100 to-violet-100 dark:from-purple-900/40 dark:to-violet-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-purple-900 dark:text-purple-100">Manual Maintenance Trigger</h2>
<p className="text-sm text-purple-600 dark:text-purple-300 mt-1">Execute platform-wide maintenance operations</p>
</div>
</div>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-purple-900 dark:text-gray-200">
Manually trigger the daily maintenance job which includes:
</p>
<ul className="text-sm text-purple-900 dark:text-gray-200 space-y-1 ml-4">
<li className="flex items-start">
<span className="text-purple-500 dark:text-purple-400 mr-2">&bull;</span>
<span>Mark stalled sessions as timed out</span>
</li>
<li className="flex items-start">
<span className="text-purple-500 dark:text-purple-400 mr-2">&bull;</span>
<span>Aggregate metrics into historical snapshots (with automatic catch-up for missed days)</span>
</li>
<li className="flex items-start">
<span className="text-purple-500 dark:text-purple-400 mr-2">&bull;</span>
<span>Clean up old data based on retention policies</span>
</li>
</ul>
<div className="bg-purple-50 dark:bg-gray-700 border border-purple-200 dark:border-purple-600 rounded-lg p-4">
<label className="block text-sm font-medium text-purple-800 dark:text-purple-200 mb-1">
Target Date (optional)
</label>
<input
type="date"
value={maintenanceDate}
onChange={(e) => setMaintenanceDate(e.target.value)}
max={new Date(Date.now() - 86400000).toISOString().split('T')[0]}
className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Leave empty to run the standard maintenance with automatic catch-up (aggregates any missed days within the last 7 days).
Select a specific date to manually aggregate metrics for that day, e.g. to backfill data older than 7 days.
</p>
</div>
<div className="bg-white dark:bg-gray-700 border border-purple-300 dark:border-purple-600 rounded-lg p-3">
<div className="flex items-start space-x-2">
<svg className="w-5 h-5 text-purple-600 dark:text-purple-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-sm text-gray-800 dark:text-gray-200">
<strong>Warning:</strong> This operation runs across all tenants and may take several minutes to complete.
Use this only for testing or when immediate cleanup is needed.
</p>
</div>
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleTriggerMaintenance}
disabled={triggeringMaintenance}
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-violet-600 text-white rounded-lg hover:from-purple-700 hover:to-violet-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg flex items-center space-x-2"
>
{triggeringMaintenance ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
<span>Running...</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Run Now</span>
</>
)}
</button>
</div>
</div>
</div>
{/* Fetch & Reseed from GitHub */}
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-gray-800 dark:to-gray-800 border-2 border-emerald-300 dark:border-emerald-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-emerald-200 dark:border-emerald-700 bg-gradient-to-r from-emerald-100 to-teal-100 dark:from-emerald-900/40 dark:to-teal-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<div>
<h2 className="text-xl font-semibold text-emerald-900 dark:text-emerald-100">Fetch &amp; Reseed from GitHub</h2>
<p className="text-sm text-emerald-600 dark:text-emerald-300 mt-1">Fetch the latest rules from the GitHub repository and reseed all rule types</p>
</div>
</div>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-emerald-900 dark:text-gray-200">
This operation fetches all rule definitions from the GitHub repository and writes them to Azure Table Storage:
</p>
<ul className="text-sm text-emerald-900 dark:text-gray-200 space-y-1 ml-4">
<li className="flex items-start">
<span className="text-emerald-500 dark:text-emerald-400 mr-2">&bull;</span>
<span>Fetches Gather Rules, Analyze Rules, and IME Log Patterns from GitHub</span>
</li>
<li className="flex items-start">
<span className="text-emerald-500 dark:text-emerald-400 mr-2">&bull;</span>
<span>Deletes all existing global built-in entries and writes the latest versions</span>
</li>
<li className="flex items-start">
<span className="text-emerald-500 dark:text-emerald-400 mr-2">&bull;</span>
<span>Tenant-specific custom rules and overrides are not affected</span>
</li>
</ul>
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-3">
<div className="flex items-start space-x-2">
<svg className="w-5 h-5 text-blue-500 dark:text-blue-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-blue-800 dark:text-blue-200">
Rules are loaded from <code className="px-1 bg-blue-100 dark:bg-blue-800 rounded text-xs">raw.githubusercontent.com</code>. After a merge to the repository, it may take up to <strong>5 minutes</strong> for GitHub&apos;s CDN cache to update. If you see stale rules after reseeding, wait a few minutes and try again.
</p>
</div>
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleFetchFromGitHub}
disabled={fetchingFromGitHub}
className="px-6 py-3 bg-gradient-to-r from-emerald-600 to-teal-600 text-white rounded-lg hover:from-emerald-700 hover:to-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg flex items-center space-x-2"
>
{fetchingFromGitHub ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
<span>Fetching from GitHub...</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Fetch &amp; Reseed All</span>
</>
)}
</button>
</div>
</div>
</div>
{/* Individual Reseed Sections */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Reseed Analyze Rules */}
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-800 border border-amber-200 dark:border-amber-700 rounded-lg shadow">
<div className="p-4 border-b border-amber-200 dark:border-amber-700 bg-gradient-to-r from-amber-100 to-orange-100 dark:from-amber-900/40 dark:to-orange-900/40">
<h3 className="text-lg font-semibold text-amber-900 dark:text-amber-100">Reseed Analyze Rules</h3>
<p className="text-xs text-amber-600 dark:text-amber-300 mt-1">Fetch and reseed only analyze rules from GitHub</p>
</div>
<div className="p-4">
<div className="flex justify-end">
<button
onClick={handleReseedAnalyzeRules}
disabled={reseedingRules}
className="px-4 py-2 bg-gradient-to-r from-amber-600 to-orange-600 text-white rounded-lg hover:from-amber-700 hover:to-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow flex items-center space-x-2 text-sm"
>
{reseedingRules ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Reseeding...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Reseed Analyze</span>
</>
)}
</button>
</div>
</div>
</div>
{/* Reseed Gather Rules */}
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-800 border border-amber-200 dark:border-amber-700 rounded-lg shadow">
<div className="p-4 border-b border-amber-200 dark:border-amber-700 bg-gradient-to-r from-amber-100 to-orange-100 dark:from-amber-900/40 dark:to-orange-900/40">
<h3 className="text-lg font-semibold text-amber-900 dark:text-amber-100">Reseed Gather Rules</h3>
<p className="text-xs text-amber-600 dark:text-amber-300 mt-1">Fetch and reseed only gather rules from GitHub</p>
</div>
<div className="p-4">
<div className="flex justify-end">
<button
onClick={handleReseedGatherRules}
disabled={reseedingGatherRules}
className="px-4 py-2 bg-gradient-to-r from-amber-600 to-orange-600 text-white rounded-lg hover:from-amber-700 hover:to-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow flex items-center space-x-2 text-sm"
>
{reseedingGatherRules ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Reseeding...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Reseed Gather</span>
</>
)}
</button>
</div>
</div>
</div>
{/* Reseed IME Log Patterns */}
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-800 border border-amber-200 dark:border-amber-700 rounded-lg shadow">
<div className="p-4 border-b border-amber-200 dark:border-amber-700 bg-gradient-to-r from-amber-100 to-orange-100 dark:from-amber-900/40 dark:to-orange-900/40">
<h3 className="text-lg font-semibold text-amber-900 dark:text-amber-100">Reseed IME Log Patterns</h3>
<p className="text-xs text-amber-600 dark:text-amber-300 mt-1">Fetch and reseed only IME log patterns from GitHub</p>
</div>
<div className="p-4">
<div className="flex justify-end">
<button
onClick={handleReseedImePatterns}
disabled={reseedingImePatterns}
className="px-4 py-2 bg-gradient-to-r from-amber-600 to-orange-600 text-white rounded-lg hover:from-amber-700 hover:to-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow flex items-center space-x-2 text-sm"
>
{reseedingImePatterns ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Reseeding...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Reseed IME</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,330 @@
"use client";
import { useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { TenantConfiguration } from "./TenantManagementSection";
interface SessionExportEvent {
eventId: string;
sessionId: string;
tenantId: string;
timestamp: string;
eventType: string;
severity: string;
source: string;
phase: number;
phaseName?: string;
message: string;
sequence: number;
rowKey?: string;
data?: Record<string, unknown>;
}
const EXPORT_V1_PHASE_NAMES: Record<number, string> = {
0: "Start", 1: "Device Preparation", 2: "Device Setup",
3: "Apps (Device)", 4: "Account Setup", 5: "Apps (User)",
6: "Finalizing Setup", 7: "Complete", 99: "Failed"
};
const EXPORT_V2_PHASE_NAMES: Record<number, string> = {
0: "Start", 1: "Device Preparation", 2: "Device Setup",
3: "App Installation", 4: "Account Setup", 5: "Apps (User)",
6: "Finalizing Setup", 7: "Complete", 99: "Failed"
};
const EXPORT_V1_PHASE_ORDER = ["Start", "Device Preparation", "Device Setup",
"Apps (Device)", "Account Setup", "Apps (User)", "Finalizing Setup", "Complete", "Failed"];
const EXPORT_V2_PHASE_ORDER = ["Start", "Device Preparation", "App Installation",
"Finalizing Setup", "Complete", "Failed"];
function downloadFile(content: string, filename: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function generateCsvExport(events: SessionExportEvent[]) {
const isV1 = events.some(e => e.phase === 2);
const phaseNames = isV1 ? EXPORT_V1_PHASE_NAMES : EXPORT_V2_PHASE_NAMES;
const esc = (v: string) => `"${v.replace(/"/g, '""')}"`;
// Sort exactly as Azure Table Storage: by timestamp ascending, then sequence ascending
const sorted = [...events].sort((a, b) => {
const tCmp = (a.timestamp ?? "").localeCompare(b.timestamp ?? "");
if (tCmp !== 0) return tCmp;
return (a.sequence ?? 0) - (b.sequence ?? 0);
});
const header = "RowKey,EventId,SessionId,TenantId,Timestamp,EventType,Severity,Source,Phase,PhaseName,Message,Sequence,Data";
const rows = sorted.map(e => [
esc(e.rowKey ?? ""),
esc(e.eventId ?? ""),
esc(e.sessionId ?? ""),
esc(e.tenantId ?? ""),
esc(e.timestamp ?? ""),
esc(e.eventType ?? ""),
esc(e.severity ?? ""),
esc(e.source ?? ""),
String(e.phase ?? 0),
esc(phaseNames[e.phase] ?? "Unknown"),
esc(e.message ?? ""),
String(e.sequence ?? 0),
esc(e.data ? JSON.stringify(e.data) : ""),
].join(","));
return "\uFEFF" + header + "\n" + rows.join("\n");
}
function generateUiExport(events: SessionExportEvent[], sessionId: string, tenantId: string) {
const isV1 = events.some(e => e.phase === 2);
const phaseNames = isV1 ? EXPORT_V1_PHASE_NAMES : EXPORT_V2_PHASE_NAMES;
const phaseOrder = isV1 ? EXPORT_V1_PHASE_ORDER : EXPORT_V2_PHASE_ORDER;
const sorted = [...events].sort((a, b) => a.sequence - b.sequence);
// Replicate eventsByPhase useMemo logic: assign Unknown-phase events to active named phase
const grouped: Record<string, SessionExportEvent[]> = {};
phaseOrder.forEach(p => { grouped[p] = []; });
let lastNamedPhase = phaseOrder[0];
for (const ev of sorted) {
const name = phaseNames[ev.phase];
if (name && name !== "Unknown") {
lastNamedPhase = name;
if (grouped[name]) grouped[name].push({ ...ev, phaseName: name });
} else {
// Unknown phase — insert into currently active named phase
if (grouped[lastNamedPhase]) grouped[lastNamedPhase].push({ ...ev, phaseName: "Unknown" });
}
}
const pad = (s: string, len: number) => s.padEnd(len);
const severityLabel = (s: string) => pad(s ?? "Unknown", 7);
const lines: string[] = [];
lines.push("AUTOPILOT MONITOR \u2014 SESSION EVENT EXPORT");
lines.push("=========================================");
lines.push(`Session ID : ${sessionId}`);
lines.push(`Tenant ID : ${tenantId}`);
lines.push(`Exported at : ${new Date().toISOString()}`);
lines.push(`Total events : ${events.length}`);
lines.push(`Enrollment : ${isV1 ? "V1" : "V2"}`);
for (const phase of phaseOrder) {
const phaseEvents = grouped[phase] ?? [];
lines.push("");
lines.push("\u2550".repeat(43));
lines.push(` ${phase} (${phaseEvents.length} event${phaseEvents.length !== 1 ? "s" : ""})`);
lines.push("\u2550".repeat(43));
if (phaseEvents.length === 0) {
lines.push(" (no events)");
} else {
for (const ev of phaseEvents) {
const ts = ev.timestamp ? new Date(ev.timestamp).toISOString().replace("T", " ").substring(0, 23) : "?";
lines.push(`[${ts}] [${severityLabel(ev.severity)}] ${ev.eventType} \u2014 ${ev.message}`);
let detail = ` Source: ${ev.source ?? "?"} | Seq: ${ev.sequence ?? "?"} | EventId: ${ev.eventId ?? "?"}`;
if (ev.phaseName === "Unknown") detail += ` | RawPhase: ${ev.phase}`;
lines.push(detail);
if (ev.data && Object.keys(ev.data).length > 0) {
lines.push(` Data: ${JSON.stringify(ev.data)}`);
}
}
}
}
return lines.join("\n");
}
interface SessionExportSectionProps {
tenants: TenantConfiguration[];
getAccessToken: () => Promise<string | null>;
}
export function SessionExportSection({
tenants,
getAccessToken,
}: SessionExportSectionProps) {
const [exportSessionId, setExportSessionId] = useState("");
const [exportTenantId, setExportTenantId] = useState("");
const [exportLoading, setExportLoading] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [exportedEvents, setExportedEvents] = useState<SessionExportEvent[] | null>(null);
const cleanId = (v: string) => v.trim().replace(/^["'\s]+|["'\s]+$/g, "");
const handleFetchExportEvents = async () => {
const sid = cleanId(exportSessionId);
const tid = cleanId(exportTenantId);
if (!sid || !tid) {
setExportError("Session ID and Tenant ID are required.");
return;
}
try {
setExportLoading(true);
setExportError(null);
setExportedEvents(null);
const token = await getAccessToken();
if (!token) throw new Error("Failed to get access token");
const res = await fetch(
`${API_BASE_URL}/api/sessions/${sid}/events?tenantId=${encodeURIComponent(tid)}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const data = await res.json();
if (!data.success) throw new Error(data.message || "Backend returned error");
setExportedEvents(data.events ?? []);
} catch (err) {
setExportError(err instanceof Error ? err.message : "Failed to fetch events");
} finally {
setExportLoading(false);
}
};
return (
<div className="bg-gradient-to-br from-teal-50 to-cyan-50 dark:from-gray-800 dark:to-gray-800 border-2 border-teal-300 dark:border-teal-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-teal-200 dark:border-teal-700 bg-gradient-to-r from-teal-100 to-cyan-100 dark:from-teal-900/40 dark:to-cyan-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-teal-600 dark:text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<div>
<h2 className="text-xl font-semibold text-teal-900 dark:text-teal-100">Session Event Export</h2>
<p className="text-sm text-teal-600 dark:text-teal-300 mt-1">Fetch and export all events directly from storage — use to analyze timeline phase grouping and ordering</p>
</div>
</div>
</div>
<div className="p-6">
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-teal-900 dark:text-teal-100 mb-1">Session ID</label>
<input
type="text"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
value={exportSessionId}
onChange={e => setExportSessionId(e.target.value)}
className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-teal-900 dark:text-teal-100 mb-1">Tenant ID</label>
{tenants.length > 0 ? (
<div className="space-y-1.5">
<select
value={tenants.some(t => t.tenantId === exportTenantId) ? exportTenantId : ""}
onChange={e => { if (e.target.value) setExportTenantId(e.target.value); }}
className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
>
<option value="">&mdash; select tenant &mdash;</option>
{[...tenants].sort((a, b) => (a.domainName || a.tenantId).localeCompare(b.domainName || b.tenantId)).map(t => (
<option key={t.tenantId} value={t.tenantId}>
{t.domainName ? `${t.domainName} (${t.tenantId})` : t.tenantId}
</option>
))}
</select>
<input
type="text"
placeholder="or enter Tenant ID directly"
value={exportTenantId}
onChange={e => setExportTenantId(e.target.value)}
className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
) : (
<input
type="text"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
value={exportTenantId}
onChange={e => setExportTenantId(e.target.value)}
className="w-full px-3 py-2 border border-teal-300 dark:border-teal-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
)}
</div>
</div>
<div className="flex justify-end">
<button
onClick={handleFetchExportEvents}
disabled={exportLoading}
className="px-5 py-2.5 bg-gradient-to-r from-teal-600 to-cyan-600 text-white rounded-lg hover:from-teal-700 hover:to-cyan-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg flex items-center space-x-2 text-sm font-medium"
>
{exportLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Fetching...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>Fetch Events</span>
</>
)}
</button>
</div>
{exportError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-3 flex items-center space-x-2">
<svg className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm text-red-800 dark:text-red-300">{exportError}</span>
</div>
)}
{exportedEvents !== null && (
<div className="space-y-3">
<div className="flex items-center space-x-2 text-sm text-teal-800 dark:text-teal-200 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-700 rounded-lg p-3">
<svg className="w-4 h-4 text-teal-600 dark:text-teal-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
<strong>{exportedEvents.length}</strong> events loaded
{" \u00B7 "}
{exportedEvents.some(e => e.phase === 2) ? "V1" : "V2"}
{" \u00B7 "}
session {cleanId(exportSessionId).slice(0, 8)}
</span>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={() => {
const sid = cleanId(exportSessionId);
const tid = cleanId(exportTenantId);
downloadFile(
generateUiExport(exportedEvents, sid, tid),
`session-${sid.slice(0, 8)}-timeline.txt`,
"text/plain;charset=utf-8"
);
}}
className="flex items-center justify-center space-x-2 px-4 py-2.5 bg-white dark:bg-gray-700 border-2 border-teal-400 dark:border-teal-600 text-teal-800 dark:text-teal-200 rounded-lg hover:bg-teal-50 dark:hover:bg-teal-900/20 transition-colors text-sm font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<span>Download Timeline Export (.txt)</span>
</button>
<button
onClick={() => {
const sid = cleanId(exportSessionId);
downloadFile(
generateCsvExport(exportedEvents),
`session-${sid.slice(0, 8)}-events.csv`,
"text/csv;charset=utf-8"
);
}}
className="flex items-center justify-center space-x-2 px-4 py-2.5 bg-white dark:bg-gray-700 border-2 border-teal-400 dark:border-teal-600 text-teal-800 dark:text-teal-200 rounded-lg hover:bg-teal-50 dark:hover:bg-teal-900/20 transition-colors text-sm font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<span>Download Raw CSV Export (.csv)</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,374 @@
"use client";
import { useEffect, useState } from "react";
import { API_BASE_URL } from "@/lib/config";
interface TenantAdmin {
tenantId: string;
upn: string;
isEnabled: boolean;
addedDate: string;
addedBy: string;
}
interface TenantAdminSectionProps {
tenantId: string;
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
}
export function TenantAdminSection({
tenantId,
getAccessToken,
setError,
setSuccessMessage,
}: TenantAdminSectionProps) {
const [tenantAdmins, setTenantAdmins] = useState<TenantAdmin[]>([]);
const [loadingAdmins, setLoadingAdmins] = useState(false);
const [newAdminEmail, setNewAdminEmail] = useState("");
const [addingAdmin, setAddingAdmin] = useState(false);
const [removingAdmin, setRemovingAdmin] = useState<string | null>(null);
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
const [currentAdminPage, setCurrentAdminPage] = useState(0);
const [adminSearchQuery, setAdminSearchQuery] = useState("");
const adminsPerPage = 5;
// Fetch tenant admins when tenantId changes
const fetchTenantAdmins = async (tid: string) => {
try {
setLoadingAdmins(true);
setCurrentAdminPage(0); // Reset to first page when loading new tenant
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/tenants/${tid}/admins`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Failed to load admins: ${response.statusText}`);
}
const data: TenantAdmin[] = await response.json();
setTenantAdmins(data);
} catch (err) {
console.error("Error fetching tenant admins:", err);
setError(err instanceof Error ? err.message : "Failed to load tenant admins");
} finally {
setLoadingAdmins(false);
}
};
useEffect(() => {
fetchTenantAdmins(tenantId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tenantId]);
const handleAddTenantAdmin = async () => {
if (!newAdminEmail.trim()) return;
try {
setAddingAdmin(true);
setError(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins`, {
method: "POST",
headers: {
"Content-Type": "application/json",
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ upn: newAdminEmail.trim() }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to add admin: ${response.statusText}`);
}
setSuccessMessage(`Admin ${newAdminEmail} added successfully!`);
setNewAdminEmail("");
// Refresh admin list
await fetchTenantAdmins(tenantId);
// Auto-hide success message after 3 seconds
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
console.error("Error adding tenant admin:", err);
setError(err instanceof Error ? err.message : "Failed to add admin");
} finally {
setAddingAdmin(false);
}
};
const handleRemoveTenantAdmin = async (adminUpn: string) => {
if (!confirm(`Are you sure you want to remove ${adminUpn} as an admin?`)) {
return;
}
try {
setRemovingAdmin(adminUpn);
setError(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}`, {
method: "DELETE",
headers: {
'Authorization': `Bearer ${token}`
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to remove admin: ${response.statusText}`);
}
setSuccessMessage(`Admin ${adminUpn} removed successfully!`);
// Refresh admin list
await fetchTenantAdmins(tenantId);
// Auto-hide success message after 3 seconds
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
console.error("Error removing tenant admin:", err);
setError(err instanceof Error ? err.message : "Failed to remove admin");
} finally {
setRemovingAdmin(null);
}
};
const handleToggleTenantAdmin = async (adminUpn: string, isEnabled: boolean) => {
try {
setTogglingAdmin(adminUpn);
setError(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const action = isEnabled ? 'disable' : 'enable';
const response = await fetch(`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/${action}`, {
method: "PATCH",
headers: {
'Authorization': `Bearer ${token}`
},
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = { error: `Failed to ${action} admin: ${response.statusText}` };
}
throw new Error(errorData.error || `Failed to ${action} admin: ${response.statusText}`);
}
setSuccessMessage(`Admin ${adminUpn} ${isEnabled ? 'disabled' : 'enabled'} successfully!`);
// Refresh admin list
await fetchTenantAdmins(tenantId);
// Auto-hide success message after 3 seconds
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
console.error("Error toggling tenant admin:", err);
setError(err instanceof Error ? err.message : "Failed to toggle admin");
} finally {
setTogglingAdmin(null);
}
};
// Admin Pagination with Search
const filteredAdmins = tenantAdmins.filter(admin =>
admin.upn.toLowerCase().includes(adminSearchQuery.toLowerCase())
);
const totalAdminPages = Math.ceil(filteredAdmins.length / adminsPerPage);
const startAdminIndex = currentAdminPage * adminsPerPage;
const endAdminIndex = startAdminIndex + adminsPerPage;
const paginatedAdmins = filteredAdmins.slice(startAdminIndex, endAdminIndex);
return (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<svg className="w-5 h-5 text-purple-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<div className="flex-1">
<p className="font-semibold text-purple-900 mb-2">Admin Users Management</p>
{/* Current Admins List */}
<div className="mb-3">
<p className="text-sm text-purple-800 font-medium mb-2">
Current Admins ({tenantAdmins.length}):
</p>
{/* Search Field */}
<div className="mb-3">
<div className="relative">
<input
type="text"
name="admin-search-modal"
value={adminSearchQuery}
onChange={(e) => {
setAdminSearchQuery(e.target.value);
setCurrentAdminPage(0);
}}
placeholder="Search by email..."
autoComplete="off"
className="w-full px-3 py-2 pl-9 pr-9 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
/>
<svg className="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{adminSearchQuery && (
<button
onClick={() => {
setAdminSearchQuery("");
setCurrentAdminPage(0);
}}
className="absolute right-2.5 top-2.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear search"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{loadingAdmins ? (
<div className="text-sm text-purple-700">Loading admins...</div>
) : tenantAdmins.length === 0 ? (
<div className="text-sm text-purple-700 italic">No admins configured yet</div>
) : filteredAdmins.length === 0 ? (
<div className="text-sm text-purple-700 italic p-3 text-center bg-purple-50 rounded-lg">
No admins match your search
</div>
) : (
<>
<div className="space-y-2">
{paginatedAdmins.map((admin) => (
<div
key={admin.upn}
className={`flex items-center justify-between p-2 bg-white border rounded text-sm ${
admin.isEnabled ? 'border-purple-200' : 'border-gray-300 bg-gray-50'
}`}
>
<div className="flex-1">
<div className="flex items-center space-x-2">
<div className="font-medium text-gray-900">{admin.upn}</div>
{!admin.isEnabled && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-700">
Disabled
</span>
)}
</div>
<div className="text-xs text-gray-500">
Added {new Date(admin.addedDate).toLocaleDateString()} by {admin.addedBy}
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => handleToggleTenantAdmin(admin.upn, admin.isEnabled)}
disabled={togglingAdmin === admin.upn}
className={`px-2 py-1 text-xs rounded hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed ${
admin.isEnabled
? 'bg-yellow-600 text-white hover:bg-yellow-700'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{togglingAdmin === admin.upn
? "..."
: admin.isEnabled ? "Disable" : "Enable"}
</button>
<button
onClick={() => handleRemoveTenantAdmin(admin.upn)}
disabled={removingAdmin === admin.upn}
className="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{removingAdmin === admin.upn ? "..." : "Remove"}
</button>
</div>
</div>
))}
</div>
{/* Pagination */}
{totalAdminPages > 1 && (
<div className="flex items-center justify-between pt-3 mt-3 border-t border-purple-200">
<button
onClick={() => setCurrentAdminPage(p => Math.max(0, p - 1))}
disabled={currentAdminPage === 0}
className="px-3 py-1 text-xs font-medium text-purple-700 bg-white border border-purple-300 rounded hover:bg-purple-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-xs text-purple-700">
Page {currentAdminPage + 1} of {totalAdminPages} ({filteredAdmins.length} admin{filteredAdmins.length !== 1 ? 's' : ''})
</span>
<button
onClick={() => setCurrentAdminPage(p => Math.min(totalAdminPages - 1, p + 1))}
disabled={currentAdminPage >= totalAdminPages - 1}
className="px-3 py-1 text-xs font-medium text-purple-700 bg-white border border-purple-300 rounded hover:bg-purple-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
{/* Add New Admin */}
<div>
<p className="text-sm text-purple-800 font-medium mb-2">Add New Admin:</p>
<div className="flex space-x-2">
<input
type="email"
name="modal-new-admin-email"
id="modal-add-admin-email-input"
value={newAdminEmail}
onChange={(e) => setNewAdminEmail(e.target.value)}
placeholder="admin@tenant.com"
autoComplete="off"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTenantAdmin();
}
}}
/>
<button
onClick={() => handleAddTenantAdmin()}
disabled={addingAdmin || !newAdminEmail.trim()}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
{addingAdmin ? "Adding..." : "Add"}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,621 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { TenantAdminSection } from "./TenantAdminSection";
export interface TenantConfiguration {
tenantId: string;
domainName: string;
lastUpdated: string;
updatedBy: string;
disabled: boolean;
disabledReason?: string;
disabledUntil?: string;
rateLimitRequestsPerMinute: number;
manufacturerWhitelist: string;
modelWhitelist: string;
validateAutopilotDevice: boolean;
allowInsecureAgentRequests?: boolean;
dataRetentionDays: number;
sessionTimeoutHours: number;
maxNdjsonPayloadSizeMB: number;
}
export interface TenantManagementSectionProps {
tenants: TenantConfiguration[];
loadingTenants: boolean;
fetchTenants: () => void;
previewApproved: Set<string>;
setPreviewApproved: React.Dispatch<React.SetStateAction<Set<string>>>;
setTenants: React.Dispatch<React.SetStateAction<TenantConfiguration[]>>;
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
}
export function TenantManagementSection({
tenants,
loadingTenants,
fetchTenants,
previewApproved,
setPreviewApproved,
setTenants,
getAccessToken,
setError,
setSuccessMessage,
}: TenantManagementSectionProps) {
const [searchQuery, setSearchQuery] = useState("");
const [showOnlyWaitlist, setShowOnlyWaitlist] = useState(false);
const [tenantSectionExpanded, setTenantSectionExpanded] = useState(false);
const [editingTenant, setEditingTenant] = useState<TenantConfiguration | null>(null);
const [savingTenant, setSavingTenant] = useState(false);
const [togglingSecurityBypassTenant, setTogglingSecurityBypassTenant] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(0);
const tenantsPerPage = tenantSectionExpanded ? 7 : 3;
// Preview Whitelist state
const [togglingPreviewTenant, setTogglingPreviewTenant] = useState<string | null>(null);
// Filter and sort tenants
const filteredTenants = tenants.filter(t => {
const matchesSearch =
t.tenantId.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.domainName.toLowerCase().includes(searchQuery.toLowerCase());
const matchesWaitlist = !showOnlyWaitlist || !previewApproved.has(t.tenantId);
return matchesSearch && matchesWaitlist;
});
// Statistics (always over all tenants, not filtered)
const readyCount = tenants.filter(t => t.validateAutopilotDevice).length;
const waitlistCount = tenants.filter(t => !previewApproved.has(t.tenantId)).length;
const totalCount = tenants.length;
// Pagination
const totalPages = Math.ceil(filteredTenants.length / tenantsPerPage);
const startIndex = currentPage * tenantsPerPage;
const endIndex = startIndex + tenantsPerPage;
const paginatedTenants = filteredTenants.slice(startIndex, endIndex);
// Reset to first page when search changes
useEffect(() => {
setCurrentPage(0);
}, [searchQuery, showOnlyWaitlist]);
const handleSaveTenant = async (tenant: TenantConfiguration) => {
try {
setSavingTenant(true);
setError(null);
setSuccessMessage(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const response = await fetch(`${API_BASE_URL}/api/config/${tenant.tenantId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(tenant),
});
if (!response.ok) {
throw new Error(`Failed to save tenant configuration: ${response.statusText}`);
}
const result = await response.json();
// Update tenant in list
setTenants(prev => prev.map(t => t.tenantId === tenant.tenantId ? result.config : t));
setEditingTenant(null);
setSuccessMessage(`Tenant ${tenant.tenantId} configuration saved successfully!`);
// Auto-hide success message after 3 seconds
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
console.error("Error saving tenant configuration:", err);
setError(err instanceof Error ? err.message : "Failed to save tenant configuration");
} finally {
setSavingTenant(false);
}
};
const handleToggleSecurityBypass = async (tenant: TenantConfiguration) => {
try {
setTogglingSecurityBypassTenant(tenant.tenantId);
setError(null);
setSuccessMessage(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const newValue = !tenant.allowInsecureAgentRequests;
const response = await fetch(`${API_BASE_URL}/api/config/${tenant.tenantId}/security-bypass`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ allowInsecureAgentRequests: newValue }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to update security bypass: ${response.statusText}`);
}
setTenants(prev => prev.map(t =>
t.tenantId === tenant.tenantId
? { ...t, allowInsecureAgentRequests: newValue }
: t
));
setEditingTenant(prev => prev && prev.tenantId === tenant.tenantId
? { ...prev, allowInsecureAgentRequests: newValue }
: prev);
setSuccessMessage(
newValue
? `Security bypass enabled for tenant ${tenant.tenantId}.`
: `Security bypass disabled for tenant ${tenant.tenantId}.`
);
setTimeout(() => setSuccessMessage(null), 4000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update security bypass");
} finally {
setTogglingSecurityBypassTenant(null);
}
};
const handleTogglePreview = async (tenantId: string) => {
try {
setTogglingPreviewTenant(tenantId);
setError(null);
setSuccessMessage(null);
const token = await getAccessToken();
if (!token) {
throw new Error('Failed to get access token');
}
const isCurrentlyApproved = previewApproved.has(tenantId);
const response = await fetch(`${API_BASE_URL}/api/preview-whitelist/${tenantId}`, {
method: isCurrentlyApproved ? "DELETE" : "POST",
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to update preview access: ${response.statusText}`);
}
setPreviewApproved(prev => {
const next = new Set(prev);
if (isCurrentlyApproved) {
next.delete(tenantId);
} else {
next.add(tenantId);
}
return next;
});
setSuccessMessage(
isCurrentlyApproved
? `Preview access revoked for tenant ${tenantId}`
: `Preview access granted for tenant ${tenantId}`
);
setTimeout(() => setSuccessMessage(null), 4000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update preview access");
} finally {
setTogglingPreviewTenant(null);
}
};
return (
<>
<div className="bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-lg shadow-lg">
<div
className="p-6 border-b border-green-200 bg-gradient-to-r from-green-100 to-emerald-100 cursor-pointer select-none"
onClick={() => { setTenantSectionExpanded(v => !v); setCurrentPage(0); }}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<div>
<h2 className="text-xl font-semibold text-green-900">Tenant Management</h2>
<p className="text-sm text-green-600 mt-1">View and manage all tenant configurations</p>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={(e) => { e.stopPropagation(); fetchTenants(); }}
disabled={loadingTenants}
className="p-1.5 rounded-lg text-green-700 hover:bg-green-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Refresh tenants"
>
<svg className={`w-4 h-4 ${loadingTenants ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<svg
className={`w-5 h-5 text-green-700 transition-transform duration-200 ${tenantSectionExpanded ? '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>
</div>
<div className="p-6">
{loadingTenants ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600 mx-auto"></div>
<p className="mt-3 text-gray-600 text-sm">Loading tenants...</p>
</div>
) : (
<div className="space-y-4">
{/* Search */}
<div className="flex items-center justify-between space-x-2 mb-4">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search by domain or tenant ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
<button
onClick={() => { setShowOnlyWaitlist(v => !v); setCurrentPage(0); }}
className={`flex items-center space-x-1 px-3 py-2 text-sm rounded-lg border transition-colors whitespace-nowrap ${
showOnlyWaitlist
? 'bg-amber-500 text-white border-amber-500'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
{showOnlyWaitlist && (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)}
<span>Waitlist</span>
</button>
<span className="text-sm text-gray-600 whitespace-nowrap">{filteredTenants.length} tenant(s)</span>
</div>
{/* Tenant List */}
<div className="space-y-3">
{paginatedTenants.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{showOnlyWaitlist ? "No waitlist tenants found" : searchQuery ? "No tenants found matching your search" : "No tenants registered yet"}
</div>
) : (
<>
{paginatedTenants.map((tenant) => (
<div
key={tenant.tenantId}
className={`border rounded-lg p-4 transition-all ${
tenant.disabled
? 'bg-red-50 border-red-300'
: 'bg-white border-gray-200 hover:border-green-300'
}`}
>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div>
<h3 className="font-semibold text-gray-900 text-lg">
{tenant.domainName || tenant.tenantId}
</h3>
<p className="text-sm text-gray-500 mt-0.5">
Tenant ID: {tenant.tenantId}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{tenant.disabled && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
Suspended
</span>
)}
{previewApproved.has(tenant.tenantId) ? (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
Preview
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
Waitlist
</span>
)}
{tenant.validateAutopilotDevice && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Ready
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleTogglePreview(tenant.tenantId)}
disabled={togglingPreviewTenant === tenant.tenantId}
className={`px-3 py-2 text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
previewApproved.has(tenant.tenantId)
? 'bg-amber-500 text-white hover:bg-amber-600'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{togglingPreviewTenant === tenant.tenantId
? "..."
: previewApproved.has(tenant.tenantId)
? "Revoke"
: "Approve"}
</button>
<button
onClick={() => {
setEditingTenant(tenant);
}}
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Edit
</button>
</div>
</div>
</div>
</div>
))}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
<button
onClick={() => setCurrentPage(p => Math.max(0, p - 1))}
disabled={currentPage === 0}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {currentPage + 1} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages - 1, p + 1))}
disabled={currentPage >= totalPages - 1}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
{/* Statistics */}
{totalCount > 0 && (
<div className="pt-3 border-t border-green-200 flex items-center justify-between gap-4 text-sm text-gray-600 flex-wrap">
<span>
<span className="font-semibold text-blue-700">{readyCount}</span>
{' '}of{' '}
<span className="font-semibold">{totalCount}</span>
{' '}Tenant(s) are Ready
</span>
<span>
<span className="font-semibold text-amber-600">{waitlistCount}</span>
{' '}of{' '}
<span className="font-semibold">{totalCount}</span>
{' '}Tenant(s) are on the Waitlist
</span>
</div>
)}
</div>
)}
</div>
</div>
{/* Edit Tenant Modal */}
{editingTenant && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-green-600 text-white p-6 rounded-t-lg">
<h2 className="text-2xl font-bold">Edit Tenant Configuration</h2>
<p className="text-green-100 text-sm mt-1">{editingTenant.tenantId}</p>
</div>
<div className="p-6 space-y-6">
{/* Tenant Suspension */}
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h3 className="font-semibold text-red-900 mb-3">Tenant Suspension</h3>
<div className="space-y-3">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={editingTenant.disabled}
onChange={(e) => setEditingTenant({ ...editingTenant, disabled: e.target.checked })}
className="w-4 h-4 text-red-600 border-gray-300 rounded focus:ring-red-500"
/>
<span className="text-sm font-medium text-gray-700">Suspend Tenant</span>
</label>
{editingTenant.disabled && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Reason</label>
<input
type="text"
value={editingTenant.disabledReason || ''}
onChange={(e) => setEditingTenant({ ...editingTenant, disabledReason: e.target.value })}
placeholder="Optional: Why is this tenant suspended?"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Disabled Until</label>
<input
type="datetime-local"
value={editingTenant.disabledUntil ? new Date(editingTenant.disabledUntil).toISOString().slice(0, 16) : ''}
onChange={(e) => setEditingTenant({ ...editingTenant, disabledUntil: e.target.value ? new Date(e.target.value).toISOString() : undefined })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
<p className="text-xs text-gray-500 mt-1">Optional: Auto-enable after this date/time</p>
</div>
</>
)}
</div>
</div>
{/* Admin Users Info */}
<TenantAdminSection
tenantId={editingTenant.tenantId}
getAccessToken={getAccessToken}
setError={setError}
setSuccessMessage={setSuccessMessage}
/>
{/* Rate Limit */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rate Limit (Requests/Min)</label>
<input
type="number"
min="1"
max="1000"
value={editingTenant.rateLimitRequestsPerMinute}
onChange={(e) => setEditingTenant({ ...editingTenant, rateLimitRequestsPerMinute: parseInt(e.target.value) || 100 })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
{/* Hardware Whitelist */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Manufacturer Whitelist</label>
<input
type="text"
value={editingTenant.manufacturerWhitelist}
onChange={(e) => setEditingTenant({ ...editingTenant, manufacturerWhitelist: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Model Whitelist</label>
<input
type="text"
value={editingTenant.modelWhitelist}
onChange={(e) => setEditingTenant({ ...editingTenant, modelWhitelist: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={editingTenant.validateAutopilotDevice}
onChange={(e) => setEditingTenant({ ...editingTenant, validateAutopilotDevice: e.target.checked })}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">Autopilot Device Validation</span>
</label>
<div className="border border-amber-300 bg-amber-50 rounded-lg p-3">
<p className="text-sm font-semibold text-amber-900">Galactic Admin Test Bypass</p>
<p className="text-xs text-amber-800 mt-1">
Allows agent requests even when Autopilot device validation is disabled. Use only for temporary test tenants.
</p>
<div className="mt-3 flex items-center justify-between">
<span className={`text-xs font-medium ${editingTenant.allowInsecureAgentRequests ? "text-red-700" : "text-green-700"}`}>
{editingTenant.allowInsecureAgentRequests ? "Bypass is ENABLED" : "Bypass is DISABLED"}
</span>
<button
onClick={() => handleToggleSecurityBypass(editingTenant)}
disabled={togglingSecurityBypassTenant === editingTenant.tenantId}
className={`px-3 py-1.5 text-xs font-medium rounded-md text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
editingTenant.allowInsecureAgentRequests
? "bg-red-600 hover:bg-red-700"
: "bg-amber-600 hover:bg-amber-700"
}`}
>
{togglingSecurityBypassTenant === editingTenant.tenantId
? "Updating..."
: editingTenant.allowInsecureAgentRequests
? "Disable Bypass"
: "Enable Bypass"}
</button>
</div>
</div>
{/* Data Management */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Data Retention (Days)</label>
<input
type="number"
min="7"
max="3650"
value={editingTenant.dataRetentionDays}
onChange={(e) => setEditingTenant({ ...editingTenant, dataRetentionDays: parseInt(e.target.value) || 90 })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Session Timeout (Hours)</label>
<input
type="number"
min="1"
max="48"
value={editingTenant.sessionTimeoutHours}
onChange={(e) => setEditingTenant({ ...editingTenant, sessionTimeoutHours: parseInt(e.target.value) || 5 })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max NDJSON Payload (MB)</label>
<input
type="number"
min="1"
max="50"
value={editingTenant.maxNdjsonPayloadSizeMB}
onChange={(e) => setEditingTenant({ ...editingTenant, maxNdjsonPayloadSizeMB: parseInt(e.target.value) || 5 })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
</div>
{/* Modal Actions */}
<div className="sticky bottom-0 bg-gray-50 px-6 py-4 border-t border-gray-200 rounded-b-lg flex justify-end space-x-3">
<button
onClick={() => setEditingTenant(null)}
disabled={savingTenant}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => handleSaveTenant(editingTenant)}
disabled={savingTenant}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 flex items-center space-x-2"
>
{savingTenant ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Saving...</span>
</>
) : (
<span>Save Changes</span>
)}
</button>
</div>
</div>
</div>
)}
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,11 @@ import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
import { downloadAsJson, stripInternalFields, bumpVersion } from "@/lib/rulePageHelpers";
import { StatCard } from "@/components/rules/StatCard";
import { RuleFilterBar } from "@/components/rules/RuleFilterBar";
import { EmptyState } from "@/components/rules/EmptyState";
import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJsonToggle";
interface RelatedDoc {
title: string;
@@ -103,22 +108,6 @@ const CATEGORY_COLORS: Record<string, { bg: string; text: string }> = {
enrollment: { bg: "bg-indigo-100", text: "text-indigo-700" },
};
function downloadAsJson(data: unknown, filename: string) {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function stripInternalFields(rule: AnalyzeRule) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { isBuiltIn, isCommunity, createdAt, updatedAt, ...rest } = rule;
return rest;
}
function getSeverityColor(severity: string) {
return SEVERITY_COLORS[severity.toLowerCase()] || SEVERITY_COLORS.info;
@@ -166,13 +155,6 @@ const EMPTY_FORM: RuleForm = {
relatedDocs: [],
};
function bumpVersion(v: string): string {
const parts = (v ?? "1.0").split(".");
const major = parts[0] ?? "1";
const minor = parseInt(parts[1] ?? "0", 10);
return `${major}.${minor + 1}`;
}
function ruleToForm(rule: AnalyzeRule): RuleForm {
return {
ruleId: rule.ruleId,
@@ -811,81 +793,58 @@ export default function AnalyzeRulesPage() {
<div className="space-y-6">
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
<div className="bg-white rounded-lg shadow p-4">
<p className="text-sm text-gray-500">Total</p>
<p className="text-2xl font-bold text-gray-900">{totalRules}</p>
</div>
<div className="bg-white rounded-lg shadow p-4">
<p className="text-sm text-gray-500">Active</p>
<p className="text-2xl font-bold text-green-600">{activeRules}</p>
</div>
<div className="bg-white rounded-lg shadow p-4 border-l-4 border-red-400">
<p className="text-sm text-gray-500">Critical</p>
<p className="text-2xl font-bold text-red-600">{criticalCount}</p>
</div>
<div className="bg-white rounded-lg shadow p-4 border-l-4 border-orange-400">
<p className="text-sm text-gray-500">High</p>
<p className="text-2xl font-bold text-orange-600">{highCount}</p>
</div>
<div className="bg-white rounded-lg shadow p-4 border-l-4 border-yellow-400">
<p className="text-sm text-gray-500">Warning</p>
<p className="text-2xl font-bold text-yellow-600">{warningCount}</p>
</div>
<div className="bg-white rounded-lg shadow p-4 border-l-4 border-blue-400">
<p className="text-sm text-gray-500">Info</p>
<p className="text-2xl font-bold text-blue-600">{infoCount}</p>
</div>
<StatCard label="Total" value={totalRules} />
<StatCard label="Active" value={activeRules} valueColor="text-green-600" />
<StatCard label="Critical" value={criticalCount} borderColor="border-red-400" valueColor="text-red-600" />
<StatCard label="High" value={highCount} borderColor="border-orange-400" valueColor="text-orange-600" />
<StatCard label="Warning" value={warningCount} borderColor="border-yellow-400" valueColor="text-yellow-600" />
<StatCard label="Info" value={infoCount} borderColor="border-blue-400" valueColor="text-blue-600" />
</div>
{/* Filter Bar + Create Button */}
<div className="bg-white rounded-lg shadow p-4">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex flex-col md:flex-row md:items-center md:space-x-4 space-y-3 md:space-y-0 flex-1">
<div className="flex-1 relative">
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
<input type="text" placeholder="Search by title or rule ID..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
{searchQuery && (
<button onClick={() => setSearchQuery("")} className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600" title="Clear search">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
)}
</div>
<select value={severityFilter} onChange={(e) => setSeverityFilter(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<option value="all">All Severities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<option value="all">All Categories</option>
{uniqueCategories.map((cat) => (<option key={cat} value={cat}>{cat.charAt(0).toUpperCase() + cat.slice(1)}</option>))}
</select>
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<option value="all">All Types</option>
<option value="builtin">Built-in</option>
<option value="community">Community</option>
<option value="custom">Custom</option>
</select>
</div>
<div className="flex items-center gap-2">
<button onClick={handleExportAll} className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center space-x-2 text-sm font-medium whitespace-nowrap" title="Export all visible rules as JSON">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
<span>Export All</span>
</button>
<button onClick={() => { setShowCreateForm(!showCreateForm); if (showCreateForm) setNewRule({ ...EMPTY_FORM, conditions: [{ ...EMPTY_CONDITION }] }); }} className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center space-x-2 text-sm font-medium whitespace-nowrap">
{showCreateForm ? (
<><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg><span>Cancel</span></>
) : (
<><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg><span>Create Custom Rule</span></>
)}
</button>
</div>
</div>
<div className="mt-3 text-sm text-gray-500">
Showing {filteredRules.length} of {totalRules} rule{totalRules !== 1 ? "s" : ""}
</div>
</div>
<RuleFilterBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="Search by title or rule ID..."
filters={[
{
label: "Severity",
value: severityFilter,
onChange: setSeverityFilter,
options: [
{ value: "all", label: "All Severities" },
{ value: "critical", label: "Critical" },
{ value: "high", label: "High" },
{ value: "warning", label: "Warning" },
{ value: "info", label: "Info" },
],
},
{
label: "Category",
value: categoryFilter,
onChange: setCategoryFilter,
options: [
{ value: "all", label: "All Categories" },
...uniqueCategories.map((cat) => ({ value: cat, label: cat.charAt(0).toUpperCase() + cat.slice(1) })),
],
},
{
label: "Type",
value: typeFilter,
onChange: setTypeFilter,
options: [
{ value: "all", label: "All Types" },
{ value: "builtin", label: "Built-in" },
{ value: "community", label: "Community" },
{ value: "custom", label: "Custom" },
],
},
]}
onExportAll={handleExportAll}
onCreateNew={() => { setShowCreateForm(!showCreateForm); if (showCreateForm) setNewRule({ ...EMPTY_FORM, conditions: [{ ...EMPTY_CONDITION }] }); }}
createLabel="Create Custom Rule"
showCreateForm={showCreateForm}
/>
{/* Create Custom Rule Form */}
{showCreateForm && (
@@ -900,41 +859,43 @@ export default function AnalyzeRulesPage() {
</div>
</div>
{/* JSON Mode Toggle */}
<div className="flex items-center bg-gray-100 rounded-lg p-1">
<button onClick={() => { setJsonModeCreate(false); setJsonError(null); }} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${!jsonModeCreate ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}>Form</button>
<button onClick={() => { setJsonText(JSON.stringify(newRule, null, 2)); setJsonModeCreate(true); setJsonError(null); }} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${jsonModeCreate ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}>JSON</button>
</div>
<JsonModeToggleButtons
jsonMode={jsonModeCreate}
onToggleMode={(mode) => {
if (mode) { setJsonText(JSON.stringify(newRule, null, 2)); }
setJsonModeCreate(mode);
setJsonError(null);
}}
/>
</div>
</div>
<div className="p-6">
{jsonModeCreate ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">Edit the rule as JSON. All fields are supported including <code className="bg-gray-100 px-1 rounded text-xs">event_correlation</code> condition properties.</p>
<button type="button" onClick={() => {
try {
const parsed = JSON.parse(jsonText) as RuleForm;
if (!parsed.ruleId && !parsed.title) throw new Error("JSON must include at least ruleId and title");
setNewRule({ ...EMPTY_FORM, ...parsed });
setJsonModeCreate(false);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
}} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium whitespace-nowrap ml-4">Apply JSON →</button>
</div>
{jsonError && <p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{jsonError}</p>}
<textarea
value={jsonText}
onChange={(e) => { setJsonText(e.target.value); setJsonError(null); }}
rows={30}
spellCheck={false}
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm font-mono text-gray-900 bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-y"
/>
</div>
) : (
renderFormFields(newRule, setNewRule, true)
)}
<FormJsonToggle
jsonMode={jsonModeCreate}
onToggleMode={(mode) => {
if (mode) { setJsonText(JSON.stringify(newRule, null, 2)); }
setJsonModeCreate(mode);
setJsonError(null);
}}
jsonText={jsonText}
onJsonTextChange={(text) => { setJsonText(text); setJsonError(null); }}
jsonError={jsonError}
onApplyJson={() => {
try {
const parsed = JSON.parse(jsonText) as RuleForm;
if (!parsed.ruleId && !parsed.title) throw new Error("JSON must include at least ruleId and title");
setNewRule({ ...EMPTY_FORM, ...parsed });
setJsonModeCreate(false);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
}}
textareaRows={30}
description='Edit the rule as JSON. All fields are supported including <code class="bg-gray-100 px-1 rounded text-xs">event_correlation</code> condition properties.'
>
{renderFormFields(newRule, setNewRule, true)}
</FormJsonToggle>
<div className="flex items-center justify-end space-x-3 pt-4 mt-5 border-t border-gray-200">
<button onClick={() => { setShowCreateForm(false); setJsonModeCreate(false); setJsonError(null); setNewRule({ ...EMPTY_FORM, conditions: [{ ...EMPTY_CONDITION }] }); }} disabled={creating} 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">Cancel</button>
<button onClick={() => {
@@ -959,10 +920,11 @@ export default function AnalyzeRulesPage() {
{/* Rules List */}
{filteredRules.length === 0 ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<p className="text-gray-500">{rules.length === 0 ? "No analyze rules found." : "No rules match your current filters."}</p>
</div>
<EmptyState
message={rules.length === 0 ? "No analyze rules found." : "No rules match your current filters."}
onClearFilters={() => { setSearchQuery(""); setSeverityFilter("all"); setCategoryFilter("all"); setTypeFilter("all"); }}
showClearButton={!!(searchQuery || severityFilter !== "all" || categoryFilter !== "all" || typeFilter !== "all")}
/>
) : (
<div className="space-y-3">
{filteredRules.map((rule) => {
@@ -1146,42 +1108,42 @@ export default function AnalyzeRulesPage() {
</h4>
</div>
{/* JSON Mode Toggle */}
<div className="flex items-center bg-gray-100 rounded-lg p-1">
<button onClick={() => { setJsonModeEdit(false); setJsonError(null); }} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${!jsonModeEdit ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}>Form</button>
<button onClick={() => { setJsonText(JSON.stringify(editForm, null, 2)); setJsonModeEdit(true); setJsonError(null); }} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${jsonModeEdit ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}>JSON</button>
</div>
<JsonModeToggleButtons
jsonMode={jsonModeEdit}
onToggleMode={(mode) => {
if (mode) { setJsonText(JSON.stringify(editForm, null, 2)); }
setJsonModeEdit(mode);
setJsonError(null);
}}
/>
</div>
{jsonModeEdit ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">Edit the rule as JSON. All fields are supported including <code className="bg-gray-100 px-1 rounded text-xs">event_correlation</code> condition properties.</p>
<button type="button" onClick={() => {
try {
const parsed = JSON.parse(jsonText) as RuleForm;
if (!parsed.title) throw new Error("JSON must include title");
setEditForm({ ...editForm, ...parsed });
setJsonModeEdit(false);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
}} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium whitespace-nowrap ml-4">Apply JSON →</button>
</div>
{jsonError && <p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{jsonError}</p>}
<textarea
value={jsonText}
onChange={(e) => { setJsonText(e.target.value); setJsonError(null); }}
rows={30}
spellCheck={false}
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm font-mono text-gray-900 bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-y"
/>
</div>
) : (
renderFormFields(editForm, setEditForm, false)
)}
<FormJsonToggle
jsonMode={jsonModeEdit}
onToggleMode={(mode) => {
if (mode) { setJsonText(JSON.stringify(editForm, null, 2)); }
setJsonModeEdit(mode);
setJsonError(null);
}}
jsonText={jsonText}
onJsonTextChange={(text) => { setJsonText(text); setJsonError(null); }}
jsonError={jsonError}
onApplyJson={() => {
try {
const parsed = JSON.parse(jsonText) as RuleForm;
if (!parsed.title) throw new Error("JSON must include title");
setEditForm({ ...editForm, ...parsed });
setJsonModeEdit(false);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
}}
textareaRows={30}
description='Edit the rule as JSON. All fields are supported including <code class="bg-gray-100 px-1 rounded text-xs">event_correlation</code> condition properties.'
>
{renderFormFields(editForm, setEditForm, false)}
</FormJsonToggle>
<div className="flex items-center justify-end space-x-3 pt-4 mt-5 border-t border-gray-200">
<button onClick={() => { setEditingRuleId(null); setJsonModeEdit(false); setJsonError(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">Cancel</button>
@@ -7,6 +7,11 @@ import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
import { downloadAsJson, stripInternalFields, bumpVersion } from "@/lib/rulePageHelpers";
import { StatCard } from "@/components/rules/StatCard";
import { RuleFilterBar } from "@/components/rules/RuleFilterBar";
import { EmptyState } from "@/components/rules/EmptyState";
import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJsonToggle";
interface GatherRule {
ruleId: string;
@@ -122,22 +127,6 @@ const EMPTY_FORM: NewRuleForm = {
outputSeverity: "info",
};
function downloadAsJson(data: unknown, filename: string) {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function stripInternalFields(rule: GatherRule) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { isBuiltIn, isCommunity, createdAt, updatedAt, ...rest } = rule;
return rest;
}
function formatTrigger(trigger: string) {
switch (trigger) {
@@ -445,14 +434,6 @@ export default function GatherRulesPage() {
throw new Error("Failed to get access token");
}
// Increment version: "1.0" → "1.1", "1.9" → "1.10", "2" → "2.1", unknown → "1.1"
const bumpVersion = (v: string): string => {
const parts = (v ?? "1.0").split(".");
const major = parts[0] ?? "1";
const minor = parseInt(parts[1] ?? "0", 10);
return `${major}.${minor + 1}`;
};
const payload: Record<string, unknown> = {
...rule,
title: formData.title.trim(),
@@ -941,124 +922,49 @@ export default function GatherRulesPage() {
{/* Summary Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Total Rules</div>
<div className="mt-1 text-2xl font-bold text-gray-900">{totalRules}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Active Rules</div>
<div className="mt-1 text-2xl font-bold text-emerald-600">{activeRules}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Built-in / Community</div>
<div className="mt-1 text-2xl font-bold text-blue-600">{builtInCount + communityCount}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Custom</div>
<div className="mt-1 text-2xl font-bold text-purple-600">{customCount}</div>
</div>
<StatCard label="Total Rules" value={totalRules} />
<StatCard label="Active Rules" value={activeRules} valueColor="text-emerald-600" />
<StatCard label="Built-in / Community" value={builtInCount + communityCount} valueColor="text-blue-600" />
<StatCard label="Custom" value={customCount} valueColor="text-purple-600" />
</div>
{/* Filter Bar + Create Button */}
<div className="bg-white rounded-lg shadow p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-col sm:flex-row gap-3 flex-1">
{/* Search */}
<div className="relative flex-1 max-w-md">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by title or rule ID..."
autoComplete="off"
className="w-full px-4 py-2 pl-10 pr-10 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"
/>
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-3 top-2.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear search"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Category Filter */}
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 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"
>
<option value="all">All Categories</option>
<option value="network">Network</option>
<option value="identity">Identity</option>
<option value="apps">Apps</option>
<option value="device">Device</option>
<option value="esp">ESP</option>
<option value="enrollment">Enrollment</option>
</select>
{/* Type Filter */}
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 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"
>
<option value="all">All Types</option>
<option value="builtin">Built-in</option>
<option value="community">Community</option>
<option value="custom">Custom</option>
</select>
</div>
<div className="flex items-center gap-2">
{/* Export All Button */}
<button
onClick={handleExportAll}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center space-x-2 text-sm font-medium whitespace-nowrap"
title="Export all visible rules as JSON"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<span>Export All</span>
</button>
{/* Create Button */}
<button
onClick={() => {
setShowCreateForm(!showCreateForm);
if (showCreateForm) {
setNewRule({ ...EMPTY_FORM });
}
}}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center space-x-2 text-sm font-medium whitespace-nowrap"
>
{showCreateForm ? (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>Cancel</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span>Create Custom Rule</span>
</>
)}
</button>
</div>
</div>
</div>
<RuleFilterBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="Search by title or rule ID..."
filters={[
{
label: "Category",
value: categoryFilter,
onChange: setCategoryFilter,
options: [
{ value: "all", label: "All Categories" },
{ value: "network", label: "Network" },
{ value: "identity", label: "Identity" },
{ value: "apps", label: "Apps" },
{ value: "device", label: "Device" },
{ value: "esp", label: "ESP" },
{ value: "enrollment", label: "Enrollment" },
],
},
{
label: "Type",
value: typeFilter,
onChange: setTypeFilter,
options: [
{ value: "all", label: "All Types" },
{ value: "builtin", label: "Built-in" },
{ value: "community", label: "Community" },
{ value: "custom", label: "Custom" },
],
},
]}
onExportAll={handleExportAll}
onCreateNew={() => { setShowCreateForm(!showCreateForm); if (showCreateForm) setNewRule({ ...EMPTY_FORM }); }}
createLabel="Create Custom Rule"
showCreateForm={showCreateForm}
/>
{/* Create Custom Rule Form */}
{showCreateForm && (
@@ -1075,41 +981,43 @@ export default function GatherRulesPage() {
</div>
</div>
{/* JSON Mode Toggle */}
<div className="flex items-center bg-gray-100 rounded-lg p-1">
<button onClick={() => { setJsonModeCreate(false); setJsonError(null); }} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${!jsonModeCreate ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}>Form</button>
<button onClick={() => { setJsonText(JSON.stringify(newRule, null, 2)); setJsonModeCreate(true); setJsonError(null); }} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${jsonModeCreate ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}>JSON</button>
</div>
<JsonModeToggleButtons
jsonMode={jsonModeCreate}
onToggleMode={(mode) => {
if (mode) { setJsonText(JSON.stringify(newRule, null, 2)); }
setJsonModeCreate(mode);
setJsonError(null);
}}
/>
</div>
</div>
<div className="p-6">
{jsonModeCreate ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">Edit the rule as JSON. All fields are supported including <code className="bg-gray-100 px-1 rounded text-xs">parameters</code> for collector-specific options.</p>
<button type="button" onClick={() => {
try {
const parsed = JSON.parse(jsonText) as NewRuleForm;
if (!parsed.ruleId && !parsed.title) throw new Error("JSON must include at least ruleId and title");
setNewRule({ ...EMPTY_FORM, ...parsed });
setJsonModeCreate(false);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
}} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium whitespace-nowrap ml-4">Apply JSON &rarr;</button>
</div>
{jsonError && <p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{jsonError}</p>}
<textarea
value={jsonText}
onChange={(e) => { setJsonText(e.target.value); setJsonError(null); }}
rows={20}
spellCheck={false}
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm font-mono text-gray-900 bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-y"
/>
</div>
) : (
renderFormFields(newRule, setNewRule, true)
)}
<FormJsonToggle
jsonMode={jsonModeCreate}
onToggleMode={(mode) => {
if (mode) { setJsonText(JSON.stringify(newRule, null, 2)); }
setJsonModeCreate(mode);
setJsonError(null);
}}
jsonText={jsonText}
onJsonTextChange={(text) => { setJsonText(text); setJsonError(null); }}
jsonError={jsonError}
onApplyJson={() => {
try {
const parsed = JSON.parse(jsonText) as NewRuleForm;
if (!parsed.ruleId && !parsed.title) throw new Error("JSON must include at least ruleId and title");
setNewRule({ ...EMPTY_FORM, ...parsed });
setJsonModeCreate(false);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
}}
textareaRows={20}
description='Edit the rule as JSON. All fields are supported including <code class="bg-gray-100 px-1 rounded text-xs">parameters</code> for collector-specific options.'
>
{renderFormFields(newRule, setNewRule, true)}
</FormJsonToggle>
{/* Action Buttons */}
<div className="flex items-center justify-end space-x-3 pt-4 mt-5 border-t border-gray-200">
@@ -1169,24 +1077,11 @@ export default function GatherRulesPage() {
</div>
{filteredRules.length === 0 ? (
<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>
<p className="text-gray-500">No rules match your filters.</p>
{(searchQuery || categoryFilter !== "all" || typeFilter !== "all") && (
<button
onClick={() => {
setSearchQuery("");
setCategoryFilter("all");
setTypeFilter("all");
}}
className="mt-2 text-sm text-indigo-600 hover:text-indigo-800 transition-colors"
>
Clear all filters
</button>
)}
</div>
<EmptyState
message="No rules match your filters."
onClearFilters={() => { setSearchQuery(""); setCategoryFilter("all"); setTypeFilter("all"); }}
showClearButton={!!(searchQuery || categoryFilter !== "all" || typeFilter !== "all")}
/>
) : (
filteredRules.map((rule) => {
const isExpanded = expandedRuleId === rule.ruleId;
@@ -1480,40 +1375,42 @@ export default function GatherRulesPage() {
</h4>
</div>
{/* JSON Mode Toggle */}
<div className="flex items-center bg-gray-100 rounded-lg p-1">
<button onClick={() => { setJsonModeEdit(false); setJsonError(null); }} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${!jsonModeEdit ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}>Form</button>
<button onClick={() => { setJsonText(JSON.stringify(editForm, null, 2)); setJsonModeEdit(true); setJsonError(null); }} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${jsonModeEdit ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}>JSON</button>
</div>
<JsonModeToggleButtons
jsonMode={jsonModeEdit}
onToggleMode={(mode) => {
if (mode) { setJsonText(JSON.stringify(editForm, null, 2)); }
setJsonModeEdit(mode);
setJsonError(null);
}}
/>
</div>
{jsonModeEdit ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">Edit the rule as JSON. All fields are supported including <code className="bg-gray-100 px-1 rounded text-xs">parameters</code> for collector-specific options.</p>
<button type="button" onClick={() => {
try {
const parsed = JSON.parse(jsonText) as NewRuleForm;
if (!parsed.title) throw new Error("JSON must include title");
setEditForm({ ...editForm, ...parsed });
setJsonModeEdit(false);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
}} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium whitespace-nowrap ml-4">Apply JSON &rarr;</button>
</div>
{jsonError && <p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{jsonError}</p>}
<textarea
value={jsonText}
onChange={(e) => { setJsonText(e.target.value); setJsonError(null); }}
rows={20}
spellCheck={false}
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm font-mono text-gray-900 bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-y"
/>
</div>
) : (
renderFormFields(editForm, setEditForm, false)
)}
<FormJsonToggle
jsonMode={jsonModeEdit}
onToggleMode={(mode) => {
if (mode) { setJsonText(JSON.stringify(editForm, null, 2)); }
setJsonModeEdit(mode);
setJsonError(null);
}}
jsonText={jsonText}
onJsonTextChange={(text) => { setJsonText(text); setJsonError(null); }}
jsonError={jsonError}
onApplyJson={() => {
try {
const parsed = JSON.parse(jsonText) as NewRuleForm;
if (!parsed.title) throw new Error("JSON must include title");
setEditForm({ ...editForm, ...parsed });
setJsonModeEdit(false);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
}}
textareaRows={20}
description='Edit the rule as JSON. All fields are supported including <code class="bg-gray-100 px-1 rounded text-xs">parameters</code> for collector-specific options.'
>
{renderFormFields(editForm, setEditForm, false)}
</FormJsonToggle>
{/* Action Buttons */}
<div className="flex items-center justify-end space-x-3 pt-4 mt-5 border-t border-gray-200">
@@ -7,6 +7,11 @@ import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
import { downloadAsJson, stripInternalFields } from "@/lib/rulePageHelpers";
import { StatCard } from "@/components/rules/StatCard";
import { RuleFilterBar } from "@/components/rules/RuleFilterBar";
import { EmptyState } from "@/components/rules/EmptyState";
import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJsonToggle";
interface ImeLogPattern {
patternId: string;
@@ -82,22 +87,6 @@ const EMPTY_FORM: PatternForm = {
enabled: true,
};
function downloadAsJson(data: unknown, filename: string) {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function stripInternalFields(pattern: ImeLogPattern) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { isBuiltIn, ...rest } = pattern;
return rest;
}
export default function ImeLogPatternsPage() {
const router = useRouter();
@@ -413,120 +402,50 @@ export default function ImeLogPatternsPage() {
{/* Summary Stats */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Total Patterns</div>
<div className="mt-1 text-2xl font-bold text-gray-900">{totalPatterns}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Active</div>
<div className="mt-1 text-2xl font-bold text-emerald-600">{activePatterns}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Always</div>
<div className="mt-1 text-2xl font-bold text-emerald-600">{alwaysCount}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Current Phase</div>
<div className="mt-1 text-2xl font-bold text-blue-600">{currentPhaseCount}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm font-medium text-gray-500">Other Phases</div>
<div className="mt-1 text-2xl font-bold text-purple-600">{otherPhasesCount}</div>
</div>
<StatCard label="Total Patterns" value={totalPatterns} />
<StatCard label="Active" value={activePatterns} valueColor="text-emerald-600" />
<StatCard label="Always" value={alwaysCount} valueColor="text-emerald-600" />
<StatCard label="Current Phase" value={currentPhaseCount} valueColor="text-blue-600" />
<StatCard label="Other Phases" value={otherPhasesCount} valueColor="text-purple-600" />
</div>
{/* Filter Bar + Export */}
<div className="bg-white rounded-lg shadow p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-col sm:flex-row gap-3 flex-1">
{/* Search */}
<div className="relative flex-1 max-w-md">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by pattern ID, regex, action..."
autoComplete="off"
className="w-full px-4 py-2 pl-10 pr-10 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"
/>
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-3 top-2.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear search"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Category Filter */}
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 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"
>
<option value="all">All Categories</option>
<option value="always">Always</option>
<option value="currentPhase">Current Phase</option>
<option value="otherPhases">Other Phases</option>
</select>
{/* Action Filter */}
<select
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="px-3 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"
>
<option value="all">All Actions</option>
{uniqueActions.map((a) => (
<option key={a} value={a}>{ACTION_LABELS[a] || a}</option>
))}
</select>
</div>
{/* Export All Button */}
<button
onClick={handleExportAll}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center space-x-2 text-sm font-medium whitespace-nowrap"
title="Export all visible patterns as JSON"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<span>Export All ({filteredPatterns.length})</span>
</button>
</div>
</div>
<RuleFilterBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="Search by pattern ID, regex, action..."
filters={[
{
label: "Category",
value: categoryFilter,
onChange: setCategoryFilter,
options: [
{ value: "all", label: "All Categories" },
{ value: "always", label: "Always" },
{ value: "currentPhase", label: "Current Phase" },
{ value: "otherPhases", label: "Other Phases" },
],
},
{
label: "Action",
value: actionFilter,
onChange: setActionFilter,
options: [
{ value: "all", label: "All Actions" },
...uniqueActions.map((a) => ({ value: a, label: ACTION_LABELS[a] || a })),
],
},
]}
onExportAll={handleExportAll}
/>
{/* Patterns List */}
{filteredPatterns.length === 0 ? (
<div className="bg-white rounded-lg shadow p-8 text-center">
<svg className="w-12 h-12 text-gray-400 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="mt-4 text-lg font-medium text-gray-900">No patterns found</h3>
<p className="mt-2 text-sm text-gray-500">
Try adjusting your search or filter criteria.
</p>
{(searchQuery || categoryFilter !== "all" || actionFilter !== "all") && (
<button
onClick={() => {
setSearchQuery("");
setCategoryFilter("all");
setActionFilter("all");
}}
className="mt-2 text-sm text-indigo-600 hover:text-indigo-800 transition-colors"
>
Clear all filters
</button>
)}
</div>
<EmptyState
message="Try adjusting your search or filter criteria."
onClearFilters={() => { setSearchQuery(""); setCategoryFilter("all"); setActionFilter("all"); }}
showClearButton={!!(searchQuery || categoryFilter !== "all" || actionFilter !== "all")}
/>
) : (
filteredPatterns.map((pattern) => {
const isExpanded = expandedPatternId === pattern.patternId;
@@ -746,10 +665,14 @@ export default function ImeLogPatternsPage() {
</h4>
</div>
{/* JSON Mode Toggle */}
<div className="flex items-center bg-gray-100 rounded-lg p-1">
<button onClick={() => { setJsonModeEdit(false); setJsonError(null); }} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${!jsonModeEdit ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}>Form</button>
<button onClick={() => { setJsonText(JSON.stringify(editForm, null, 2)); setJsonModeEdit(true); setJsonError(null); }} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${jsonModeEdit ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}>JSON</button>
</div>
<JsonModeToggleButtons
jsonMode={jsonModeEdit}
onToggleMode={(mode) => {
if (mode) { setJsonText(JSON.stringify(editForm, null, 2)); }
setJsonModeEdit(mode);
setJsonError(null);
}}
/>
</div>
<div className="mb-4 bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-800">
@@ -762,32 +685,30 @@ export default function ImeLogPatternsPage() {
</div>
)}
{jsonModeEdit ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">Edit the pattern as JSON.</p>
<button type="button" onClick={() => {
try {
const parsed = JSON.parse(jsonText) as PatternForm;
if (!parsed.pattern) throw new Error("JSON must include pattern");
setEditForm({ ...editForm, ...parsed });
setJsonModeEdit(false);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
}} className="text-xs text-indigo-600 hover:text-indigo-800 font-medium whitespace-nowrap ml-4">Apply JSON &rarr;</button>
</div>
{jsonError && <p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{jsonError}</p>}
<textarea
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
rows={15}
className="w-full font-mono text-sm border border-gray-300 rounded-lg p-3 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-gray-900"
spellCheck={false}
/>
</div>
) : (
<FormJsonToggle
jsonMode={jsonModeEdit}
onToggleMode={(mode) => {
if (mode) { setJsonText(JSON.stringify(editForm, null, 2)); }
setJsonModeEdit(mode);
setJsonError(null);
}}
jsonText={jsonText}
onJsonTextChange={(text) => setJsonText(text)}
jsonError={jsonError}
onApplyJson={() => {
try {
const parsed = JSON.parse(jsonText) as PatternForm;
if (!parsed.pattern) throw new Error("JSON must include pattern");
setEditForm({ ...editForm, ...parsed });
setJsonModeEdit(false);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
}
}}
textareaRows={15}
description="Edit the pattern as JSON."
>
<div className="space-y-4">
{/* Category */}
<div>
@@ -926,7 +847,7 @@ export default function ImeLogPatternsPage() {
</div>
</div>
</div>
)}
</FormJsonToggle>
{/* Save/Cancel buttons */}
<div className="flex items-center justify-end space-x-3 mt-6 pt-4 border-t border-gray-200">
@@ -0,0 +1,212 @@
"use client";
import { useState } from "react";
import { RuleResult } from "../page";
interface AnalysisResultsSectionProps {
analysisResults: RuleResult[];
loadingAnalysis: boolean;
analysisExpanded: boolean;
setAnalysisExpanded: (expanded: boolean) => void;
onReanalyze: () => void;
}
export default function AnalysisResultsSection({
analysisResults,
loadingAnalysis,
analysisExpanded,
setAnalysisExpanded,
onReanalyze,
}: AnalysisResultsSectionProps) {
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<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} {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">
<button
onClick={(e) => { e.stopPropagation(); onReanalyze(); }}
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"
>
{loadingAnalysis ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span>Analyzing...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>Analyze Now</span>
</>
)}
</button>
<span className="text-gray-400">{analysisExpanded ? '▼' : '▶'}</span>
</div>
</div>
{analysisExpanded && (
<>
{loadingAnalysis && analysisResults.length === 0 ? (
<div className="text-center py-4 text-gray-500">Running analysis...</div>
) : analysisResults.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm">
No issues detected yet. Click &quot;Analyze Now&quot; to run analysis on the current events, or wait for enrollment to complete for automatic analysis.
</div>
) : (
<div className="space-y-3">
{analysisResults.map((result) => (
<AnalysisResultCard key={result.ruleId} result={result} />
))}
</div>
)}
</>
)}
</div>
);
}
function AnalysisResultCard({ result }: { result: RuleResult }) {
const [expanded, setExpanded] = useState(false);
const severityColors: Record<string, string> = {
critical: "border-l-red-600 bg-red-50",
high: "border-l-orange-500 bg-orange-50",
warning: "border-l-yellow-500 bg-yellow-50",
info: "border-l-blue-500 bg-blue-50",
};
const severityBadgeColors: Record<string, string> = {
critical: "bg-red-100 text-red-800",
high: "bg-orange-100 text-orange-800",
warning: "bg-yellow-100 text-yellow-800",
info: "bg-blue-100 text-blue-800",
};
const cardColor = severityColors[result.severity] || severityColors.info;
const badgeColor = severityBadgeColors[result.severity] || severityBadgeColors.info;
return (
<div className={`border-l-4 rounded-lg p-4 ${cardColor}`}>
<div className="flex items-start justify-between cursor-pointer" onClick={() => setExpanded(!expanded)}>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${badgeColor}`}>
{result.severity.toUpperCase()}
</span>
<span className="text-xs font-mono text-gray-500">{result.ruleId}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{result.category}</span>
</div>
<h3 className="font-medium text-gray-900">{result.ruleTitle}</h3>
<div className="flex items-center mt-1 space-x-3">
<div className="flex items-center space-x-1">
<span className="text-xs text-gray-500">Confidence:</span>
<div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
result.confidenceScore >= 80 ? 'bg-red-500' :
result.confidenceScore >= 60 ? 'bg-orange-500' :
result.confidenceScore >= 40 ? 'bg-yellow-500' : 'bg-blue-500'
}`}
style={{ width: `${result.confidenceScore}%` }}
/>
</div>
<span className="text-xs font-medium text-gray-700">{result.confidenceScore}%</span>
</div>
</div>
</div>
<span className="text-gray-400 ml-2">{expanded ? '▼' : '▶'}</span>
</div>
{expanded && (
<div className="mt-4 space-y-3">
{result.explanation && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-1">Explanation</h4>
<p className="text-sm text-gray-600">{result.explanation}</p>
</div>
)}
{result.remediation && result.remediation.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-1">Remediation</h4>
{result.remediation.map((rem, i) => (
<div key={i} className="mb-2">
<p className="text-sm font-medium text-gray-600">{rem.title}</p>
<ul className="list-disc list-inside text-sm text-gray-600 ml-2">
{rem.steps.map((step, j) => (
<li key={j}>{step}</li>
))}
</ul>
</div>
))}
</div>
)}
{result.relatedDocs && result.relatedDocs.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-1">Related Documentation</h4>
<div className="flex flex-wrap gap-2">
{result.relatedDocs.map((doc, i) => (
<a
key={i}
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 underline"
>
{doc.title}
</a>
))}
</div>
</div>
)}
{result.matchedConditions && Object.keys(result.matchedConditions).length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-1">Evidence</h4>
<div className="p-2 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
<pre>{JSON.stringify(result.matchedConditions, null, 2)}</pre>
</div>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,366 @@
"use client";
import { useState, useMemo } from "react";
import { EnrollmentEvent, Session } from "../page";
import { normalizeEventDataForDisplay } from "../utils/eventHelpers";
interface EventTimelineProps {
filteredEvents: EnrollmentEvent[];
events: EnrollmentEvent[];
session: Session | null;
severityFilters: Set<string>;
toggleSeverityFilter: (severity: string) => void;
expandedPhases: Set<string>;
togglePhase: (phaseName: string) => void;
timelineExpanded: boolean;
setTimelineExpanded: (expanded: boolean) => void;
expandAll: () => void;
collapseAll: () => void;
isWhiteGloveSession: boolean;
whiteGloveSplitSequence: number;
orderedPhases: string[];
eventsByPhase: Record<string, EnrollmentEvent[]>;
preProvGrouped: { eventsByPhase: Record<string, EnrollmentEvent[]>; orderedPhases: string[] };
userEnrollGrouped: { eventsByPhase: Record<string, EnrollmentEvent[]>; orderedPhases: string[] };
userEnrollEvents: EnrollmentEvent[];
isGalacticAdmin?: boolean;
}
export default function EventTimeline({
filteredEvents,
events,
session,
severityFilters,
toggleSeverityFilter,
expandedPhases,
togglePhase,
timelineExpanded,
setTimelineExpanded,
expandAll,
collapseAll,
isWhiteGloveSession,
whiteGloveSplitSequence,
orderedPhases,
eventsByPhase,
preProvGrouped,
userEnrollGrouped,
userEnrollEvents,
isGalacticAdmin,
}: EventTimelineProps) {
return (
<>
{/* Severity filters + Expand/Collapse — shared controls above the timeline(s) */}
<div className="flex flex-wrap 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">({filteredEvents.length}/{events.length})</span>
<div className="flex gap-1.5 ml-auto">
<button
onClick={expandAll}
title="Expand All"
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-50 text-blue-700 hover:bg-blue-100 rounded transition-colors"
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<span className="hidden sm:inline">Expand All</span>
</button>
<button
onClick={collapseAll}
title="Collapse All"
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-50 text-gray-700 hover:bg-gray-100 rounded transition-colors"
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
<span className="hidden sm:inline">Collapse All</span>
</button>
</div>
</div>
{/* Timeline — split for WhiteGlove sessions, single card otherwise */}
{isWhiteGloveSession ? (
<>
{/* Pre-Provisioning Part */}
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center gap-3 mb-6">
<h2 className="text-xl font-semibold text-gray-900">Pre-Provisioning Part</h2>
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-amber-100 text-amber-800">WhiteGlove</span>
</div>
{preProvGrouped.orderedPhases.length === 0 ? (
<div className="text-gray-500 text-center py-8">No events found.</div>
) : (
<div className="space-y-8">
{preProvGrouped.orderedPhases.map((phaseName) => (
<PhaseSection
key={`pre-${phaseName}`}
phaseName={phaseName}
events={preProvGrouped.eventsByPhase[phaseName]}
isExpanded={expandedPhases.has(`pre-${phaseName}`)}
onToggle={() => togglePhase(`pre-${phaseName}`)}
isGalacticAdmin={isGalacticAdmin}
/>
))}
</div>
)}
</div>
{/* User Enrollment Part */}
{userEnrollEvents.length > 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center gap-3 mb-6">
<h2 className="text-xl font-semibold text-gray-900">User Enrollment Part</h2>
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">Resumed</span>
</div>
{userEnrollGrouped.orderedPhases.length === 0 ? (
<div className="text-gray-500 text-center py-8">No events found.</div>
) : (
<div className="space-y-8">
{userEnrollGrouped.orderedPhases.map((phaseName) => (
<PhaseSection
key={`user-${phaseName}`}
phaseName={phaseName}
events={userEnrollGrouped.eventsByPhase[phaseName]}
isExpanded={expandedPhases.has(`user-${phaseName}`)}
onToggle={() => togglePhase(`user-${phaseName}`)}
isGalacticAdmin={isGalacticAdmin}
/>
))}
</div>
)}
</div>
) : session?.status === 'Pending' ? (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6 text-center">
<p className="text-amber-800 font-medium mb-1">Awaiting User Enrollment</p>
<p className="text-amber-600 text-sm">
Pre-provisioning is complete. The timeline will continue when the user powers on the device.
</p>
</div>
) : null}
</>
) : (
/* Original single-timeline card */
<div className="bg-white shadow rounded-lg p-6">
<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>
<span className="text-gray-400">{timelineExpanded ? '▼' : '▶'}</span>
</button>
{timelineExpanded && (
<>
{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)}
isGalacticAdmin={isGalacticAdmin}
/>
))}
</div>
)}
</>
)}
</div>
)}
</>
);
}
function PhaseSection({
phaseName,
events,
isExpanded,
onToggle,
isGalacticAdmin
}: {
phaseName: string;
events: EnrollmentEvent[];
isExpanded: boolean;
onToggle: () => void;
isGalacticAdmin?: boolean;
}) {
return (
<div className="border-l-4 border-blue-500 pl-4">
<button
onClick={onToggle}
className="flex items-center justify-between w-full text-left mb-3 group"
>
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600">
{phaseName} ({events.length} events)
</h3>
<span className="text-gray-400">{isExpanded ? '▼' : '▶'}</span>
</button>
{isExpanded && (
<div className="space-y-3">
{events.map((event, index) => (
<EventRow
key={event.eventId || `${event.sessionId}-${event.sequence}`}
event={event}
isGalacticAdmin={isGalacticAdmin}
/>
))}
</div>
)}
</div>
);
}
function EventRow({ event, isGalacticAdmin }: { event: EnrollmentEvent; isGalacticAdmin?: boolean }) {
const [showDetails, setShowDetails] = useState(false);
const [showRaw, setShowRaw] = useState(false);
const [copied, setCopied] = useState(false);
const detailData = useMemo(() => normalizeEventDataForDisplay(event.data), [event.data]);
// Gather rule console output detection — use source, not eventType,
// because users can name gather rule event types freely.
const isGatherEvent = event.source === "GatherRuleExecutor";
const gatherOutput = isGatherEvent
? ((detailData?.output ?? detailData?.Output) as string | null | undefined) ?? null
: null;
const gatherCommand = isGatherEvent
? ((detailData?.command ?? detailData?.Command) as string | null | undefined) ?? null
: null;
const gatherExitCode = isGatherEvent
? ((detailData?.exit_code ?? detailData?.exitCode) as number | null | undefined) ?? null
: null;
const hasGatherOutput = gatherOutput != null && gatherOutput !== "";
const formattedOutput = hasGatherOutput
? gatherOutput.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
: null;
const copyEventId = async () => {
try {
await navigator.clipboard.writeText(event.eventId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy EventID:', err);
}
};
const hasDetails = detailData && Object.keys(detailData).length > 0;
return (
<div className="bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 font-mono">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
<SeverityBadge severity={event.severity} />
<span className="text-sm font-medium text-gray-900">{event.eventType}</span>
</div>
<p className="mt-1 text-sm text-gray-600">{event.message}</p>
<div className="mt-1 flex items-center gap-3 text-xs text-gray-500">
<span>Source: {event.source}</span>
<span>Seq: {event.sequence}</span>
{isGalacticAdmin && (
<button
onClick={copyEventId}
className="font-mono hover:text-blue-600 cursor-pointer transition-colors"
title={copied ? 'Copied!' : `Click to copy full EventId: ${event.eventId}`}
>
EventId: {event.eventId.substring(0, 8)}... {copied && '✓'}
</button>
)}
</div>
</div>
{hasDetails && (
<button
onClick={() => setShowDetails(!showDetails)}
className="text-xs text-blue-600 hover:text-blue-800 ml-4 flex-shrink-0"
>
{showDetails ? 'Hide' : hasGatherOutput ? 'Output' : 'Details'}
</button>
)}
</div>
{/* Gather rule: terminal-style output block */}
{showDetails && hasGatherOutput && (
<div className="mt-3">
{gatherCommand && (
<div className="flex items-center gap-1.5 mb-1.5 text-xs font-mono text-gray-600">
<span className="text-gray-400 select-none">$</span>
<span>{gatherCommand}</span>
</div>
)}
<div className="bg-gray-900 rounded-lg overflow-hidden">
<div className="px-3 py-2 max-h-96 overflow-y-auto overflow-x-auto">
<pre className="text-xs text-gray-100 font-mono whitespace-pre">{formattedOutput}</pre>
</div>
</div>
<div className="mt-1.5 flex items-center justify-between">
{gatherExitCode !== null ? (
<span className={`text-xs font-mono ${gatherExitCode === 0 ? 'text-green-600' : 'text-red-600'}`}>
exit {gatherExitCode}
</span>
) : <span />}
<button
onClick={() => setShowRaw(!showRaw)}
className="text-xs text-gray-400 hover:text-gray-600"
>
{showRaw ? 'hide raw' : 'raw JSON'}
</button>
</div>
{showRaw && (
<div className="mt-2 p-3 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
<pre>{JSON.stringify(detailData, null, 2)}</pre>
</div>
)}
</div>
)}
{/* Non-gather (or gather without output): raw JSON details */}
{showDetails && !hasGatherOutput && detailData && (
<div className="mt-3 p-3 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
<pre>{JSON.stringify(detailData, null, 2)}</pre>
</div>
)}
</div>
);
}
function SeverityBadge({ severity }: { severity: string }) {
const colors = {
Info: "bg-blue-100 text-blue-800",
Warning: "bg-yellow-100 text-yellow-800",
Error: "bg-red-100 text-red-800",
Critical: "bg-red-200 text-red-900"
};
const color = colors[severity as keyof typeof colors] || colors.Info;
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${color}`}>
{severity}
</span>
);
}
@@ -0,0 +1,50 @@
"use client";
import { Session } from "../page";
interface MarkFailedModalProps {
show: boolean;
session: Session | null;
onConfirm: () => void;
onCancel: () => void;
}
export default function MarkFailedModal({ show, session, onConfirm, onCancel }: MarkFailedModalProps) {
if (!show || !session) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={onCancel}>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 className="ml-4 text-lg font-semibold text-gray-900">Mark Session as Failed</h3>
</div>
<div className="mb-6">
<p className="text-sm text-gray-700 mb-2">
You are about to manually mark this session as <span className="font-semibold text-red-600">Failed</span>.
</p>
<p className="text-sm text-gray-700 mb-2">
Session <span className="font-mono text-xs">{session.sessionId}</span> for device <span className="font-semibold">{session.deviceName || session.serialNumber}</span> will be marked as failed with the reason "Manually marked as failed by administrator".
</p>
<p className="text-sm text-gray-600">
This action will update the session status and cannot be undone. Do you want to continue?
</p>
</div>
<div className="flex justify-end gap-3">
<button onClick={onCancel} className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors">
Cancel
</button>
<button onClick={onConfirm} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors">
Mark as Failed
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,320 @@
"use client";
import { EnrollmentEvent } from "../page";
import { V1_PHASES, V2_PHASES } from "../utils/phaseConstants";
interface PhaseTimelineProps {
currentPhase: number;
completedPhases: number[];
events?: EnrollmentEvent[];
sessionStatus?: string;
enrollmentType?: string;
}
export default function PhaseTimeline({ currentPhase, completedPhases, events = [], sessionStatus, enrollmentType }: PhaseTimelineProps) {
const phases = enrollmentType === "v2" ? V2_PHASES : V1_PHASES;
// Derive current activity for the active phase from events
const getCurrentActivity = (phaseId: number): string | null => {
if (sessionStatus === 'Succeeded' || sessionStatus === 'Failed') return null;
if (phaseId !== effectiveCurrentPhase) return null;
// Event types that are not useful as activity status display
const ignoredEventTypes = new Set([
"performance_snapshot",
"system_info",
"network_info",
]);
// Get events for this phase, sorted by sequence desc, excluding noise
const phaseEvents = events
.filter(e => e.phase === phaseId && !ignoredEventTypes.has(e.eventType))
.sort((a, b) => b.sequence - a.sequence);
if (phaseEvents.length === 0) return null;
// Check for app_tracking_summary events (new strategic events)
const trackingSummary = phaseEvents.find(e => e.eventType === "app_tracking_summary");
if (trackingSummary?.data) {
const d = trackingSummary.data;
const completed = parseInt(d.appsCompleted ?? "0", 10);
const total = parseInt(d.totalApps ?? "0", 10);
if (total > 0) {
return `Installing apps (${completed}/${total})`;
}
}
// Check for esp_ui_state events (legacy) to show app install progress
const espState = phaseEvents.find(e => e.eventType === "esp_ui_state");
if (espState?.data) {
const d = espState.data;
const completed = parseInt(d.blocking_apps_completed ?? d.blockingAppsCompleted ?? "0", 10);
const total = parseInt(d.blocking_apps_total ?? d.blockingAppsTotal ?? "0", 10);
const currentItem = d.current_item ?? d.currentItem ?? d.status_text ?? d.statusText;
if (total > 0 && currentItem) {
return `${currentItem} (${completed}/${total})`;
}
if (total > 0) {
return `Installing apps (${completed}/${total})`;
}
}
// Check for app install events (new strategic events)
const appInstallEvt = phaseEvents.find(e =>
e.eventType === "app_download_started" || e.eventType === "app_install_started"
);
if (appInstallEvt?.data) {
const appName = appInstallEvt.data.appName ?? appInstallEvt.data.appId ?? "app";
if (appInstallEvt.eventType === "app_download_started") return `Downloading ${appName}`;
return `Installing ${appName}`;
}
// Check for download_progress to show active download (legacy)
const downloadEvt = phaseEvents.find(e => e.eventType === "download_progress");
if (downloadEvt?.data) {
const d = downloadEvt.data;
const appName = d.app_name ?? d.appName ?? "content";
const pct = d.bytes_total && d.bytes_downloaded
? Math.round((parseInt(d.bytes_downloaded) / parseInt(d.bytes_total)) * 100)
: null;
if (pct !== null) return `Downloading ${appName} - ${pct}%`;
return `Downloading ${appName}`;
}
// Fall back to latest event message
const latest = phaseEvents[0];
if (latest && latest.message && latest.message.length < 80) {
return latest.message;
}
return null;
};
// Helper: get first/last event timestamp for a phase
const getFirstEventTime = (phaseId: number): number | null => {
const ts = events
.filter(e => e.phase === phaseId)
.map(e => new Date(e.timestamp).getTime());
return ts.length > 0 ? Math.min(...ts) : null;
};
const getLastEventTime = (phaseId: number): number | null => {
const ts = events
.filter(e => e.phase === phaseId)
.map(e => new Date(e.timestamp).getTime());
return ts.length > 0 ? Math.max(...ts) : null;
};
const formatDuration = (ms: number): string | null => {
const durationSec = Math.round(ms / 1000);
if (durationSec < 5) return null;
if (durationSec < 60) return `${durationSec}s`;
if (durationSec < 3600) return `${Math.floor(durationSec / 60)}m ${durationSec % 60}s`;
return `${Math.floor(durationSec / 3600)}h ${Math.floor((durationSec % 3600) / 60)}m`;
};
// Calculate phase durations using phase-transition timestamps
// Parent phases (Device Setup, Account Setup) span until the next major phase starts
// Sub-phases (Apps Device, Apps User) show only their own event range
const getPhaseDuration = (phaseId: number): string | null => {
const isV2 = enrollmentType === "v2";
// No timing for Start and Device Preparation (agent not active yet)
if (phaseId === 0 || phaseId === 1) return null;
// No timing for Complete
if (phaseId === 7) return null;
if (isV2) {
// V2: simpler phases — no sub-phase nesting
// Phase 3 (App Installation): first phase 3 event → first phase 6 event (or last phase 3)
// Phase 6 (Finalizing): first phase 6 event → first phase 7 event (or last phase 6)
const start = getFirstEventTime(phaseId);
if (!start) return null;
const nextPhaseMap: Record<number, number> = { 3: 6, 6: 7 };
const nextPhase = nextPhaseMap[phaseId];
const end = (nextPhase !== undefined ? getFirstEventTime(nextPhase) : null) ?? getLastEventTime(phaseId);
if (!end || end <= start) return null;
return formatDuration(end - start);
}
// V1 phase-transition-based durations:
// Device Setup (2): first phase 2 → first phase 4 (includes Apps Device sub-phase)
// Apps Device (3): first phase 3 → last phase 3 (sub-phase only)
// Account Setup (4): first phase 4 → first phase 6 (includes Apps User sub-phase)
// Apps User (5): first phase 5 → last phase 5 (sub-phase only)
// Finalizing (6): first phase 6 → first phase 7 (or last phase 6)
const start = getFirstEventTime(phaseId);
if (!start) return null;
let end: number | null = null;
switch (phaseId) {
case 2: // Device Setup → ends when Account Setup starts
end = getFirstEventTime(4) ?? getLastEventTime(3) ?? getLastEventTime(2);
break;
case 3: // Apps (Device) — sub-phase, own event range
end = getLastEventTime(3);
break;
case 4: // Account Setup → ends when Finalizing starts
end = getFirstEventTime(6) ?? getLastEventTime(5) ?? getLastEventTime(4);
break;
case 5: // Apps (User) — sub-phase, own event range
end = getLastEventTime(5);
break;
case 6: // Finalizing → ends when Complete starts
end = getFirstEventTime(7) ?? getLastEventTime(6);
break;
default:
end = getLastEventTime(phaseId);
break;
}
if (!end || end <= start) return null;
return formatDuration(end - start);
};
// Check if a phase is a sub-phase (Apps Device / Apps User in V1)
const isSubPhase = (phaseId: number): boolean => {
if (enrollmentType === "v2") return false;
return phaseId === 3 || phaseId === 5;
};
// Derive the highest real phase (0-7) seen in events, excluding phase 99 (Failed)
const maxEventPhase = (() => {
const realPhases = events
.filter(e => e.phase >= 0 && e.phase <= 7)
.map(e => e.phase);
if (realPhases.length === 0) return -1;
return Math.max(...realPhases);
})();
// Determine the actual failure phase from events when session has failed
// currentPhase=99 means "Failed" but we need to know WHICH phase it failed in
const failurePhase = (() => {
if (sessionStatus !== 'Failed' || currentPhase !== 99) return null;
return maxEventPhase >= 0 ? maxEventPhase : 0;
})();
// Effective current phase: use the max of backend currentPhase and what events show.
// This prevents the tracker from lagging behind the timeline when events from a new
// phase arrive via SignalR before the backend updates session.currentPhase.
const effectiveCurrentPhase = (() => {
if (sessionStatus === 'Succeeded') return currentPhase;
if (sessionStatus === 'Failed') return failurePhase !== null ? failurePhase : currentPhase;
// In-progress: take the higher of backend phase and events phase
if (maxEventPhase < 0) return currentPhase;
return Math.max(currentPhase, maxEventPhase);
})();
const getPhaseStatus = (phaseId: number) => {
// Agent starts at MDM phase (3) - Pre-Flight(0)/Network(1)/Identity(2) are inferred as completed
// since the machine reached MDM enrollment
if (phaseId >= 0 && phaseId <= 2) return 'completed'; // Pre-Flight(0), Network(1), Identity(2)
// If phase is completed, show as completed (green)
if (completedPhases.includes(phaseId)) return 'completed';
// Handle failed sessions
if (sessionStatus === 'Failed' && failurePhase !== null) {
if (phaseId === failurePhase) return 'failed';
if (phaseId < failurePhase) return 'completed';
return 'pending';
}
// Normal in-progress logic (using effectiveCurrentPhase to stay in sync with events)
if (phaseId === effectiveCurrentPhase) return 'current';
if (phaseId < effectiveCurrentPhase) return 'completed';
return 'pending';
};
const getPhaseColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-500 text-white border-green-500';
case 'current': return 'bg-blue-500 text-white border-blue-500 ring-4 ring-blue-200';
case 'failed': return 'bg-red-500 text-white border-red-500 ring-4 ring-red-200';
case 'pending': return 'bg-gray-200 text-gray-500 border-gray-300';
default: return 'bg-gray-200 text-gray-500 border-gray-300';
}
};
const getConnectorColor = (fromPhase: number) => {
const fromStatus = getPhaseStatus(fromPhase);
if (fromStatus === 'completed') return 'bg-green-500';
if (fromStatus === 'failed') return 'bg-red-500';
return 'bg-gray-300';
};
return (
<div className="w-full py-4">
<div className="flex w-full">
{phases.map((phase, index) => {
const status = getPhaseStatus(phase.id);
const prevStatus = index > 0 ? getPhaseStatus(phases[index - 1].id) : null;
const connColor = index > 0 ? getConnectorColor(phases[index - 1].id) : '';
const showArrow = prevStatus === 'current' || prevStatus === 'failed';
return (
<div key={phase.id} className="flex-1 relative flex flex-col items-center min-w-0">
{/* Connector line from previous phase center to this phase center */}
{index > 0 && (
<div
className={`absolute h-1 ${connColor}`}
style={{ top: '22px', left: '-50%', right: '50%' }}
/>
)}
{/* Arrow when previous phase is current or failed */}
{index > 0 && showArrow && (
<div
className="absolute z-20"
style={{ top: '16px', left: 'calc(50% - 36px)' }}
>
<div
className={`w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[12px] ${
connColor === 'bg-green-500' ? 'border-l-green-500' :
connColor === 'bg-red-500' ? 'border-l-red-500' : 'border-l-gray-300'
}`}
style={{
filter: connColor === 'bg-green-500'
? 'drop-shadow(0 0 3px rgba(34, 197, 94, 0.5))'
: connColor === 'bg-red-500'
? 'drop-shadow(0 0 3px rgba(239, 68, 68, 0.5))'
: 'drop-shadow(0 0 3px rgba(209, 213, 219, 0.5))'
}}
/>
</div>
)}
{/* Circle - centered, on top of connector */}
<div className={`relative z-10 w-12 h-12 rounded-full flex items-center justify-center border-2 transition-all font-semibold ${getPhaseColor(status)}`}>
{status === 'completed' ? '✓' : status === 'failed' ? '✕' : phase.id + 1}
</div>
{/* Labels - centered below circle */}
<div className="mt-3 text-center">
<div className="text-xs font-medium text-gray-700 whitespace-nowrap">
{phase.shortName}
</div>
{(status === 'completed' || status === 'failed') && getPhaseDuration(phase.id) && (
<div className={`mt-0.5 text-[10px] ${
status === 'failed' ? 'text-red-400' :
isSubPhase(phase.id) ? 'text-gray-400 italic' : 'text-gray-500 font-medium'
}`}>
{isSubPhase(phase.id) ? `(${getPhaseDuration(phase.id)})` : getPhaseDuration(phase.id)}
</div>
)}
{status === 'failed' && (
<div className="mt-0.5 text-[10px] text-red-500 font-semibold">Failed</div>
)}
{status === 'current' && getCurrentActivity(phase.id) && (
<div className="mt-1 max-w-[140px]">
<div className="text-[10px] text-blue-600 font-medium line-clamp-2 animate-pulse" title={getCurrentActivity(phase.id) || undefined}>
{getCurrentActivity(phase.id)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import { Session } from "../page";
interface SessionInfoCardProps {
session: Session;
enrollmentDuration: string | null;
displayStatus: string;
isGatherRulesSession: boolean;
}
export default function SessionInfoCard({ session, enrollmentDuration, displayStatus, isGatherRulesSession }: SessionInfoCardProps) {
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900">Session Info</h2>
{isGatherRulesSession && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-violet-100 text-violet-800 border border-violet-200">
<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="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>
Gather Rules Collection
</span>
)}
</div>
{isGatherRulesSession && (
<div className="mb-4 p-3 bg-violet-50 border border-violet-200 rounded-lg text-sm text-violet-800">
This session was created by running <code className="font-mono text-xs bg-violet-100 px-1 py-0.5 rounded">--run-gather-rules</code>. It contains diagnostic data collected outside of a regular enrollment flow.
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<InfoItem
label="Device"
value={session.deviceName || session.serialNumber}
copyText={session.deviceName || session.serialNumber}
/>
<InfoItem
label="Model"
value={`${session.manufacturer} ${session.model}`}
copyText={`${session.manufacturer} ${session.model}`}
/>
<InfoItem label="Serial Number" value={session.serialNumber} copyText={session.serialNumber} />
<InfoItem
label="Session ID"
value={
<span title={session.sessionId} className="cursor-default">
{session.sessionId.split("-").slice(0, 3).join("-")}…
</span>
}
copyText={session.sessionId}
/>
<InfoItem label="Status" value={<StatusBadge status={displayStatus} failureReason={session.failureReason} />} />
<InfoItem label="Started" value={new Date(session.startedAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })} />
<InfoItem label="Duration" value={enrollmentDuration ?? `${Math.round(session.durationSeconds / 60)} min`} />
<InfoItem label="Events" value={session.eventCount.toString()} />
</div>
</div>
);
}
function InfoItem({ label, value, copyText }: { label: string; value: React.ReactNode; copyText?: string }) {
const [copied, setCopied] = useState(false);
const copyValue = async () => {
if (!copyText) return;
try {
await navigator.clipboard.writeText(copyText);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
} catch (err) {
console.error("Failed to copy value:", err);
}
};
return (
<div>
<div className="text-sm font-medium text-gray-500">{label}</div>
<div className="mt-1 group flex items-center gap-1.5">
<div className="text-sm text-gray-900 break-all">{value}</div>
{copyText && (
<button
type="button"
onClick={copyValue}
title={copied ? "Copied!" : "Copy to clipboard"}
aria-label={copied ? `${label} copied` : `Copy ${label}`}
className="inline-flex items-center justify-center w-5 h-5 rounded border border-gray-200 bg-white text-gray-500 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 hover:bg-gray-50 hover:text-gray-700 transition-opacity"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.704 5.29a1 1 0 010 1.42l-7.2 7.2a1 1 0 01-1.415 0l-3.2-3.2a1 1 0 111.414-1.42l2.493 2.494 6.493-6.494a1 1 0 011.415 0z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M6 2a2 2 0 00-2 2v8a2 2 0 002 2h1v-2H6V4h7v1h2V4a2 2 0 00-2-2H6z" />
<path d="M9 7a2 2 0 00-2 2v7a2 2 0 002 2h7a2 2 0 002-2V9a2 2 0 00-2-2H9z" />
</svg>
)}
</button>
)}
</div>
</div>
);
}
function StatusBadge({ status, failureReason }: { status: string; failureReason?: string }) {
const statusConfig = {
InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" },
Pending: { color: "bg-amber-100 text-amber-800", text: "Pending" },
Succeeded: { color: "bg-green-100 text-green-800", text: "Succeeded" },
Failed: { color: "bg-red-100 text-red-800", text: "Failed" },
Unknown: { color: "bg-gray-100 text-gray-800", text: "Unknown" },
};
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.Unknown;
const isTimeout = status === "Failed" && failureReason && failureReason.toLowerCase().includes("timed out");
return (
<span
className={`px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full ${config.color}`}
title={failureReason || undefined}
>
{config.text}
{isTimeout && (
<span title={failureReason} className="inline-flex items-center">
⏱️
</span>
)}
</span>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
import { EnrollmentEvent } from "../page";
// Pure helper — groups a flat event list into phase buckets.
// Extracted from the useMemo so it can be called multiple times for WhiteGlove split timelines.
export function groupEventsByPhase(
events: EnrollmentEvent[],
phaseNamesMap: Record<number, string>,
phaseOrder: string[]
): { eventsByPhase: Record<string, EnrollmentEvent[]>; orderedPhases: string[] } {
const sortedEvents = [...events]
.sort((a, b) => a.sequence - b.sequence)
.map(e => ({ ...e, phaseName: phaseNamesMap[e.phase] ?? "Unknown" }));
const eventsByPhase: Record<string, EnrollmentEvent[]> = {};
let currentActivePhaseName = "Start";
for (const event of sortedEvents) {
let targetPhase = event.phaseName || "Unknown";
if (targetPhase !== "Unknown") {
currentActivePhaseName = targetPhase;
} else {
targetPhase = currentActivePhaseName;
}
if (!eventsByPhase[targetPhase]) eventsByPhase[targetPhase] = [];
eventsByPhase[targetPhase].push(event);
}
const orderedPhases = phaseOrder.filter(p => eventsByPhase[p]?.length > 0);
return { eventsByPhase, orderedPhases };
}
export function normalizeJsonLikeValue(value: any): any {
if (typeof value === "string") {
const trimmed = value.trim();
const looksLikeJson =
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"));
if (!looksLikeJson) return value;
try {
return normalizeJsonLikeValue(JSON.parse(value));
} catch {
return value;
}
}
if (Array.isArray(value)) {
return value.map(normalizeJsonLikeValue);
}
if (value && typeof value === "object") {
const normalized: Record<string, any> = {};
for (const [k, v] of Object.entries(value)) {
normalized[k] = normalizeJsonLikeValue(v);
}
return normalized;
}
return value;
}
export function normalizeEventDataForDisplay(data?: Record<string, any>): Record<string, any> | null {
if (!data) return null;
return normalizeJsonLikeValue(data);
}
@@ -0,0 +1,26 @@
// Phase definitions per enrollment type
export const V1_PHASES = [
{ id: 0, name: "Start", shortName: "Start" },
{ id: 1, name: "Device Preparation", shortName: "Device Preparation" },
{ id: 2, name: "Device Setup", shortName: "Device Setup" },
{ id: 3, name: "Apps (Device)", shortName: "Apps (Device)" },
{ id: 4, name: "Account Setup", shortName: "Account Setup" },
{ id: 5, name: "Apps (User)", shortName: "Apps (User)" },
{ id: 6, name: "Finalizing Setup", shortName: "Finalizing" },
{ id: 7, name: "Complete", shortName: "Complete" },
];
export const V2_PHASES = [
{ id: 0, name: "Start", shortName: "Start" },
{ id: 1, name: "Device Preparation", shortName: "Device Preparation" },
{ id: 3, name: "App Installation", shortName: "Apps" },
{ id: 6, name: "Finalizing Setup", shortName: "Finalizing" },
{ id: 7, name: "Complete", shortName: "Complete" },
];
export const V1_PHASE_ORDER = ["Start", "Device Preparation", "Device Setup", "Apps (Device)", "Account Setup", "Apps (User)", "Finalizing Setup", "Complete", "Failed"];
export const V2_PHASE_ORDER = ["Start", "Device Preparation", "App Installation", "Finalizing Setup", "Complete", "Failed"];
// Lookup by phase number — Phase 3 has different names per enrollment type
export const V1_PHASE_NAMES: Record<number, string> = { [-1]: "Unknown", 0: "Start", 1: "Device Preparation", 2: "Device Setup", 3: "Apps (Device)", 4: "Account Setup", 5: "Apps (User)", 6: "Finalizing Setup", 7: "Complete", 99: "Failed" };
export const V2_PHASE_NAMES: Record<number, string> = { [-1]: "Unknown", 0: "Start", 1: "Device Preparation", 2: "Device Setup", 3: "App Installation", 4: "Account Setup", 5: "Apps (User)", 6: "Finalizing Setup", 7: "Complete", 99: "Failed" };
@@ -0,0 +1,290 @@
"use client";
import { TenantAdmin } from "../page";
interface AdminManagementSectionProps {
admins: TenantAdmin[];
loadingAdmins: boolean;
newAdminEmail: string;
setNewAdminEmail: (value: string) => void;
addingAdmin: boolean;
removingAdmin: string | null;
togglingAdmin: string | null;
adminSearchQuery: string;
setAdminSearchQuery: (value: string) => void;
currentAdminPage: number;
setCurrentAdminPage: (value: number | ((prev: number) => number)) => void;
user: { upn?: string } | null;
onAddAdmin: () => void;
onRemoveAdmin: (upn: string) => void;
onToggleAdmin: (upn: string, isEnabled: boolean) => void;
}
export default function AdminManagementSection({
admins,
loadingAdmins,
newAdminEmail,
setNewAdminEmail,
addingAdmin,
removingAdmin,
togglingAdmin,
adminSearchQuery,
setAdminSearchQuery,
currentAdminPage,
setCurrentAdminPage,
user,
onAddAdmin,
onRemoveAdmin,
onToggleAdmin,
}: AdminManagementSectionProps) {
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-purple-50 to-indigo-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Admin Users Management</h2>
<p className="text-sm text-gray-500 mt-1">Manage who has admin access to this tenant</p>
</div>
</div>
</div>
<div className="p-6 space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-800">
<p className="font-medium">About Admin Users</p>
<p className="mt-1">
Admin users have full access to tenant configuration, all sessions, diagnostics, and settings.
Non-admin users only have access to the simplified device tracking page.
</p>
<p className="mt-2">
<strong>Your email:</strong> {user?.upn}
</p>
</div>
</div>
</div>
{/* Current Admins List */}
<div>
<label className="block mb-2">
<span className="text-gray-700 font-medium">Current Admin Users</span>
{loadingAdmins && (
<span className="ml-2 text-sm text-gray-500">(Loading...)</span>
)}
</label>
{/* Search Field */}
<div className="mb-3">
<div className="relative">
<input
type="text"
name="admin-search-field"
value={adminSearchQuery}
onChange={(e) => {
setAdminSearchQuery(e.target.value);
setCurrentAdminPage(0);
}}
placeholder="Search by email..."
autoComplete="off"
className="w-full px-4 py-2 pl-10 pr-10 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
/>
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{adminSearchQuery && (
<button
onClick={() => {
setAdminSearchQuery("");
setCurrentAdminPage(0);
}}
className="absolute right-3 top-2.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear search"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{admins.length === 0 && !loadingAdmins ? (
<div className="text-sm text-gray-500 italic">No admins found</div>
) : (
<>
{/* Filtered and Paginated Admin List */}
{(() => {
const filteredAdmins = admins.filter(admin =>
admin.upn.toLowerCase().includes(adminSearchQuery.toLowerCase())
);
if (filteredAdmins.length === 0) {
return (
<div className="text-sm text-gray-500 italic p-4 text-center bg-gray-50 rounded-lg">
No admins match your search
</div>
);
}
const adminsPerPage = 3;
const totalAdminPages = Math.ceil(filteredAdmins.length / adminsPerPage);
const startAdminIndex = currentAdminPage * adminsPerPage;
const endAdminIndex = startAdminIndex + adminsPerPage;
const paginatedAdmins = filteredAdmins.slice(startAdminIndex, endAdminIndex);
return (
<>
<div className="space-y-2">
{paginatedAdmins.map((admin) => (
<div
key={admin.upn}
className={`flex items-center justify-between p-3 border rounded-lg ${
admin.isEnabled
? "bg-gray-50 border-gray-200"
: "bg-gray-100 border-gray-300"
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<div className="font-medium text-gray-900 truncate">{admin.upn}</div>
{!admin.isEnabled && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-700">
Disabled
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
Added {new Date(admin.addedDate).toLocaleDateString()} by {admin.addedBy}
</div>
</div>
<div className="flex items-center space-x-2 ml-4">
{admin.upn.toLowerCase() === user?.upn?.toLowerCase() ? (
<span className="text-sm text-blue-600 font-medium">(You)</span>
) : (
<>
<button
onClick={() => onToggleAdmin(admin.upn, admin.isEnabled)}
disabled={togglingAdmin === admin.upn}
className={`px-3 py-1 text-sm text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
admin.isEnabled
? "bg-yellow-600 hover:bg-yellow-700"
: "bg-green-600 hover:bg-green-700"
}`}
>
{togglingAdmin === admin.upn
? "..."
: admin.isEnabled
? "Disable"
: "Enable"}
</button>
<button
onClick={() => onRemoveAdmin(admin.upn)}
disabled={removingAdmin === admin.upn}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{removingAdmin === admin.upn ? "Removing..." : "Remove"}
</button>
</>
)}
</div>
</div>
))}
</div>
{/* Pagination Controls */}
{totalAdminPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200">
<button
onClick={() => setCurrentAdminPage(prev => Math.max(0, prev - 1))}
disabled={currentAdminPage === 0}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {currentAdminPage + 1} of {totalAdminPages} ({filteredAdmins.length} admin{filteredAdmins.length !== 1 ? 's' : ''})
</span>
<button
onClick={() => setCurrentAdminPage(prev => Math.min(totalAdminPages - 1, prev + 1))}
disabled={currentAdminPage >= totalAdminPages - 1}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</>
);
})()}
</>
)}
</div>
{/* Add New Admin */}
<div>
<label className="block mb-2">
<span className="text-gray-700 font-medium">Add New Admin</span>
<p className="text-sm text-gray-500 mb-2">
Enter the user email (UPN) to grant admin access.
Example: <code className="bg-gray-100 px-1 rounded">newadmin@company.com</code>
</p>
<div className="flex space-x-2">
<input
type="email"
name="new-admin-email"
id="add-new-admin-email-input"
value={newAdminEmail}
onChange={(e) => setNewAdminEmail(e.target.value)}
placeholder="newadmin@tenant.com"
autoComplete="off"
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
onAddAdmin();
}
}}
/>
<button
onClick={onAddAdmin}
disabled={addingAdmin || !newAdminEmail.trim()}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
{addingAdmin ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Adding...</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span>Add</span>
</>
)}
</button>
</div>
</label>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start space-x-2">
<svg className="w-5 h-5 text-yellow-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-sm text-yellow-800">
<strong>Important:</strong> Make sure to include your own email in the list to maintain admin access!
The first user to log in was automatically made an admin.
</p>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,215 @@
"use client";
interface AgentSettingsSectionProps {
enablePerformanceCollector: boolean;
setEnablePerformanceCollector: (value: boolean) => void;
performanceCollectorInterval: number;
setPerformanceCollectorInterval: (value: number) => void;
selfDestructOnComplete: boolean;
setSelfDestructOnComplete: (value: boolean) => void;
keepLogFile: boolean;
setKeepLogFile: (value: boolean) => void;
rebootOnComplete: boolean;
setRebootOnComplete: (value: boolean) => void;
rebootDelaySeconds: number;
setRebootDelaySeconds: (value: number) => void;
enableGeoLocation: boolean;
setEnableGeoLocation: (value: boolean) => void;
enableImeMatchLog: boolean;
setEnableImeMatchLog: (value: boolean) => void;
logLevel: string;
setLogLevel: (value: string) => void;
}
export default function AgentSettingsSection({
enablePerformanceCollector,
setEnablePerformanceCollector,
performanceCollectorInterval,
setPerformanceCollectorInterval,
selfDestructOnComplete,
setSelfDestructOnComplete,
keepLogFile,
setKeepLogFile,
rebootOnComplete,
setRebootOnComplete,
rebootDelaySeconds,
setRebootDelaySeconds,
enableGeoLocation,
setEnableGeoLocation,
enableImeMatchLog,
setEnableImeMatchLog,
logLevel,
setLogLevel,
}: AgentSettingsSectionProps) {
return (
<>
{/* Agent Collectors */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-emerald-50 to-teal-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Agent Collectors</h2>
<p className="text-sm text-gray-500 mt-1">Enable optional data collectors on enrolled devices. These generate additional telemetry traffic.</p>
</div>
</div>
</div>
<div className="p-6 space-y-5">
{/* Performance Collector */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-medium text-gray-900">Performance Collector</p>
<span className="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">Medium Traffic</span>
</div>
<p className="text-sm text-gray-500 mt-1">CPU, memory, disk metrics at configurable intervals</p>
{enablePerformanceCollector && (
<div className="mt-2 flex items-center space-x-2">
<span className="text-sm text-gray-600">Interval:</span>
<input
type="number"
min="30"
max="300"
value={performanceCollectorInterval}
onChange={(e) => setPerformanceCollectorInterval(parseInt(e.target.value) || 60)}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-sm text-gray-500">seconds</span>
</div>
)}
</div>
<button
onClick={() => setEnablePerformanceCollector(!enablePerformanceCollector)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enablePerformanceCollector ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enablePerformanceCollector ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
</div>
{/* Agent Parameters */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-violet-50 to-purple-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Agent Parameters</h2>
<p className="text-sm text-gray-500 mt-1">Control agent behavior on enrolled devices. Changes take effect on the next agent config refresh.</p>
</div>
</div>
</div>
<div className="p-6 space-y-4">
{/* Self-Destruct */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Self-Destruct on Complete</p>
<p className="text-sm text-gray-500">Remove Scheduled Task and all agent files when enrollment completes</p>
</div>
<button onClick={() => setSelfDestructOnComplete(!selfDestructOnComplete)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${selfDestructOnComplete ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${selfDestructOnComplete ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Keep Log File */}
{selfDestructOnComplete && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Keep Log File</p>
<p className="text-sm text-gray-500">Preserve the agent log during self-destruct (all other files are removed)</p>
</div>
<button onClick={() => setKeepLogFile(!keepLogFile)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${keepLogFile ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${keepLogFile ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
)}
{/* Reboot on Complete */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Reboot on Complete</p>
<p className="text-sm text-gray-500">Reboot the device after enrollment completes (and after self-destruct if enabled)</p>
</div>
<button onClick={() => setRebootOnComplete(!rebootOnComplete)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${rebootOnComplete ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${rebootOnComplete ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{rebootOnComplete && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Reboot Delay</p>
<p className="text-sm text-gray-500">Seconds before reboot is initiated — gives the user time to see what is happening</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={3600}
value={rebootDelaySeconds}
onChange={(e) => setRebootDelaySeconds(Math.max(0, parseInt(e.target.value) || 0))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
)}
{/* Geo Location */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Geo-Location Detection</p>
<p className="text-sm text-gray-500">Capture device location, ISP and network info at enrollment start (queries external IP service)</p>
</div>
<button onClick={() => setEnableGeoLocation(!enableGeoLocation)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enableGeoLocation ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableGeoLocation ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* IME Match Log */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">IME Pattern Match Log</p>
<p className="text-sm text-gray-500">
Write every matched IME log line to a local file for diagnostics
{enableImeMatchLog && <span className="block text-xs text-gray-400 mt-0.5 font-mono">%ProgramData%\AutopilotMonitor\Logs\ime_pattern_matches.log</span>}
</p>
</div>
<button onClick={() => setEnableImeMatchLog(!enableImeMatchLog)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enableImeMatchLog ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableImeMatchLog ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Log Level */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Log Level</p>
<p className="text-sm text-gray-500">Agent log verbosity — Info for normal operation, Debug for troubleshooting, Verbose for full tracing</p>
</div>
<select
value={logLevel}
onChange={(e) => setLogLevel(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
>
<option value="Info">Info</option>
<option value="Debug">Debug</option>
<option value="Verbose">Verbose</option>
</select>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,69 @@
"use client";
interface AutopilotValidationSectionProps {
validateAutopilotDevice: boolean;
setValidateAutopilotDevice: (value: boolean) => void;
autopilotConsentInProgress: boolean;
saving: boolean;
onBeginConsent: () => void;
}
export default function AutopilotValidationSection({
validateAutopilotDevice,
setValidateAutopilotDevice,
autopilotConsentInProgress,
saving,
onBeginConsent,
}: AutopilotValidationSectionProps) {
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-gray-900">Autopilot Device Validation</h2>
<p className="text-sm text-gray-500 mt-1">Validate devices against Intune Windows Autopilot registration (mandatory for agent ingestion)</p>
</div>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${validateAutopilotDevice ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`}>
{validateAutopilotDevice ? "Enabled" : "Disabled"}
</span>
</div>
</div>
<div className="p-6 space-y-4">
<label className="flex items-start justify-between gap-4">
<div>
<p className="font-medium text-gray-900">Enable Autopilot Device Validation</p>
<p className="text-sm text-gray-500">
Enabling starts Microsoft Entra admin consent for the <strong>DeviceManagementServiceConfig.Read.All</strong> permission. After consent, the setting is saved automatically.
</p>
</div>
<button
onClick={() => {
if (validateAutopilotDevice) {
setValidateAutopilotDevice(false);
} else {
onBeginConsent();
}
}}
disabled={saving || autopilotConsentInProgress}
className={`relative inline-flex h-8 w-14 shrink-0 items-center rounded-full transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${validateAutopilotDevice ? 'bg-green-600' : 'bg-gray-300'}`}
>
<span className={`inline-block h-6 w-6 transform rounded-full bg-white transition-transform ${validateAutopilotDevice ? 'translate-x-7' : 'translate-x-1'}`} />
</button>
</label>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-900">
<strong>Important:</strong> If this is disabled, backend agent endpoints reject requests for this tenant.
Use this toggle and complete admin consent first.
</p>
</div>
{autopilotConsentInProgress && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-800">
Checking or applying admin consent...
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,66 @@
"use client";
interface DataManagementSectionProps {
dataRetentionDays: number;
setDataRetentionDays: (value: number) => void;
sessionTimeoutHours: number;
setSessionTimeoutHours: (value: number) => void;
}
export default function DataManagementSection({
dataRetentionDays,
setDataRetentionDays,
sessionTimeoutHours,
setSessionTimeoutHours,
}: DataManagementSectionProps) {
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Data Management</h2>
<p className="text-sm text-gray-500 mt-1">Configure data retention and session timeout policies</p>
</div>
<div className="p-6 space-y-6">
{/* Data Retention Days */}
<div>
<label className="block">
<span className="text-gray-700 font-medium">Data Retention Period (Days)</span>
<p className="text-sm text-gray-500 mb-2">
Sessions and events older than this will be automatically deleted by the daily maintenance job. Default: 90 days.
</p>
<input
type="number"
min="7"
max="180"
value={dataRetentionDays}
onChange={(e) => setDataRetentionDays(parseInt(e.target.value) || 90)}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Minimum: 7 days, Maximum: 180 days</p>
</label>
</div>
{/* Session Timeout Hours */}
<div>
<label className="block">
<span className="text-gray-700 font-medium">Session Timeout (Hours)</span>
<p className="text-sm text-gray-500 mb-2">
Sessions in "InProgress" status longer than this will be marked as "Failed - Timed Out".
This prevents stalled sessions from running indefinitely and skewing statistics.
<br />
<strong>Tip:</strong> Use the same value as your ESP (Enrollment Status Page) timeout for consistency.
</p>
<input
type="number"
min="1"
max="12"
value={sessionTimeoutHours}
onChange={(e) => setSessionTimeoutHours(parseInt(e.target.value) || 5)}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
<p className="text-xs text-gray-400 mt-1">Default: 5 hours (ESP default). Minimum: 1 hour, Maximum: 12 hours</p>
</label>
</div>
</div>
</div>
);
}
@@ -0,0 +1,234 @@
"use client";
import { DiagnosticsLogPath } from "../page";
/** Parses the expiry date from the `se=` parameter of a SAS URL. Returns null if not found. */
export function parseSasExpiry(sasUrl: string): Date | null {
try {
const qIndex = sasUrl.indexOf('?');
if (qIndex < 0) return null;
const params = new URLSearchParams(sasUrl.substring(qIndex + 1));
const se = params.get('se');
if (!se) return null;
const d = new Date(se);
return isNaN(d.getTime()) ? null : d;
} catch {
return null;
}
}
interface DiagnosticsSectionProps {
diagnosticsBlobSasUrl: string;
setDiagnosticsBlobSasUrl: (value: string) => void;
diagnosticsUploadMode: string;
setDiagnosticsUploadMode: (value: string) => void;
diagnosticsSasExpiry: Date | null;
tenantDiagPaths: DiagnosticsLogPath[];
setTenantDiagPaths: (value: DiagnosticsLogPath[]) => void;
globalDiagPaths: DiagnosticsLogPath[];
newDiagPath: string;
setNewDiagPath: (value: string) => void;
newDiagDesc: string;
setNewDiagDesc: (value: string) => void;
}
export default function DiagnosticsSection({
diagnosticsBlobSasUrl,
setDiagnosticsBlobSasUrl,
diagnosticsUploadMode,
setDiagnosticsUploadMode,
diagnosticsSasExpiry,
tenantDiagPaths,
setTenantDiagPaths,
globalDiagPaths,
newDiagPath,
setNewDiagPath,
newDiagDesc,
setNewDiagDesc,
}: DiagnosticsSectionProps) {
return (
<div id="diagnostics" className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Diagnostics Package</h2>
<p className="text-sm text-gray-500 mt-1">Upload diagnostic files as a ZIP package to your Azure Blob Storage after enrollment.</p>
</div>
</div>
</div>
<div className="p-6 space-y-4">
{/* Info */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-900">
The agent requests a short-lived upload URL from the backend <strong>just before uploading</strong>. Your SAS URL is stored securely in the backend and never sent to devices in the agent configuration.
</p>
</div>
{/* Blob Storage SAS URL */}
<div>
<label className="block">
<span className="text-gray-700 font-medium">Blob Storage Container SAS URL</span>
<p className="text-sm text-gray-500 mb-2">
Create an Azure Blob Storage container and generate a Container-level SAS URL with Read, Write and Create permissions.
</p>
<div className="flex items-center gap-2">
<input
type="url"
value={diagnosticsBlobSasUrl}
onChange={(e) => setDiagnosticsBlobSasUrl(e.target.value)}
placeholder="https://storageaccount.blob.core.windows.net/diagnostics?sv=...&sig=..."
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500 transition-colors font-mono text-sm"
/>
{diagnosticsBlobSasUrl && diagnosticsUploadMode !== "Off" && (
<span className="mt-1 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 whitespace-nowrap">
Active
</span>
)}
</div>
</label>
{/* SAS URL expiry indicator */}
{diagnosticsBlobSasUrl && diagnosticsSasExpiry && (() => {
const now = new Date();
const daysRemaining = Math.ceil((diagnosticsSasExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
const isExpired = daysRemaining <= 0;
const isWarning = daysRemaining > 0 && daysRemaining <= 7;
return (
<div className={`mt-2 flex items-center gap-1.5 text-xs ${isExpired ? 'text-red-600' : isWarning ? 'text-amber-600' : 'text-green-600'}`}>
{isExpired ? (
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : isWarning ? (
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
) : (
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
<span>
{isExpired
? `Expired on ${diagnosticsSasExpiry.toLocaleDateString()}`
: `Expires on ${diagnosticsSasExpiry.toLocaleDateString()}${isWarning ? ` (${daysRemaining} day${daysRemaining === 1 ? '' : 's'} remaining)` : ''}`}
</span>
</div>
);
})()}
</div>
{/* Upload Mode */}
<div className={`p-4 rounded-lg border transition-colors ${diagnosticsBlobSasUrl ? 'border-gray-200 hover:border-amber-200' : 'border-gray-100 opacity-50'}`}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Upload Mode</p>
<p className="text-sm text-gray-500">Choose when diagnostics packages are uploaded</p>
</div>
<select
value={diagnosticsUploadMode}
onChange={(e) => setDiagnosticsUploadMode(e.target.value)}
disabled={!diagnosticsBlobSasUrl}
className="px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<option value="Off">Off</option>
<option value="Always">Always</option>
<option value="OnFailure">On Failure Only</option>
</select>
</div>
</div>
{/* Additional Log Paths */}
<div className="p-4 rounded-lg border border-gray-200">
<p className="font-medium text-gray-900 mb-1">Additional Log Paths</p>
<p className="text-sm text-gray-500 mb-3">
Extra log files or wildcards included in the diagnostics ZIP. Global paths (set by your platform admin) are always included and shown below as read-only.
</p>
{/* Global paths (read-only) */}
{globalDiagPaths.length > 0 && (
<div className="mb-3">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">Global (platform-wide)</p>
<div className="space-y-1.5">
{globalDiagPaths.map((entry, idx) => (
<div key={idx} className="flex items-start justify-between bg-gray-100 border border-gray-300 rounded-lg px-3 py-2">
<div className="min-w-0 flex-1">
<p className="font-mono text-xs text-gray-700 break-all">{entry.path}</p>
{entry.description && <p className="text-xs text-gray-500 mt-0.5">{entry.description}</p>}
</div>
<span className="ml-2 flex-shrink-0 text-gray-400 bg-gray-200 rounded-full px-1.5 py-0.5 text-xs">global</span>
</div>
))}
</div>
</div>
)}
{/* Tenant paths */}
{tenantDiagPaths.length > 0 && (
<div className="mb-3">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">Your paths</p>
<div className="space-y-1.5">
{tenantDiagPaths.map((entry, idx) => (
<div key={idx} className="flex items-start justify-between bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
<div className="min-w-0 flex-1">
<p className="font-mono text-xs text-amber-900 break-all">{entry.path}</p>
{entry.description && <p className="text-xs text-amber-600 mt-0.5">{entry.description}</p>}
</div>
<button
onClick={() => setTenantDiagPaths(tenantDiagPaths.filter((_, i) => i !== idx))}
className="ml-2 flex-shrink-0 text-amber-400 hover:text-red-600 transition-colors"
title="Remove"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
{/* Add new tenant path */}
<div className="flex flex-col sm:flex-row gap-2 mt-2">
<input
type="text"
placeholder="Path or wildcard (e.g. C:\Windows\Panther\*.log)"
value={newDiagPath}
onChange={(e) => setNewDiagPath(e.target.value)}
className="flex-1 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500 font-mono"
/>
<input
type="text"
placeholder="Description (optional)"
value={newDiagDesc}
onChange={(e) => setNewDiagDesc(e.target.value)}
className="flex-1 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500"
/>
<button
onClick={() => {
const p = newDiagPath.trim();
if (!p) return;
setTenantDiagPaths([...tenantDiagPaths, { path: p, description: newDiagDesc.trim(), isBuiltIn: false }]);
setNewDiagPath("");
setNewDiagDesc("");
}}
disabled={!newDiagPath.trim()}
className="px-4 py-1.5 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
>
Add
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
Paths are validated on the agent against an allowlist of safe prefixes. Wildcards are only allowed in the last segment.
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
"use client";
interface HardwareWhitelistSectionProps {
manufacturerWhitelist: string;
setManufacturerWhitelist: (value: string) => void;
modelWhitelist: string;
setModelWhitelist: (value: string) => void;
}
export default function HardwareWhitelistSection({
manufacturerWhitelist,
setManufacturerWhitelist,
modelWhitelist,
setModelWhitelist,
}: HardwareWhitelistSectionProps) {
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Hardware Whitelist</h2>
<p className="text-sm text-gray-500 mt-1">Restrict which device manufacturers and models can enroll</p>
</div>
<div className="p-6 space-y-6">
{/* Manufacturer Whitelist */}
<div>
<label className="block">
<span className="text-gray-700 font-medium">Allowed Manufacturers</span>
<p className="text-sm text-gray-500 mb-2">
Comma-separated list. Supports wildcards: <code className="bg-gray-100 px-1 rounded">*</code> = all,
<code className="bg-gray-100 px-1 rounded ml-1">Dell*</code> = starts with "Dell"
</p>
<input
type="text"
value={manufacturerWhitelist}
onChange={(e) => setManufacturerWhitelist(e.target.value)}
placeholder="Dell*,HP*,Lenovo*,Microsoft Corporation"
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</label>
</div>
{/* Model Whitelist */}
<div>
<label className="block">
<span className="text-gray-700 font-medium">Allowed Models</span>
<p className="text-sm text-gray-500 mb-2">
Comma-separated list. Use <code className="bg-gray-100 px-1 rounded">*</code> to allow all models.
Examples: <code className="bg-gray-100 px-1 rounded">Latitude*</code>, <code className="bg-gray-100 px-1 rounded">EliteBook*</code>
</p>
<input
type="text"
value={modelWhitelist}
onChange={(e) => setModelWhitelist(e.target.value)}
placeholder="*"
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</label>
</div>
</div>
</div>
);
}
@@ -0,0 +1,133 @@
"use client";
interface OffboardingSectionProps {
showOffboardDialog: boolean;
setShowOffboardDialog: (value: boolean) => void;
offboardConfirmText: string;
setOffboardConfirmText: (value: string) => void;
offboarding: boolean;
offboardError: string | null;
setOffboardError: (value: string | null) => void;
onOffboard: () => void;
}
export default function OffboardingSection({
showOffboardDialog,
setShowOffboardDialog,
offboardConfirmText,
setOffboardConfirmText,
offboarding,
offboardError,
setOffboardError,
onOffboard,
}: OffboardingSectionProps) {
return (
<>
{/* Danger Zone: Tenant Offboarding */}
<div className="bg-white rounded-lg shadow border-2 border-red-200">
<div className="p-6 border-b border-red-100 bg-red-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-red-900">Danger Zone</h2>
<p className="text-sm text-red-600 mt-1">Irreversible and destructive actions</p>
</div>
</div>
</div>
<div className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-gray-900">Offboard this Tenant</h3>
<p className="text-sm text-gray-500 mt-1">
Permanently deletes <strong>all data</strong> for this tenant – sessions, events, rules, audit logs, configuration, and all admin accounts including yours. This cannot be undone.
</p>
</div>
<button
onClick={() => { setShowOffboardDialog(true); setOffboardConfirmText(""); setOffboardError(null); }}
className="ml-6 flex-shrink-0 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors text-sm font-medium"
>
Offboard Tenant
</button>
</div>
</div>
</div>
{/* Offboard Confirmation Dialog */}
{showOffboardDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<div className="flex items-center space-x-3 mb-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">Tenant Offboarding</h3>
<p className="text-sm text-red-600 font-medium">This action is permanent and cannot be undone.</p>
</div>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4 text-sm text-red-800 space-y-1">
<p className="font-semibold">The following will be permanently deleted:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>All enrollment sessions and events</li>
<li>All gather and analyze rules</li>
<li>Audit logs and usage metrics</li>
<li>Tenant configuration</li>
<li>All admin accounts (including yours)</li>
</ul>
<p className="mt-3 font-semibold">After offboarding you will be signed out and lose all access.</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Type <span className="font-bold text-red-600">OFFBOARD</span> to confirm
</label>
<input
type="text"
value={offboardConfirmText}
onChange={(e) => setOffboardConfirmText(e.target.value)}
placeholder="OFFBOARD"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
autoComplete="off"
/>
</div>
{offboardError && (
<div className="mb-4 bg-red-50 border border-red-200 rounded p-3 text-sm text-red-800">
{offboardError}
</div>
)}
<div className="flex space-x-3">
<button
onClick={() => { setShowOffboardDialog(false); setOffboardConfirmText(""); setOffboardError(null); }}
disabled={offboarding}
className="flex-1 px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
onClick={onOffboard}
disabled={offboarding || offboardConfirmText !== "OFFBOARD"}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center space-x-2"
>
{offboarding ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Offboarding...</span>
</>
) : (
<span>Permanently Delete All Data</span>
)}
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,92 @@
"use client";
interface TeamsNotificationsSectionProps {
teamsWebhookUrl: string;
setTeamsWebhookUrl: (value: string) => void;
teamsNotifyOnSuccess: boolean;
setTeamsNotifyOnSuccess: (value: boolean) => void;
teamsNotifyOnFailure: boolean;
setTeamsNotifyOnFailure: (value: boolean) => void;
}
export default function TeamsNotificationsSection({
teamsWebhookUrl,
setTeamsWebhookUrl,
teamsNotifyOnSuccess,
setTeamsNotifyOnSuccess,
teamsNotifyOnFailure,
setTeamsNotifyOnFailure,
}: TeamsNotificationsSectionProps) {
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-sky-50 to-blue-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-sky-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Teams Notifications</h2>
<p className="text-sm text-gray-500 mt-1">Send enrollment status notifications to a Microsoft Teams channel via Incoming Webhook.</p>
</div>
</div>
</div>
<div className="p-6 space-y-4">
{/* Webhook URL */}
<div>
<label className="block">
<span className="text-gray-700 font-medium">Incoming Webhook URL</span>
<p className="text-sm text-gray-500 mb-2">
Create an Incoming Webhook in your Teams channel (Channel → Connectors → Incoming Webhook) and paste the URL here.
</p>
<div className="flex items-center gap-2">
<input
type="url"
value={teamsWebhookUrl}
onChange={(e) => setTeamsWebhookUrl(e.target.value)}
placeholder="https://your-org.webhook.office.com/webhookb2/..."
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm"
/>
{teamsWebhookUrl && (
<span className="mt-1 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 whitespace-nowrap">
Active
</span>
)}
</div>
</label>
</div>
{/* Notify on Success */}
<div className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${teamsWebhookUrl ? 'border-gray-200 hover:border-sky-200' : 'border-gray-100 opacity-50'}`}>
<div>
<p className="font-medium text-gray-900">Notify on Success</p>
<p className="text-sm text-gray-500">Send a notification when an enrollment completes successfully</p>
</div>
<button
onClick={() => teamsWebhookUrl && setTeamsNotifyOnSuccess(!teamsNotifyOnSuccess)}
disabled={!teamsWebhookUrl}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed ${teamsNotifyOnSuccess && teamsWebhookUrl ? 'bg-sky-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${teamsNotifyOnSuccess && teamsWebhookUrl ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Notify on Failure */}
<div className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${teamsWebhookUrl ? 'border-gray-200 hover:border-sky-200' : 'border-gray-100 opacity-50'}`}>
<div>
<p className="font-medium text-gray-900">Notify on Failure</p>
<p className="text-sm text-gray-500">Send a notification when an enrollment fails</p>
</div>
<button
onClick={() => teamsWebhookUrl && setTeamsNotifyOnFailure(!teamsNotifyOnFailure)}
disabled={!teamsWebhookUrl}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed ${teamsNotifyOnFailure && teamsWebhookUrl ? 'bg-sky-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${teamsNotifyOnFailure && teamsWebhookUrl ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
"use client";
interface EmptyStateProps {
message: string;
onClearFilters: () => void;
showClearButton?: boolean;
}
export function EmptyState({ message, onClearFilters, showClearButton = true }: EmptyStateProps) {
return (
<div className="bg-white rounded-lg shadow p-8 text-center">
<svg className="w-12 h-12 text-gray-400 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="mt-4 text-lg font-medium text-gray-900">No items found</h3>
<p className="mt-2 text-sm text-gray-500">{message}</p>
{showClearButton && (
<button
onClick={onClearFilters}
className="mt-2 text-sm text-indigo-600 hover:text-indigo-800 transition-colors"
>
Clear all filters
</button>
)}
</div>
);
}
@@ -0,0 +1,90 @@
"use client";
import React from "react";
interface FormJsonToggleProps {
jsonMode: boolean;
onToggleMode: (jsonMode: boolean) => void;
jsonText: string;
onJsonTextChange: (text: string) => void;
jsonError: string | null;
onApplyJson: () => void;
children: React.ReactNode;
textareaRows?: number;
description?: string;
}
export function FormJsonToggle({
jsonMode,
onToggleMode,
jsonText,
onJsonTextChange,
jsonError,
onApplyJson,
children,
textareaRows = 20,
description,
}: FormJsonToggleProps) {
return (
<>
{jsonMode ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
{description && (
<p className="text-sm text-gray-600" dangerouslySetInnerHTML={{ __html: description }} />
)}
<button
type="button"
onClick={onApplyJson}
className="text-xs text-indigo-600 hover:text-indigo-800 font-medium whitespace-nowrap ml-4"
>
Apply JSON &rarr;
</button>
</div>
{jsonError && (
<p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{jsonError}</p>
)}
<textarea
value={jsonText}
onChange={(e) => onJsonTextChange(e.target.value)}
rows={textareaRows}
spellCheck={false}
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm font-mono text-gray-900 bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-y"
/>
</div>
) : (
children
)}
</>
);
}
/** The toggle buttons for switching between Form and JSON mode. */
export function JsonModeToggleButtons({
jsonMode,
onToggleMode,
}: {
jsonMode: boolean;
onToggleMode: (jsonMode: boolean) => void;
}) {
return (
<div className="flex items-center bg-gray-100 rounded-lg p-1">
<button
onClick={() => onToggleMode(false)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
!jsonMode ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"
}`}
>
Form
</button>
<button
onClick={() => onToggleMode(true)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
jsonMode ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"
}`}
>
JSON
</button>
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
interface FilterDropdown {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}
interface RuleFilterBarProps {
searchQuery: string;
onSearchChange: (query: string) => void;
searchPlaceholder?: string;
filters: FilterDropdown[];
onExportAll?: () => void;
onCreateNew?: () => void;
createLabel?: string;
showCreateForm?: boolean;
}
export function RuleFilterBar({
searchQuery,
onSearchChange,
searchPlaceholder = "Search...",
filters,
onExportAll,
onCreateNew,
createLabel = "Create Custom Rule",
showCreateForm = false,
}: RuleFilterBarProps) {
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-col sm:flex-row gap-3 flex-1">
{/* Search */}
<div className="relative flex-1 max-w-md">
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
autoComplete="off"
className="w-full px-4 py-2 pl-10 pr-10 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"
/>
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{searchQuery && (
<button
onClick={() => onSearchChange("")}
className="absolute right-3 top-2.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear search"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Filter Dropdowns */}
{filters.map((filter) => (
<select
key={filter.label}
value={filter.value}
onChange={(e) => filter.onChange(e.target.value)}
className="px-3 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"
>
{filter.options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
))}
</div>
{/* Action Buttons */}
{(onExportAll || onCreateNew) && (
<div className="flex items-center gap-2">
{onExportAll && (
<button
onClick={onExportAll}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center space-x-2 text-sm font-medium whitespace-nowrap"
title="Export all visible items as JSON"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<span>Export All</span>
</button>
)}
{onCreateNew && (
<button
onClick={onCreateNew}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center space-x-2 text-sm font-medium whitespace-nowrap"
>
{showCreateForm ? (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>Cancel</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span>{createLabel}</span>
</>
)}
</button>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,17 @@
"use client";
interface StatCardProps {
label: string;
value: number | string;
borderColor?: string;
valueColor?: string;
}
export function StatCard({ label, value, borderColor, valueColor }: StatCardProps) {
return (
<div className={`bg-white rounded-lg shadow p-4${borderColor ? ` border-l-4 ${borderColor}` : ""}`}>
<div className="text-sm font-medium text-gray-500">{label}</div>
<div className={`mt-1 text-2xl font-bold ${valueColor ?? "text-gray-900"}`}>{value}</div>
</div>
);
}
@@ -0,0 +1,34 @@
/**
* Shared utilities for rule/pattern management pages.
*/
/** Trigger a browser download of `data` serialised as pretty-printed JSON. */
export function downloadAsJson(data: unknown, filename: string) {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/**
* Return a shallow copy of `item` with internal / metadata fields removed.
* Works generically for AnalyzeRule, GatherRule, and ImeLogPattern objects.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function stripInternalFields<T extends { isBuiltIn?: any; isCommunity?: any; createdAt?: any; updatedAt?: any }>(item: T): Omit<T, "isBuiltIn" | "isCommunity" | "createdAt" | "updatedAt"> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { isBuiltIn, isCommunity, createdAt, updatedAt, ...rest } = item;
return rest as Omit<T, "isBuiltIn" | "isCommunity" | "createdAt" | "updatedAt">;
}
/** Bump a semver-style version string: "1.0" -> "1.1", "1.9" -> "1.10", etc. */
export function bumpVersion(v: string): string {
const parts = (v ?? "1.0").split(".");
const major = parts[0] ?? "1";
const minor = parseInt(parts[1] ?? "0", 10);
return `${major}.${minor + 1}`;
}
File diff suppressed because one or more lines are too long