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:
Oliver Kieselbach
2026-02-26 09:52:22 +01:00
parent ad5c5ff878
commit 326557c4cd
12 changed files with 569 additions and 93 deletions
@@ -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&apos;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"