From 443decc4d9b98c7f17d64666ee64739992c80eca Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 3 Mar 2026 10:13:37 +0100 Subject: [PATCH] fix WhiteGlove Part 2 event timeline disruption on mid-enrollment reboot Prevent phase regression in the event timeline when a reboot during User Enrollment (Part 2) emits a new agent_started with Phase=Start. The groupEventsByPhase function now accepts a preventPhaseRegression option that tracks the highest phase index seen and suppresses regressions to earlier phases. Reboot events stay in the active enrollment phase instead of resetting the timeline to Start. Also adds timestamp as fallback sort key to handle potential duplicate sequences from agent persistence gaps. Applied to both UI rendering and text/CSV export for Part 2 only. Co-Authored-By: Claude Opus 4.6 --- .../app/sessions/[sessionId]/page.tsx | 20 ++++++++++-- .../[sessionId]/utils/eventHelpers.ts | 30 ++++++++++++++++-- .../lib/sessionExportUtils.ts | 31 ++++++++++++++++--- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index 8756d462..54909b0b 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -70,6 +70,7 @@ export interface Session { diagnosticsBlobName?: string; lastEventAt?: string; isPreProvisioned?: boolean; + isHybridJoin?: boolean; osBuild?: string; osEdition?: string; osLanguage?: string; @@ -703,7 +704,7 @@ export default function SessionDetailPage() { const userEnrollGrouped = useMemo(() => isWhiteGloveSession && userEnrollEvents.length > 0 - ? groupEventsByPhase(userEnrollEvents, phaseNamesMap, phaseOrder) + ? groupEventsByPhase(userEnrollEvents, phaseNamesMap, phaseOrder, { preventPhaseRegression: true }) : { eventsByPhase: {} as Record, orderedPhases: [] as string[] }, [userEnrollEvents, isWhiteGloveSession, phaseNamesMap, phaseOrder] ); @@ -1268,8 +1269,23 @@ function DeviceDetailsCard({ events }: { events: EnrollmentEvent[] }) { {hasValue(autopilotProfile.CloudAssignedOobeConfig) && } {hasValue(autopilotProfile.ZtdRegistrationId) && } {hasValue(autopilotProfile.AadDeviceId) && } + {autopilotProfile.AutopilotMode !== undefined && ( + + )} {autopilotProfile.CloudAssignedDomainJoinMethod !== undefined && ( - + + )} + {autopilotProfile.HybridJoinSkipDCConnectivityCheck !== undefined && ( + )} {autopilotProfile.CloudAssignedForcedEnrollment !== undefined && ( diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/eventHelpers.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/eventHelpers.ts index b4fbfc59..3744b964 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/eventHelpers.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/eventHelpers.ts @@ -2,22 +2,46 @@ import { EnrollmentEvent } from "../page"; // Pure helper — groups a flat event list into phase buckets. // Extracted from the useMemo so it can be called multiple times for WhiteGlove split timelines. +// +// preventPhaseRegression: when true, once the phase advances past a certain point it cannot +// regress to an earlier phase. Used for WhiteGlove Part 2 (User Enrollment) to absorb +// mid-enrollment reboots that emit a new agent_started (Phase=Start) without disrupting the +// timeline flow. The reboot events stay in whatever phase was active before the reboot. export function groupEventsByPhase( events: EnrollmentEvent[], phaseNamesMap: Record, - phaseOrder: string[] + phaseOrder: string[], + options?: { preventPhaseRegression?: boolean } ): { eventsByPhase: Record; orderedPhases: string[] } { const sortedEvents = [...events] - .sort((a, b) => a.sequence - b.sequence) + .sort((a, b) => { + const seqDiff = a.sequence - b.sequence; + if (seqDiff !== 0) return seqDiff; + // Fallback: timestamp breaks ties when sequence counter was not persisted before reboot + return (a.timestamp ?? "").localeCompare(b.timestamp ?? ""); + }) .map(e => ({ ...e, phaseName: phaseNamesMap[e.phase] ?? "Unknown" })); + const preventRegression = options?.preventPhaseRegression === true; const eventsByPhase: Record = {}; let currentActivePhaseName = "Start"; + let maxPhaseIndex = 0; for (const event of sortedEvents) { let targetPhase = event.phaseName || "Unknown"; if (targetPhase !== "Unknown") { - currentActivePhaseName = targetPhase; + if (preventRegression) { + const candidateIndex = phaseOrder.indexOf(targetPhase); + if (candidateIndex >= 0 && candidateIndex >= maxPhaseIndex) { + currentActivePhaseName = targetPhase; + maxPhaseIndex = candidateIndex; + } else { + // Phase would regress (e.g. reboot agent_started) — keep current phase + targetPhase = currentActivePhaseName; + } + } else { + currentActivePhaseName = targetPhase; + } } else { targetPhase = currentActivePhaseName; } diff --git a/src/Web/autopilot-monitor-web/lib/sessionExportUtils.ts b/src/Web/autopilot-monitor-web/lib/sessionExportUtils.ts index 0367232b..cc35b21c 100644 --- a/src/Web/autopilot-monitor-web/lib/sessionExportUtils.ts +++ b/src/Web/autopilot-monitor-web/lib/sessionExportUtils.ts @@ -177,16 +177,31 @@ export function generateRuleResultsCsvExport(results: RuleResultCsvData[]): stri function groupEventsByPhase( eventsToGroup: SessionExportEvent[], phaseNames: Record, - phaseOrder: string[] + phaseOrder: string[], + options?: { preventPhaseRegression?: boolean } ): Record { + const preventRegression = options?.preventPhaseRegression === true; const grouped: Record = {}; phaseOrder.forEach(p => { grouped[p] = []; }); let lastNamedPhase = phaseOrder[0]; + let maxPhaseIndex = 0; for (const ev of eventsToGroup) { const name = phaseNames[ev.phase]; if (name && name !== "Unknown") { - lastNamedPhase = name; - if (grouped[name]) grouped[name].push({ ...ev, phaseName: name }); + if (preventRegression) { + const candidateIndex = phaseOrder.indexOf(name); + if (candidateIndex >= 0 && candidateIndex >= maxPhaseIndex) { + lastNamedPhase = name; + maxPhaseIndex = candidateIndex; + if (grouped[name]) grouped[name].push({ ...ev, phaseName: name }); + } else { + // Phase would regress (e.g. reboot agent_started) — keep current phase + if (grouped[lastNamedPhase]) grouped[lastNamedPhase].push({ ...ev, phaseName: name }); + } + } else { + lastNamedPhase = name; + if (grouped[name]) grouped[name].push({ ...ev, phaseName: name }); + } } else { if (grouped[lastNamedPhase]) grouped[lastNamedPhase].push({ ...ev, phaseName: "Unknown" }); } @@ -309,9 +324,15 @@ export function generateUiExport( const preProvEvents = sorted.filter(e => e.sequence <= splitSeq || e.eventType === "whiteglove_complete" ); + // Sort Part 2 events by timestamp (primary) then sequence (secondary) to handle + // potential duplicate sequences from agent sequence counter not persisted before reboot. const userEnrollEvents = sorted.filter(e => e.sequence > splitSeq && e.eventType !== "whiteglove_complete" - ); + ).sort((a, b) => { + const tCmp = (a.timestamp ?? "").localeCompare(b.timestamp ?? ""); + if (tCmp !== 0) return tCmp; + return (a.sequence ?? 0) - (b.sequence ?? 0); + }); // --- Pre-Provisioning Part --- lines.push(""); @@ -327,7 +348,7 @@ export function generateUiExport( lines.push("#".repeat(43)); lines.push(` USER ENROLLMENT PART [Resumed]`); lines.push("#".repeat(43)); - const userEnrollGrouped = groupEventsByPhase(userEnrollEvents, phaseNames, phaseOrder); + const userEnrollGrouped = groupEventsByPhase(userEnrollEvents, phaseNames, phaseOrder, { preventPhaseRegression: true }); renderPhaseBlocks(lines, userEnrollGrouped, phaseOrder, severityLabel); } else { // Mirrors the "Awaiting User Enrollment" banner shown in the UI when