mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat: add optional receivedAt field to EnrollmentEvent interface
This commit is contained in:
+8
-6
@@ -684,16 +684,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
// distinguishable from the later full-enrollment package.
|
||||
await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded: true, fileNameSuffix: "preprov");
|
||||
|
||||
// Step 4: Persist final sequence for Boot 2 continuation
|
||||
_sessionPersistence.SaveSequence(Interlocked.Read(ref _eventSequence));
|
||||
_logger.Info($"Sequence counter persisted at {Interlocked.Read(ref _eventSequence)} for next boot");
|
||||
|
||||
// Step 5: Mark WhiteGlove Part 1 as complete so the next boot (Part 2)
|
||||
// Step 4: Mark WhiteGlove Part 1 as complete so the next boot (Part 2)
|
||||
// can signal the backend to transition Pending → InProgress.
|
||||
_sessionPersistence.SaveWhiteGloveComplete();
|
||||
_logger.Info("WhiteGlove marker persisted for Part 2 detection");
|
||||
|
||||
// Step 6: Exit — session.id + session.seq + whiteglove.complete survive so
|
||||
// Step 5: Exit — session.id + session.seq + whiteglove.complete survive so
|
||||
// the agent resumes on next boot.
|
||||
// No DeleteSessionId, no WriteEnrollmentCompleteMarker, no self-destruct.
|
||||
// The Windows Autopilot shutdown will power off the device;
|
||||
@@ -712,6 +708,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
});
|
||||
await UploadEventsAsync();
|
||||
|
||||
// Step 6: Persist final sequence AFTER all events have been emitted and uploaded.
|
||||
// Must be the last mutation before exit so Part 2's LoadSequence() returns
|
||||
// the true final value — prevents duplicate sequence numbers across reboots.
|
||||
_sessionPersistence.SaveSequence(Interlocked.Read(ref _eventSequence));
|
||||
_logger.Info($"Sequence counter persisted at {Interlocked.Read(ref _eventSequence)} for next boot");
|
||||
|
||||
_logger.Info("WhiteGlove graceful shutdown complete. Agent exiting.");
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
491
|
||||
493
|
||||
|
||||
+5
@@ -121,6 +121,11 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
var sessionPrefix = $"[Session: {request.SessionId.Substring(0, Math.Min(8, request.SessionId.Length))}]";
|
||||
_logger.LogInformation($"{sessionPrefix} IngestEvents: Processing {request.Events.Count} events (Device: {validation.CertificateThumbprint}, Hardware: {validation.Manufacturer} {validation.Model}, Rate: {validation.RateLimitResult?.RequestsInWindow}/{validation.RateLimitResult?.MaxRequests})");
|
||||
|
||||
// Stamp server-side receive time on all events in this batch
|
||||
var receivedAt = DateTime.UtcNow;
|
||||
foreach (var evt in request.Events)
|
||||
evt.ReceivedAt = receivedAt;
|
||||
|
||||
// Store events in Azure Table Storage (batch write for efficiency)
|
||||
var storedEvents = await _storageService.StoreEventsBatchAsync(request.Events);
|
||||
int processedCount = storedEvents.Count;
|
||||
|
||||
@@ -448,7 +448,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
evt.Data != null && evt.Data.Count > 0
|
||||
? JsonConvert.SerializeObject(evt.Data)
|
||||
: string.Empty,
|
||||
"DataJson", evt.EventId)
|
||||
"DataJson", evt.EventId),
|
||||
["ReceivedAt"] = evt.ReceivedAt
|
||||
};
|
||||
|
||||
await tableClient.UpsertEntityAsync(entity);
|
||||
@@ -520,7 +521,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
evt.Data != null && evt.Data.Count > 0
|
||||
? JsonConvert.SerializeObject(evt.Data)
|
||||
: string.Empty,
|
||||
"DataJson", evt.EventId)
|
||||
"DataJson", evt.EventId),
|
||||
["ReceivedAt"] = evt.ReceivedAt
|
||||
};
|
||||
|
||||
return new TableTransactionAction(TableTransactionActionType.UpsertReplace, entity);
|
||||
@@ -1176,8 +1178,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
events.Add(MapToEnrollmentEvent(entity));
|
||||
}
|
||||
|
||||
// Sort by Timestamp ascending (chronological order)
|
||||
return events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
|
||||
// Sort by Sequence ascending — the authoritative event order
|
||||
// (assigned atomically via Interlocked.Increment on the agent)
|
||||
return events.OrderBy(e => e.Sequence).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1379,7 +1382,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
Message = entity.GetString("Message") ?? string.Empty,
|
||||
Sequence = entity.GetInt64("Sequence") ?? 0,
|
||||
Data = DeserializeEventData(entity.GetString("DataJson")),
|
||||
RowKey = entity.RowKey
|
||||
RowKey = entity.RowKey,
|
||||
ReceivedAt = entity.GetDateTimeOffset("ReceivedAt")?.UtcDateTime
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Server-side UTC timestamp set when the backend receives and stores this event.
|
||||
/// Null for events that pre-date this feature. Never set by the agent.
|
||||
/// </summary>
|
||||
public DateTime? ReceivedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of event (e.g., "phase_transition", "app_install_start", "error")
|
||||
/// </summary>
|
||||
|
||||
+40
-19
@@ -23,7 +23,6 @@ interface EventTimelineProps {
|
||||
preProvGrouped: { eventsByPhase: Record<string, EnrollmentEvent[]>; orderedPhases: string[] };
|
||||
userEnrollGrouped: { eventsByPhase: Record<string, EnrollmentEvent[]>; orderedPhases: string[] };
|
||||
userEnrollEvents: EnrollmentEvent[];
|
||||
isGalacticAdmin?: boolean;
|
||||
preProvDuration?: string | null;
|
||||
userEnrollDuration?: string | null;
|
||||
showScriptOutput?: boolean;
|
||||
@@ -48,7 +47,6 @@ export default function EventTimeline({
|
||||
preProvGrouped,
|
||||
userEnrollGrouped,
|
||||
userEnrollEvents,
|
||||
isGalacticAdmin,
|
||||
preProvDuration,
|
||||
userEnrollDuration,
|
||||
showScriptOutput,
|
||||
@@ -188,7 +186,6 @@ export default function EventTimeline({
|
||||
events={filterPhaseEvents(preProvGrouped.eventsByPhase[phaseName])}
|
||||
isExpanded={expandedPhases.has(`pre-${phaseName}`)}
|
||||
onToggle={() => togglePhase(`pre-${phaseName}`)}
|
||||
isGalacticAdmin={isGalacticAdmin}
|
||||
showScriptOutput={showScriptOutput}
|
||||
/>
|
||||
))}
|
||||
@@ -226,7 +223,6 @@ export default function EventTimeline({
|
||||
events={filterPhaseEvents(userEnrollGrouped.eventsByPhase[phaseName])}
|
||||
isExpanded={expandedPhases.has(`user-${phaseName}`)}
|
||||
onToggle={() => togglePhase(`user-${phaseName}`)}
|
||||
isGalacticAdmin={isGalacticAdmin}
|
||||
showScriptOutput={showScriptOutput}
|
||||
/>
|
||||
))}
|
||||
@@ -267,7 +263,6 @@ export default function EventTimeline({
|
||||
events={filterPhaseEvents(eventsByPhase[phaseName])}
|
||||
isExpanded={expandedPhases.has(phaseName)}
|
||||
onToggle={() => togglePhase(phaseName)}
|
||||
isGalacticAdmin={isGalacticAdmin}
|
||||
showScriptOutput={showScriptOutput}
|
||||
/>
|
||||
))}
|
||||
@@ -286,14 +281,12 @@ function PhaseSection({
|
||||
events,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isGalacticAdmin,
|
||||
showScriptOutput
|
||||
}: {
|
||||
phaseName: string;
|
||||
events: EnrollmentEvent[];
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
isGalacticAdmin?: boolean;
|
||||
showScriptOutput?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -316,7 +309,6 @@ function PhaseSection({
|
||||
<EventRow
|
||||
key={event.eventId || `${event.sessionId}-${event.sequence}`}
|
||||
event={event}
|
||||
isGalacticAdmin={isGalacticAdmin}
|
||||
showScriptOutput={showScriptOutput}
|
||||
/>
|
||||
))}
|
||||
@@ -326,7 +318,7 @@ function PhaseSection({
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ event, isGalacticAdmin, showScriptOutput }: { event: EnrollmentEvent; isGalacticAdmin?: boolean; showScriptOutput?: boolean }) {
|
||||
function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScriptOutput?: boolean }) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -386,7 +378,8 @@ function EventRow({ event, isGalacticAdmin, showScriptOutput }: { event: Enrollm
|
||||
}
|
||||
};
|
||||
|
||||
const hasDetails = isTruncated || (detailData && Object.keys(detailData).length > 0);
|
||||
const hasData = isTruncated || (detailData && Object.keys(detailData).length > 0);
|
||||
const hasDetails = true; // Every event has at least the metadata block
|
||||
|
||||
return (
|
||||
<div id={`event-${event.eventId}`} className="bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-colors">
|
||||
@@ -403,15 +396,6 @@ function EventRow({ event, isGalacticAdmin, showScriptOutput }: { event: Enrollm
|
||||
<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 && (
|
||||
@@ -424,6 +408,43 @@ function EventRow({ event, isGalacticAdmin, showScriptOutput }: { event: Enrollm
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event metadata block — always shown when details are expanded */}
|
||||
{showDetails && (() => {
|
||||
const receivedDelta = event.receivedAt
|
||||
? Math.round((new Date(event.receivedAt).getTime() - new Date(event.timestamp).getTime()) / 1000 * 10) / 10
|
||||
: null;
|
||||
return (
|
||||
<div className="mt-2 border border-gray-200 rounded-md px-3 py-2 text-xs text-gray-600">
|
||||
<div className="flex">
|
||||
<span className="w-20 flex-shrink-0 text-gray-400">EventId</span>
|
||||
<span className="flex-1 min-w-0 font-mono truncate">{event.eventId}</span>
|
||||
<button
|
||||
onClick={copyEventId}
|
||||
className="ml-2 flex-shrink-0 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title={copied ? 'Copied!' : 'Copy EventId'}
|
||||
>
|
||||
{copied ? '✓' : 'copy'}
|
||||
</button>
|
||||
<span className="w-20 flex-shrink-0 text-gray-400 ml-4">Phase</span>
|
||||
<span className="flex-shrink-0">{event.phaseName || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="flex mt-0.5">
|
||||
<span className="w-20 flex-shrink-0 text-gray-400">Created</span>
|
||||
<span className="flex-1 min-w-0 font-mono">{event.timestamp}</span>
|
||||
<span className="w-20 flex-shrink-0 text-gray-400 ml-6">Received</span>
|
||||
<span className="flex-shrink-0 font-mono">
|
||||
{event.receivedAt
|
||||
? new Date(event.receivedAt).toISOString().replace('T', ' ').replace('Z', '')
|
||||
: '—'}
|
||||
{receivedDelta !== null && receivedDelta >= 0 && (
|
||||
<span className="text-gray-400 ml-1">(+{receivedDelta}s)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Gather rule: terminal-style output block */}
|
||||
{showDetails && hasGatherOutput && (
|
||||
<div className="mt-3">
|
||||
|
||||
@@ -1007,7 +1007,6 @@ export default function SessionDetailPage() {
|
||||
preProvGrouped={preProvGrouped}
|
||||
userEnrollGrouped={userEnrollGrouped}
|
||||
userEnrollEvents={userEnrollEvents}
|
||||
isGalacticAdmin={user?.isGalacticAdmin}
|
||||
preProvDuration={whiteGloveDurations.preProvDuration}
|
||||
userEnrollDuration={whiteGloveDurations.userEnrollDuration}
|
||||
showScriptOutput={showScriptOutput}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface SessionExportEvent {
|
||||
message: string;
|
||||
sequence: number;
|
||||
rowKey?: string;
|
||||
receivedAt?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -51,7 +52,7 @@ export function generateCsvExport(events: SessionExportEvent[]) {
|
||||
// Columns match the Events Azure Table Storage schema exactly
|
||||
// PartitionKey = TenantId_SessionId, RowKey = Timestamp_Sequence
|
||||
// PhaseName is a derived extra column appended after DataJson for convenience
|
||||
const header = "PartitionKey,RowKey,EventId,SessionId,TenantId,Timestamp,EventType,Severity,Source,Phase,Message,Sequence,DataJson,PhaseName";
|
||||
const header = "PartitionKey,RowKey,EventId,SessionId,TenantId,Timestamp,ReceivedAt,EventType,Severity,Source,Phase,Message,Sequence,DataJson,PhaseName";
|
||||
const rows = sorted.map(e => {
|
||||
const sev = e.severity ?? "";
|
||||
const sevInt = SEVERITY_INT[sev] ?? SEVERITY_INT[sev.charAt(0).toUpperCase() + sev.slice(1).toLowerCase()];
|
||||
@@ -63,6 +64,7 @@ export function generateCsvExport(events: SessionExportEvent[]) {
|
||||
esc(e.sessionId ?? ""),
|
||||
esc(e.tenantId ?? ""),
|
||||
esc(e.timestamp ?? ""),
|
||||
esc(e.receivedAt ?? ""),
|
||||
esc(e.eventType ?? ""),
|
||||
esc(severityCell),
|
||||
esc(e.source ?? ""),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ export interface EnrollmentEvent {
|
||||
phaseName?: string;
|
||||
message: string;
|
||||
sequence: number;
|
||||
receivedAt?: string;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user