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