diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx index 3fbb79db..92d80e50 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx @@ -16,7 +16,7 @@ interface PhaseTimelineProps { } export default function PhaseTimeline({ currentPhase, completedPhases, events = [], sessionStatus, enrollmentType, isPreProvisioned, isSkipUserStatusPage, onPhaseClick }: PhaseTimelineProps) { - const { phases, useSkipLayout } = resolvePhaseLayout({ enrollmentType, isSkipUserStatusPage, isPreProvisioned }); + const { phases, useSkipLayout, skippedPhaseIds } = resolvePhaseLayout({ enrollmentType, isSkipUserStatusPage, isPreProvisioned }); // Derive current activity for the active phase from events const getCurrentActivity = (phaseId: number): string | null => { @@ -148,8 +148,9 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = let end: number | null = null; if (useSkipLayout) { - // V1 SkipUser: Device Setup(2) → FinalizingSetup(6) → AppsUser(5) → Complete(7) - // No AccountSetup(4). AppsUser is a top-level phase (not sub-phase). + // V1 SkipUser: Device Setup(2) → Apps Device(3) → [AccountSetup(4) skipped] → + // [AppsUser(5) skipped] → Finalizing(6) → Complete(7). + // AccountSetup/AppsUser render as 'skipped' so no duration is shown for them. switch (phaseId) { case 2: // Device Setup → ends when Finalizing Setup starts end = getFirstEventTime(6) ?? getLastEventTime(3) ?? getLastEventTime(2); @@ -157,11 +158,8 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = case 3: // Apps (Device) — sub-phase, own event range end = getLastEventTime(3); break; - case 6: // Finalizing Setup → ends when Apps (User) or Complete starts - end = getFirstEventTime(5) ?? getFirstEventTime(7) ?? getLastEventTime(6); - break; - case 5: // Apps (User) — top-level, ends when Complete starts - end = getFirstEventTime(7) ?? getLastEventTime(5); + case 6: // Finalizing Setup → ends when Complete starts (AppsUser is skipped) + end = getFirstEventTime(7) ?? getLastEventTime(6); break; default: end = getLastEventTime(phaseId); @@ -220,10 +218,13 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = return idx >= 0 ? idx : -1; }; - // Derive the furthest-reached phase seen in events using display-order index + // Derive the furthest-reached phase seen in events using display-order index. + // Skipped phases (e.g. AccountSetup/AppsUser when SkipUserStatusPage=true) are excluded: + // background events with their phase tag (e.g. esp_phase_changed → AccountSetup) must + // not pull the active marker into a phase the policy says is skipped. const maxEventPhase = (() => { const realPhases = events - .filter(e => e.phase >= 0 && e.phase <= 7) + .filter(e => e.phase >= 0 && e.phase <= 7 && !skippedPhaseIds.has(e.phase)) .map(e => e.phase); let maxPhase = -1; @@ -270,10 +271,19 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = return maxEventPhase >= 0 ? maxEventPhase : currentPhase; } if (maxEventPhase < 0) return currentPhase; + // If backend currentPhase is itself skipped (e.g. an esp_phase_changed → AccountSetup + // event nudged Sessions.CurrentPhase=4 but SkipUserStatusPage marks it as skipped), + // trust the highest non-skipped event phase instead so the active marker stays where + // real activity is happening. + if (skippedPhaseIds.has(currentPhase)) return maxEventPhase; return phaseIndex(maxEventPhase) >= phaseIndex(currentPhase) ? maxEventPhase : currentPhase; })(); const getPhaseStatus = (phaseId: number) => { + // Skipped wins over everything: ESP policy explicitly disables the phase, so it can + // never be "current" or "completed" regardless of stray phase-tagged events. + if (skippedPhaseIds.has(phaseId)) return 'skipped'; + // Agent starts at MDM phase (3) - Pre-Flight(0)/Network(1)/Identity(2) are inferred as completed // since the machine reached MDM enrollment if (phaseId >= 0 && phaseId <= 2) return 'completed'; // Pre-Flight(0), Network(1), Identity(2) @@ -310,6 +320,7 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = case 'completed': return 'bg-green-500 text-white border-green-500'; case 'current': return 'bg-blue-500 text-white border-blue-500 ring-4 ring-blue-200'; case 'failed': return 'bg-red-500 text-white border-red-500 ring-4 ring-red-200'; + case 'skipped': return 'bg-gray-100 text-gray-400 border-gray-300 border-dashed'; case 'pending': return 'bg-gray-200 text-gray-500 border-gray-300'; default: return 'bg-gray-200 text-gray-500 border-gray-300'; } @@ -334,7 +345,9 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = const status = getPhaseStatus(phase.id); const prevStatus = index > 0 ? getPhaseStatus(phases[index - 1].id) : null; const connColor = index > 0 ? getConnectorColor(phases[index - 1].id) : ''; - const showArrow = prevStatus === 'current' || prevStatus === 'failed'; + // Suppress the directional arrow when pointing into a skipped phase — the user is not + // actually moving toward it; the run will visually jump past skipped phases. + const showArrow = (prevStatus === 'current' || prevStatus === 'failed') && status !== 'skipped'; const isSplitBoundary = isWhiteGloveV1 && index === splitIndex; return ( @@ -369,14 +382,15 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = )} {/* Circle + Labels — clickable to scroll to phase in timeline */}
onPhaseClick(phase.name) : undefined} + className={onPhaseClick && status !== 'skipped' ? 'cursor-pointer' : ''} + onClick={onPhaseClick && status !== 'skipped' ? () => onPhaseClick(phase.name) : undefined} + title={status === 'skipped' ? 'Skipped — SkipUserStatusPage policy is enabled' : undefined} >
- {status === 'completed' ? '✓' : status === 'failed' ? '✕' : phase.id + 1} + {status === 'completed' ? '✓' : status === 'failed' ? '✕' : status === 'skipped' ? '⊘' : phase.id + 1}
-
+
{phase.shortName}
{(status === 'completed' || status === 'failed') && getPhaseDuration(phase.id) && ( @@ -390,6 +404,9 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = {status === 'failed' && (
Failed
)} + {status === 'skipped' && ( +
Skipped
+ )} {status === 'current' && getCurrentActivity(phase.id) && (
diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/phaseConstants.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/phaseConstants.ts index 774a8bbd..2428f6f4 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/phaseConstants.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/phaseConstants.ts @@ -10,16 +10,11 @@ export const V1_PHASES = [ { id: 7, name: "Complete", shortName: "Complete" }, ]; -// V1 with SkipUserStatusPage=true: no Account Setup, Finalizing comes before Apps (User) -export const V1_SKIP_USER_PHASES = [ - { id: 0, name: "Start", shortName: "Start" }, - { id: 1, name: "Device Preparation", shortName: "Device Preparation" }, - { id: 2, name: "Device Setup", shortName: "Device Setup" }, - { id: 3, name: "Apps (Device)", shortName: "Apps (Device)" }, - { id: 6, name: "Finalizing Setup", shortName: "Finalizing" }, - { id: 5, name: "Apps (User)", shortName: "Apps (User)" }, - { id: 7, name: "Complete", shortName: "Complete" }, -]; +// V1 with SkipUserStatusPage=true: AccountSetup (4) and AppsUser (5) are visualized as +// skipped — the ESP policy explicitly disables the user status page. Background events +// for those phases (e.g. esp_phase_changed → AccountSetup) still appear in the timeline, +// but the progress bar reflects the policy intent. +export const V1_SKIP_USER_PHASE_IDS: ReadonlySet = new Set([4, 5]); export const V2_PHASES = [ { id: 0, name: "Start", shortName: "Start" }, @@ -35,20 +30,21 @@ export const V2_PHASE_ORDER = ["Start", "Device Preparation", "App Installation" export type PhaseDescriptor = { id: number; name: string; shortName: string }; // WhiteGlove (IsPreProvisioned=true) runs the full user enrollment in Part 2 even when the -// admin set SkipUserStatusPage=true — the collapsed layout only applies to pure device-only +// admin set SkipUserStatusPage=true — skip-marking only applies to pure device-only // deployments. V2 always uses its simplified phase set. export function resolvePhaseLayout(params: { enrollmentType?: string; isSkipUserStatusPage?: boolean; isPreProvisioned?: boolean; -}): { phases: PhaseDescriptor[]; useSkipLayout: boolean } { +}): { phases: PhaseDescriptor[]; useSkipLayout: boolean; skippedPhaseIds: ReadonlySet } { if (params.enrollmentType === "v2") { - return { phases: V2_PHASES, useSkipLayout: false }; + return { phases: V2_PHASES, useSkipLayout: false, skippedPhaseIds: new Set() }; } const useSkipLayout = !!params.isSkipUserStatusPage && !params.isPreProvisioned; return { - phases: useSkipLayout ? V1_SKIP_USER_PHASES : V1_PHASES, + phases: V1_PHASES, useSkipLayout, + skippedPhaseIds: useSkipLayout ? V1_SKIP_USER_PHASE_IDS : new Set(), }; } diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/phaseLayout.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/phaseLayout.test.ts index 581ce29d..a856a9c3 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/phaseLayout.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/phaseLayout.test.ts @@ -2,13 +2,13 @@ import { describe, it, expect } from "vitest"; import { resolvePhaseLayout, V1_PHASES, - V1_SKIP_USER_PHASES, + V1_SKIP_USER_PHASE_IDS, V2_PHASES, } from "../../app/sessions/[sessionId]/utils/phaseConstants"; describe("resolvePhaseLayout", () => { - it("returns full V1 layout for WhiteGlove + SkipUserStatusPage=true (user enrollment runs in Part 2)", () => { - const { phases, useSkipLayout } = resolvePhaseLayout({ + it("returns full V1 layout with no skipped phases for WhiteGlove + SkipUserStatusPage=true (user enrollment runs in Part 2)", () => { + const { phases, useSkipLayout, skippedPhaseIds } = resolvePhaseLayout({ enrollmentType: "v1", isSkipUserStatusPage: true, isPreProvisioned: true, @@ -16,51 +16,58 @@ describe("resolvePhaseLayout", () => { expect(useSkipLayout).toBe(false); expect(phases).toBe(V1_PHASES); expect(phases.map((p) => p.id)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); - expect(phases.find((p) => p.id === 4)?.name).toBe("Account Setup"); + expect(skippedPhaseIds.size).toBe(0); }); - it("returns collapsed V1_SKIP_USER layout for Non-WhiteGlove + SkipUserStatusPage=true (pure device-only)", () => { - const { phases, useSkipLayout } = resolvePhaseLayout({ + it("returns full V1 layout with AccountSetup+AppsUser flagged as skipped for Non-WhiteGlove + SkipUserStatusPage=true", () => { + const { phases, useSkipLayout, skippedPhaseIds } = resolvePhaseLayout({ enrollmentType: "v1", isSkipUserStatusPage: true, isPreProvisioned: false, }); expect(useSkipLayout).toBe(true); - expect(phases).toBe(V1_SKIP_USER_PHASES); - // No Account Setup (id=4), Finalizing (id=6) comes before Apps (User) (id=5) - expect(phases.map((p) => p.id)).toEqual([0, 1, 2, 3, 6, 5, 7]); - expect(phases.find((p) => p.id === 4)).toBeUndefined(); + expect(phases).toBe(V1_PHASES); + // Standard order — skip is purely visual, driven by skippedPhaseIds + expect(phases.map((p) => p.id)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + expect(skippedPhaseIds).toBe(V1_SKIP_USER_PHASE_IDS); + expect(skippedPhaseIds.has(4)).toBe(true); + expect(skippedPhaseIds.has(5)).toBe(true); + expect(skippedPhaseIds.has(3)).toBe(false); + expect(skippedPhaseIds.has(6)).toBe(false); }); it("returns full V1 layout for standard V1 without SkipUserStatusPage", () => { - const { phases, useSkipLayout } = resolvePhaseLayout({ + const { phases, useSkipLayout, skippedPhaseIds } = resolvePhaseLayout({ enrollmentType: "v1", isSkipUserStatusPage: false, isPreProvisioned: false, }); expect(useSkipLayout).toBe(false); expect(phases).toBe(V1_PHASES); + expect(skippedPhaseIds.size).toBe(0); }); - it("returns V2 layout for V2 enrollment regardless of SkipUserStatusPage/IsPreProvisioned", () => { + it("returns V2 layout with no skipped phases for V2 enrollment regardless of SkipUserStatusPage/IsPreProvisioned", () => { for (const flags of [ { isSkipUserStatusPage: false, isPreProvisioned: false }, { isSkipUserStatusPage: true, isPreProvisioned: false }, { isSkipUserStatusPage: false, isPreProvisioned: true }, { isSkipUserStatusPage: true, isPreProvisioned: true }, ]) { - const { phases, useSkipLayout } = resolvePhaseLayout({ enrollmentType: "v2", ...flags }); + const { phases, useSkipLayout, skippedPhaseIds } = resolvePhaseLayout({ enrollmentType: "v2", ...flags }); expect(useSkipLayout).toBe(false); expect(phases).toBe(V2_PHASES); + expect(skippedPhaseIds.size).toBe(0); } }); it("treats undefined/missing SkipUserStatusPage as not-skip", () => { - const { phases, useSkipLayout } = resolvePhaseLayout({ + const { phases, useSkipLayout, skippedPhaseIds } = resolvePhaseLayout({ enrollmentType: "v1", isPreProvisioned: false, }); expect(useSkipLayout).toBe(false); expect(phases).toBe(V1_PHASES); + expect(skippedPhaseIds.size).toBe(0); }); });