mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user