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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-03-03 10:13:37 +01:00
parent c11018f7ce
commit 443decc4d9
3 changed files with 71 additions and 10 deletions
@@ -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<string, EnrollmentEvent[]>, orderedPhases: [] as string[] },
[userEnrollEvents, isWhiteGloveSession, phaseNamesMap, phaseOrder]
);
@@ -1268,8 +1269,23 @@ function DeviceDetailsCard({ events }: { events: EnrollmentEvent[] }) {
{hasValue(autopilotProfile.CloudAssignedOobeConfig) && <DetailRow label="OOBE Config" value={`${autopilotProfile.CloudAssignedOobeConfig}`} />}
{hasValue(autopilotProfile.ZtdRegistrationId) && <DetailRow label="ZTD Registration ID" value={`${autopilotProfile.ZtdRegistrationId}`} />}
{hasValue(autopilotProfile.AadDeviceId) && <DetailRow label="AAD Device ID" value={`${autopilotProfile.AadDeviceId}`} />}
{autopilotProfile.AutopilotMode !== undefined && (
<DetailRow label="Autopilot Mode" value={autopilotProfile.autopilotModeLabel ?? (
`${autopilotProfile.AutopilotMode}` === "0" ? "User Driven" :
`${autopilotProfile.AutopilotMode}` === "1" ? "Self Deploying" :
`${autopilotProfile.AutopilotMode}` === "2" ? "Pre-Provisioning (White Glove)" :
`${autopilotProfile.AutopilotMode}`
)} />
)}
{autopilotProfile.CloudAssignedDomainJoinMethod !== undefined && (
<DetailRow label="Domain Join Method" value={`${autopilotProfile.CloudAssignedDomainJoinMethod}` === "0" ? "Entra Join" : `${autopilotProfile.CloudAssignedDomainJoinMethod}`} />
<DetailRow label="Domain Join Method" value={autopilotProfile.domainJoinMethodLabel ?? (
`${autopilotProfile.CloudAssignedDomainJoinMethod}` === "0" ? "Entra Join" :
`${autopilotProfile.CloudAssignedDomainJoinMethod}` === "1" ? "Hybrid Azure AD Join" :
`${autopilotProfile.CloudAssignedDomainJoinMethod}`
)} />
)}
{autopilotProfile.HybridJoinSkipDCConnectivityCheck !== undefined && (
<DetailRow label="Skip DC Connectivity Check" value={`${autopilotProfile.HybridJoinSkipDCConnectivityCheck}` === "1" ? "Yes" : "No"} />
)}
{autopilotProfile.CloudAssignedForcedEnrollment !== undefined && (
<DetailRow label="Forced Enrollment" value={`${autopilotProfile.CloudAssignedForcedEnrollment}` === "1" ? "Yes" : "No"} />
@@ -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<number, string>,
phaseOrder: string[]
phaseOrder: string[],
options?: { preventPhaseRegression?: boolean }
): { eventsByPhase: Record<string, EnrollmentEvent[]>; 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<string, EnrollmentEvent[]> = {};
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;
}
@@ -177,16 +177,31 @@ export function generateRuleResultsCsvExport(results: RuleResultCsvData[]): stri
function groupEventsByPhase(
eventsToGroup: SessionExportEvent[],
phaseNames: Record<number, string>,
phaseOrder: string[]
phaseOrder: string[],
options?: { preventPhaseRegression?: boolean }
): Record<string, SessionExportEvent[]> {
const preventRegression = options?.preventPhaseRegression === true;
const grouped: Record<string, SessionExportEvent[]> = {};
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