mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
add max collector duration policy, SignalR signal refactor, session export tool, changelog page, security-first ingest ordering
- Agent/Backend: configurable MaxCollectorDurationHours (default 4h) stops interval-based collectors on stuck sessions; setting exposed in global admin config - Backend: IngestEventsFunction now runs all security checks (cert, rate limit, hardware whitelist, Autopilot validation) before touching the request body, eliminating unnecessary gzip decompression and JSON parsing on rejected requests; added TenantId header/body cross-check - Backend/Frontend: SignalR eventStream refactored from full event payload to signal-only (newEventCount); frontend fetches canonical event state from Table Storage on each signal, eliminating gap issues and phase-name race conditions - Frontend: fetchEvents replaces (not merges) event list; phaseName computed at render time in eventsByPhase useMemo; 30s catch-up fetch added as safety net - Frontend: session event export tool in admin-configuration (teal block) — fetch by sessionId+tenantId, download timeline export (.txt with UI phase grouping) and raw CSV export - Frontend: changelog page (/changelog) with Private Preview context and first architecture entry; dashboard feedback banner updated to link changelog; Navbar overflow menu gains Preview Changelog link Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+32
-2
@@ -20,24 +20,30 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _onEventCollected;
|
||||
private readonly int _intervalSeconds;
|
||||
private readonly int _maxDurationHours;
|
||||
private DateTime _startedAt;
|
||||
private Timer _pollTimer;
|
||||
|
||||
private PerformanceCounter _cpuCounter;
|
||||
private PerformanceCounter _diskQueueCounter;
|
||||
|
||||
public PerformanceCollector(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger, int intervalSeconds = 60)
|
||||
AgentLogger logger, int intervalSeconds = 60, int maxDurationHours = 4)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_intervalSeconds = intervalSeconds;
|
||||
_maxDurationHours = maxDurationHours;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_logger.Info($"Starting Performance collector (interval: {_intervalSeconds}s)");
|
||||
var limitInfo = _maxDurationHours > 0 ? $", max duration: {_maxDurationHours}h" : ", no duration limit";
|
||||
_logger.Info($"Starting Performance collector (interval: {_intervalSeconds}s{limitInfo})");
|
||||
|
||||
_startedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -72,6 +78,30 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
|
||||
{
|
||||
try
|
||||
{
|
||||
// Stop after max duration to avoid unbounded backend traffic when a session is stuck
|
||||
if (_maxDurationHours > 0 && (DateTime.UtcNow - _startedAt).TotalHours >= _maxDurationHours)
|
||||
{
|
||||
_logger.Info($"Performance collector max duration reached ({_maxDurationHours}h) — stopping");
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = "performance_collector_stopped",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "PerformanceCollector",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Performance collector stopped after {_maxDurationHours}h (max duration policy)",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "reason", "max_duration_reached" },
|
||||
{ "maxDurationHours", _maxDurationHours }
|
||||
}
|
||||
});
|
||||
Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
// CPU usage
|
||||
|
||||
@@ -286,12 +286,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
{
|
||||
// PerformanceCollector is always on (feeds UI chart)
|
||||
var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
|
||||
var maxCollectorDurationHours = collectors?.MaxCollectorDurationHours ?? 4;
|
||||
_performanceCollector = new PerformanceCollector(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
EmitEvent,
|
||||
_logger,
|
||||
perfInterval
|
||||
perfInterval,
|
||||
maxCollectorDurationHours
|
||||
);
|
||||
_performanceCollector.Start();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
{
|
||||
private readonly ILogger<GetAgentConfigFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly GatherRuleService _gatherRuleService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
@@ -24,12 +25,14 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
public GetAgentConfigFunction(
|
||||
ILogger<GetAgentConfigFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
GatherRuleService gatherRuleService,
|
||||
RateLimitService rateLimitService,
|
||||
AutopilotDeviceValidator autopilotDeviceValidator)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_gatherRuleService = gatherRuleService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
@@ -74,11 +77,15 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
// Get tenant configuration
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
|
||||
// Build collector configuration from tenant settings
|
||||
// Get global admin config for platform-wide policy settings
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
|
||||
// Build collector configuration from tenant settings + global policy
|
||||
var collectors = new CollectorConfiguration
|
||||
{
|
||||
EnablePerformanceCollector = tenantConfig.EnablePerformanceCollector,
|
||||
PerformanceIntervalSeconds = tenantConfig.PerformanceCollectorIntervalSeconds
|
||||
PerformanceIntervalSeconds = tenantConfig.PerformanceCollectorIntervalSeconds,
|
||||
MaxCollectorDurationHours = adminConfig.MaxCollectorDurationHours
|
||||
};
|
||||
|
||||
// Get active gather rules for this tenant (user-defined ad-hoc only)
|
||||
@@ -90,7 +97,7 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 6,
|
||||
ConfigVersion = 7,
|
||||
UploadIntervalSeconds = 30,
|
||||
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
|
||||
KeepLogFile = tenantConfig.KeepLogFile ?? false,
|
||||
|
||||
@@ -48,15 +48,41 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
{
|
||||
try
|
||||
{
|
||||
// Parse NDJSON+gzip request
|
||||
IngestEventsRequest? request = null;
|
||||
// --- Security checks FIRST — before touching the request body ---
|
||||
|
||||
// Extract tenantId from header to get config (for payload size limit)
|
||||
// TenantId is available in the X-Tenant-Id header, so we can validate the request
|
||||
// before paying the cost of gzip decompression and JSON parsing.
|
||||
var tenantIdHeader = req.Headers.Contains("X-Tenant-Id")
|
||||
? req.Headers.GetValues("X-Tenant-Id").FirstOrDefault()
|
||||
: null;
|
||||
|
||||
request = await ParseNdjsonGzipRequest(req.Body, tenantIdHeader);
|
||||
if (string.IsNullOrEmpty(tenantIdHeader))
|
||||
{
|
||||
return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "X-Tenant-Id header is required");
|
||||
}
|
||||
|
||||
// Validate request security (certificate, rate limit, hardware whitelist, serial number in autopilot)
|
||||
// sessionId is not yet known at this point — that's fine, it's optional for logging only.
|
||||
var (validation, errorResponse) = await req.ValidateSecurityAsync(
|
||||
tenantIdHeader,
|
||||
_configService,
|
||||
_rateLimitService,
|
||||
_autopilotDeviceValidator,
|
||||
_logger
|
||||
);
|
||||
|
||||
if (errorResponse != null)
|
||||
{
|
||||
// Security validation failed - return before parsing the body
|
||||
return new IngestEventsOutput
|
||||
{
|
||||
HttpResponse = errorResponse,
|
||||
SignalRMessages = Array.Empty<SignalRMessageAction>()
|
||||
};
|
||||
}
|
||||
|
||||
// --- Parse NDJSON+gzip request body (only after security is cleared) ---
|
||||
var request = await ParseNdjsonGzipRequest(req.Body, tenantIdHeader);
|
||||
|
||||
if (request?.Events == null || request.Events.Count == 0)
|
||||
{
|
||||
@@ -68,24 +94,11 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "SessionId and TenantId are required");
|
||||
}
|
||||
|
||||
// Validate request security (certificate, rate limit, hardware whitelist, serial number in autopilot)
|
||||
var (validation, errorResponse) = await req.ValidateSecurityAsync(
|
||||
request.TenantId,
|
||||
_configService,
|
||||
_rateLimitService,
|
||||
_autopilotDeviceValidator,
|
||||
_logger,
|
||||
request.SessionId
|
||||
);
|
||||
|
||||
if (errorResponse != null)
|
||||
// Ensure body TenantId matches the validated header TenantId (prevent body spoofing)
|
||||
if (!string.Equals(request.TenantId, tenantIdHeader, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Security validation failed - return error response
|
||||
return new IngestEventsOutput
|
||||
{
|
||||
HttpResponse = errorResponse,
|
||||
SignalRMessages = Array.Empty<SignalRMessageAction>()
|
||||
};
|
||||
_logger.LogWarning("TenantId mismatch: header={HeaderTenantId}, body={BodyTenantId}", tenantIdHeader, request.TenantId);
|
||||
return await CreateErrorResponse(req, HttpStatusCode.Forbidden, "TenantId mismatch between header and payload");
|
||||
}
|
||||
|
||||
var sessionPrefix = $"[Session: {request.SessionId.Substring(0, Math.Min(8, request.SessionId.Length))}]";
|
||||
@@ -340,22 +353,10 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
} }
|
||||
};
|
||||
|
||||
// 2. Detailed events for real-time event streaming on detail pages (session-specific)
|
||||
// Only sent to clients viewing this specific session - massive cost savings
|
||||
// Strip redundant sessionId/tenantId from individual events and rule results
|
||||
var slimEvents = storedEvents.Select(e => new {
|
||||
e.EventId,
|
||||
e.Timestamp,
|
||||
e.EventType,
|
||||
severity = e.SeverityString,
|
||||
e.Source,
|
||||
phase = e.PhaseNumber,
|
||||
phaseName = e.PhaseName,
|
||||
e.Message,
|
||||
e.Data,
|
||||
e.Sequence
|
||||
}).ToList();
|
||||
|
||||
// 2. Signal for real-time event streaming on detail pages (session-specific)
|
||||
// Only sent to clients viewing this specific session - cost-efficient signal pattern:
|
||||
// frontend fetches full events from Table Storage on receipt, ensuring canonical truth
|
||||
// and eliminating SignalR gap issues. Events payload omitted intentionally.
|
||||
var slimRuleResults = newRuleResults.Count > 0
|
||||
? newRuleResults.Select(r => new {
|
||||
r.ResultId,
|
||||
@@ -378,7 +379,7 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
Arguments = new object[] { new {
|
||||
sessionId = request.SessionId,
|
||||
tenantId = request.TenantId,
|
||||
events = slimEvents,
|
||||
newEventCount = processedCount,
|
||||
session = updatedSession,
|
||||
newRuleResults = slimRuleResults
|
||||
} }
|
||||
|
||||
@@ -181,7 +181,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{ "LastUpdated", config.LastUpdated },
|
||||
{ "UpdatedBy", config.UpdatedBy },
|
||||
{ "GlobalRateLimitRequestsPerMinute", config.GlobalRateLimitRequestsPerMinute },
|
||||
{ "PlatformStatsBlobSasUrl", config.PlatformStatsBlobSasUrl ?? string.Empty }
|
||||
{ "PlatformStatsBlobSasUrl", config.PlatformStatsBlobSasUrl ?? string.Empty },
|
||||
{ "MaxCollectorDurationHours", config.MaxCollectorDurationHours }
|
||||
};
|
||||
|
||||
return entity;
|
||||
@@ -196,7 +197,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow,
|
||||
UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown",
|
||||
GlobalRateLimitRequestsPerMinute = entity.GetInt32("GlobalRateLimitRequestsPerMinute") ?? 100,
|
||||
PlatformStatsBlobSasUrl = entity.GetString("PlatformStatsBlobSasUrl") ?? string.Empty
|
||||
PlatformStatsBlobSasUrl = entity.GetString("PlatformStatsBlobSasUrl") ?? string.Empty,
|
||||
MaxCollectorDurationHours = entity.GetInt32("MaxCollectorDurationHours") ?? 4
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public string PlatformStatsBlobSasUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum duration in hours that interval-based collectors (e.g. PerformanceCollector)
|
||||
/// keep running per session. After this time they stop automatically to prevent
|
||||
/// excessive backend traffic when a device is stuck during enrollment.
|
||||
/// 0 = no limit (run forever). Default: 4 hours.
|
||||
/// </summary>
|
||||
public int MaxCollectorDurationHours { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Creates default configuration
|
||||
/// </summary>
|
||||
@@ -57,7 +65,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
LastUpdated = DateTime.UtcNow,
|
||||
UpdatedBy = "System",
|
||||
GlobalRateLimitRequestsPerMinute = 100,
|
||||
PlatformStatsBlobSasUrl = string.Empty
|
||||
PlatformStatsBlobSasUrl = string.Empty,
|
||||
MaxCollectorDurationHours = 4
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,12 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public int PerformanceIntervalSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum duration in hours that interval-based collectors run before stopping themselves.
|
||||
/// 0 = no limit. Default: 4 hours.
|
||||
/// </summary>
|
||||
public int MaxCollectorDurationHours { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Creates default collector configuration
|
||||
/// </summary>
|
||||
|
||||
@@ -6,6 +6,36 @@ import { ProtectedRoute } from "../../components/ProtectedRoute";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
|
||||
interface SessionExportEvent {
|
||||
eventId: string;
|
||||
sessionId: string;
|
||||
tenantId: string;
|
||||
timestamp: string;
|
||||
eventType: string;
|
||||
severity: string;
|
||||
source: string;
|
||||
phase: number;
|
||||
phaseName?: string;
|
||||
message: string;
|
||||
sequence: number;
|
||||
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"];
|
||||
|
||||
interface AdminConfiguration {
|
||||
partitionKey: string;
|
||||
rowKey: string;
|
||||
@@ -13,6 +43,7 @@ interface AdminConfiguration {
|
||||
updatedBy: string;
|
||||
globalRateLimitRequestsPerMinute: number;
|
||||
platformStatsBlobSasUrl?: string;
|
||||
maxCollectorDurationHours?: number;
|
||||
customSettings?: string;
|
||||
}
|
||||
|
||||
@@ -58,6 +89,14 @@ export default function AdminConfigurationPage() {
|
||||
const [savingConfig, setSavingConfig] = useState(false);
|
||||
const [globalRateLimit, setGlobalRateLimit] = useState(100);
|
||||
const [platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl] = useState("");
|
||||
const [maxCollectorDurationHours, setMaxCollectorDurationHours] = useState(4);
|
||||
|
||||
// Session Event Export state
|
||||
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);
|
||||
|
||||
// Tenant Management state
|
||||
const [tenants, setTenants] = useState<TenantConfiguration[]>([]);
|
||||
@@ -127,6 +166,7 @@ export default function AdminConfigurationPage() {
|
||||
setAdminConfig(data);
|
||||
setGlobalRateLimit(data.globalRateLimitRequestsPerMinute);
|
||||
setPlatformStatsBlobSasUrl(data.platformStatsBlobSasUrl ?? "");
|
||||
setMaxCollectorDurationHours(data.maxCollectorDurationHours ?? 4);
|
||||
} catch (err) {
|
||||
console.error("Error fetching admin configuration:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load admin configuration");
|
||||
@@ -267,6 +307,7 @@ export default function AdminConfigurationPage() {
|
||||
...adminConfig,
|
||||
globalRateLimitRequestsPerMinute: globalRateLimit,
|
||||
platformStatsBlobSasUrl: platformStatsBlobSasUrl.trim(),
|
||||
maxCollectorDurationHours: maxCollectorDurationHours,
|
||||
};
|
||||
|
||||
const token = await getAccessToken();
|
||||
@@ -305,6 +346,7 @@ export default function AdminConfigurationPage() {
|
||||
if (!adminConfig) return;
|
||||
setGlobalRateLimit(adminConfig.globalRateLimitRequestsPerMinute);
|
||||
setPlatformStatsBlobSasUrl(adminConfig.platformStatsBlobSasUrl ?? "");
|
||||
setMaxCollectorDurationHours(adminConfig.maxCollectorDurationHours ?? 4);
|
||||
setSuccessMessage(null);
|
||||
setError(null);
|
||||
};
|
||||
@@ -636,6 +678,126 @@ export default function AdminConfigurationPage() {
|
||||
const endAdminIndex = startAdminIndex + adminsPerPage;
|
||||
const paginatedAdmins = filteredAdmins.slice(startAdminIndex, endAdminIndex);
|
||||
|
||||
const handleFetchExportEvents = async () => {
|
||||
const sid = exportSessionId.trim();
|
||||
const tid = exportTenantId.trim();
|
||||
if (!sid || !tid) {
|
||||
setExportError("Session ID and Tenant ID are required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setExportLoading(true);
|
||||
setExportError(null);
|
||||
setExportedEvents(null);
|
||||
const token = await getAccessToken();
|
||||
if (!token) throw new Error("Failed to get access token");
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/sessions/${sid}/events?tenantId=${encodeURIComponent(tid)}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.message || "Backend returned error");
|
||||
setExportedEvents(data.events ?? []);
|
||||
} catch (err) {
|
||||
setExportError(err instanceof Error ? err.message : "Failed to fetch events");
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = (content: string, filename: string, mimeType: string) => {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const generateCsvExport = (events: SessionExportEvent[]) => {
|
||||
const isV1 = events.some(e => e.phase === 2);
|
||||
const phaseNames = isV1 ? EXPORT_V1_PHASE_NAMES : EXPORT_V2_PHASE_NAMES;
|
||||
const esc = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
||||
const header = "EventId,SessionId,TenantId,Timestamp,EventType,Severity,Source,Phase,PhaseName,Message,Sequence,Data";
|
||||
const rows = events.map(e => [
|
||||
esc(e.eventId ?? ""),
|
||||
esc(e.sessionId ?? ""),
|
||||
esc(e.tenantId ?? ""),
|
||||
esc(e.timestamp ?? ""),
|
||||
esc(e.eventType ?? ""),
|
||||
esc(e.severity ?? ""),
|
||||
esc(e.source ?? ""),
|
||||
String(e.phase ?? 0),
|
||||
esc(phaseNames[e.phase] ?? "Unknown"),
|
||||
esc(e.message ?? ""),
|
||||
String(e.sequence ?? 0),
|
||||
esc(e.data ? JSON.stringify(e.data) : ""),
|
||||
].join(","));
|
||||
return "\uFEFF" + header + "\n" + rows.join("\n");
|
||||
};
|
||||
|
||||
const generateUiExport = (events: SessionExportEvent[], sessionId: string, tenantId: string) => {
|
||||
const isV1 = events.some(e => e.phase === 2);
|
||||
const phaseNames = isV1 ? EXPORT_V1_PHASE_NAMES : EXPORT_V2_PHASE_NAMES;
|
||||
const phaseOrder = isV1 ? EXPORT_V1_PHASE_ORDER : EXPORT_V2_PHASE_ORDER;
|
||||
|
||||
const sorted = [...events].sort((a, b) => a.sequence - b.sequence);
|
||||
|
||||
// Replicate eventsByPhase useMemo logic: assign Unknown-phase events to active named phase
|
||||
const grouped: Record<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");
|
||||
};
|
||||
|
||||
if (!galacticAdminMode) {
|
||||
return null;
|
||||
}
|
||||
@@ -1043,6 +1205,130 @@ export default function AdminConfigurationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Event Export Tool */}
|
||||
<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>
|
||||
<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 {exportSessionId.trim().slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
const sid = exportSessionId.trim();
|
||||
const tid = exportTenantId.trim();
|
||||
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 = exportSessionId.trim();
|
||||
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>
|
||||
|
||||
{/* Global Rate Limiting Configuration */}
|
||||
<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">
|
||||
@@ -1103,6 +1389,25 @@ export default function AdminConfigurationPage() {
|
||||
</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">
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { PublicPageHeader } from "../../components/PublicPageHeader";
|
||||
import { PublicSiteNavbar } from "../../components/PublicSiteNavbar";
|
||||
|
||||
export default function ChangelogPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<PublicSiteNavbar showSectionLinks={false} />
|
||||
<PublicPageHeader title="Changelog" />
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 sm:p-10">
|
||||
|
||||
{/* Intro */}
|
||||
<div className="mb-10 pb-8 border-b border-gray-100">
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
This changelog tracks significant platform changes during Private Preview —
|
||||
architecture updates, data flow changes, and anything else that might briefly
|
||||
affect the UI or monitoring data. If something looks off, check here first.
|
||||
A recent entry might explain it.
|
||||
</p>
|
||||
<p className="mt-3 text-gray-600 leading-relaxed">
|
||||
Found a bug or want to give feedback?{" "}
|
||||
<a
|
||||
href="https://github.com/okieselbach/Autopilot-Monitor/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 underline font-medium"
|
||||
>
|
||||
Open a GitHub Issue
|
||||
</a>
|
||||
{" "}— it helps more than you might think.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Entries — newest first */}
|
||||
<div className="space-y-10">
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-xs font-mono font-semibold text-gray-400 uppercase tracking-wider">
|
||||
2026-02-26
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Architecture
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-2">
|
||||
Reworked real-time event delivery and session timeline processing
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
The way live session events reach the dashboard timeline was fundamentally
|
||||
reworked. Previously, SignalR pushed full event payloads directly into the
|
||||
UI — which could cause silent gaps if a message was dropped or arrived
|
||||
out of order. Events are now fetched directly from storage on each signal,
|
||||
making Table Storage the single source of truth. The timeline always reflects
|
||||
what is actually stored, ordering and completeness are guaranteed, and
|
||||
phase grouping is computed at render time rather than at receipt time
|
||||
(eliminating a race condition when session metadata wasn't yet available).
|
||||
A 30-second catch-up fetch was also added as a safety net for sessions
|
||||
with degraded SignalR connectivity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="mt-12 pt-8 border-t border-gray-100">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-sm font-medium text-blue-700 hover:text-blue-800"
|
||||
>
|
||||
Landing page
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ProtectedRoute } from "../../components/ProtectedRoute";
|
||||
import { useSignalR } from "../../contexts/SignalRContext";
|
||||
@@ -495,6 +496,33 @@ export default function Home() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feedback & bug report banner */}
|
||||
<div className="mb-4 bg-blue-50 border border-blue-300 rounded-lg px-4 py-3 flex items-start gap-3 dark:bg-blue-950/30 dark:border-blue-700/50">
|
||||
<svg className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
<span className="font-semibold">Private Preview — ongoing platform updates.</span>{" "}
|
||||
Check the{" "}
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
|
||||
>
|
||||
Private Preview Changelog
|
||||
</Link>
|
||||
{" "}if something looks off — recent changes are listed there. Feedback or a bug to report?{" "}
|
||||
<a
|
||||
href="https://github.com/okieselbach/Autopilot-Monitor/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-blue-600 dark:hover:text-blue-200"
|
||||
>
|
||||
Open a GitHub Issue
|
||||
</a>
|
||||
{" "}— or reach out via LinkedIn DM.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{serialValidationEnabled === false && (
|
||||
<div className="mb-6 bg-red-600 border-2 border-red-700 rounded-xl p-5 shadow-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
|
||||
@@ -199,37 +199,27 @@ export default function SessionDetailPage() {
|
||||
|
||||
// Setup SignalR listener - re-register when connection changes
|
||||
useEffect(() => {
|
||||
// Listen for real-time event stream (cost-efficient - no HTTP requests!)
|
||||
const handleEventStream = (data: { sessionId: string; tenantId: string; events: EnrollmentEvent[]; session: Session; newRuleResults?: RuleResult[] }) => {
|
||||
console.log('Event stream received via SignalR:', data);
|
||||
if (data.sessionId === sessionIdRef.current && data.events && data.events.length > 0) {
|
||||
// Add phase names to new events
|
||||
const phaseNamesMap = data.session?.enrollmentType === "v2" ? V2_PHASE_NAMES : V1_PHASE_NAMES;
|
||||
const eventsWithPhaseNames = data.events.map((e: EnrollmentEvent) => ({
|
||||
...e,
|
||||
phaseName: phaseNamesMap[e.phase] || "Unknown"
|
||||
}));
|
||||
// Listen for event stream signal — backend sends a lightweight signal (no event payload).
|
||||
// Frontend fetches fresh events from Table Storage on receipt: canonical truth, no gaps.
|
||||
const handleEventStream = (data: { sessionId: string; tenantId: string; newEventCount: number; session?: Session; newRuleResults?: RuleResult[] }) => {
|
||||
console.log('Event stream signal received via SignalR:', data);
|
||||
if (data.sessionId !== sessionIdRef.current) return;
|
||||
|
||||
// Add new events to existing list (deduplication by eventId)
|
||||
setEvents(prevEvents => {
|
||||
const existingIds = new Set(prevEvents.map(e => e.eventId));
|
||||
const newEvents = eventsWithPhaseNames.filter(e => !existingIds.has(e.eventId));
|
||||
return [...prevEvents, ...newEvents].sort((a, b) => a.sequence - b.sequence);
|
||||
// Fetch full events from storage (single source of truth)
|
||||
fetchEvents();
|
||||
|
||||
// Session update from SignalR (no extra fetch needed)
|
||||
if (data.session) {
|
||||
setSession(data.session);
|
||||
}
|
||||
|
||||
// Rule results from SignalR (only on enrollment completion)
|
||||
if (data.newRuleResults && data.newRuleResults.length > 0) {
|
||||
setAnalysisResults(prev => {
|
||||
const existingIds = new Set(prev.map(r => r.ruleId));
|
||||
const newResults = data.newRuleResults!.filter(r => !existingIds.has(r.ruleId));
|
||||
return [...prev, ...newResults].sort((a, b) => b.confidenceScore - a.confidenceScore);
|
||||
});
|
||||
|
||||
// Update session details directly from SignalR (no HTTP request needed!)
|
||||
if (data.session) {
|
||||
setSession(data.session);
|
||||
}
|
||||
|
||||
// Add new rule results in real-time
|
||||
if (data.newRuleResults && data.newRuleResults.length > 0) {
|
||||
setAnalysisResults(prev => {
|
||||
const existingIds = new Set(prev.map(r => r.ruleId));
|
||||
const newResults = data.newRuleResults!.filter(r => !existingIds.has(r.ruleId));
|
||||
return [...prev, ...newResults].sort((a, b) => b.confidenceScore - a.confidenceScore);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,6 +246,17 @@ export default function SessionDetailPage() {
|
||||
};
|
||||
}, [on, off]); // Re-register when SignalR connection changes
|
||||
|
||||
// Periodic catch-up fetch — fills any SignalR gaps every 30s when tab is visible
|
||||
useEffect(() => {
|
||||
const effectiveTenantId = sessionTenantId || tenantId;
|
||||
if (!sessionId || !effectiveTenantId) return;
|
||||
const interval = setInterval(() => {
|
||||
if (document.visibilityState === "visible") fetchEvents();
|
||||
}, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, sessionTenantId, tenantId]);
|
||||
|
||||
const fetchSessionDetails = async () => {
|
||||
try {
|
||||
// In galactic admin mode, fetch from the galactic endpoint (cross-tenant)
|
||||
@@ -314,21 +315,10 @@ export default function SessionDetailPage() {
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const phaseNamesMap = session?.enrollmentType === "v2" ? V2_PHASE_NAMES : V1_PHASE_NAMES;
|
||||
const eventsWithPhaseNames = data.events.map((e: EnrollmentEvent) => ({
|
||||
...e,
|
||||
phaseName: phaseNamesMap[e.phase] || "Unknown"
|
||||
}));
|
||||
// Merge with existing events (from SignalR) instead of replacing,
|
||||
// so we don't lose events that arrived via SignalR since the last fetch.
|
||||
// Deduplication by eventId, same as the SignalR handler.
|
||||
setEvents(prevEvents => {
|
||||
if (prevEvents.length === 0) return eventsWithPhaseNames;
|
||||
const existingIds = new Set(prevEvents.map(e => e.eventId));
|
||||
const newEvents = eventsWithPhaseNames.filter((e: EnrollmentEvent) => !existingIds.has(e.eventId));
|
||||
if (newEvents.length === 0) return prevEvents;
|
||||
return [...prevEvents, ...newEvents].sort((a: EnrollmentEvent, b: EnrollmentEvent) => a.sequence - b.sequence);
|
||||
});
|
||||
// Replace entire event list with fresh data from Table Storage (canonical truth).
|
||||
// No merge needed — fetchEvents() is the single source; SignalR only signals.
|
||||
// phaseName is computed at render time in eventsByPhase useMemo (no race condition).
|
||||
setEvents(data.events ?? []);
|
||||
} else {
|
||||
addNotification('error', 'Backend Error', `Failed to load session events: ${response.statusText}`, 'session-events-fetch-error');
|
||||
}
|
||||
@@ -452,8 +442,13 @@ export default function SessionDetailPage() {
|
||||
|
||||
// Group events by phase with memoization
|
||||
const { eventsByPhase, orderedPhases } = useMemo(() => {
|
||||
// Sort events by sequence (chronological order)
|
||||
const sortedEvents = [...filteredEvents].sort((a, b) => a.sequence - b.sequence);
|
||||
// Compute phaseName at render time so enrollmentType is always resolved (no race condition)
|
||||
const phaseNamesMap = session?.enrollmentType === "v2" ? V2_PHASE_NAMES : V1_PHASE_NAMES;
|
||||
|
||||
// Sort events by sequence (chronological order), enriching phaseName from current session
|
||||
const sortedEvents = [...filteredEvents]
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
.map(e => ({ ...e, phaseName: phaseNamesMap[e.phase] ?? "Unknown" }));
|
||||
|
||||
// Group events by phase, inserting "Unknown" events into their chronological position
|
||||
const eventsByPhase = {} as Record<string, EnrollmentEvent[]>;
|
||||
|
||||
@@ -480,6 +480,17 @@ export default function Navbar() {
|
||||
<span>Roadmap</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowOverflow(false)}
|
||||
>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<span>Preview Changelog</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user