feat(web): visualize SkipUserStatusPage as skipped phases

Account Setup (4) and Apps (User) (5) now render with a dashed gray
circle and "Skipped" label when SkipUserStatusPage=true and the session
is non-WhiteGlove. Replaces the previously umsortiertes layout
([0,1,2,3,6,5,7]) with the standard order plus a skippedPhaseIds set
so the progress bar reflects the ESP policy directly. Filters skipped
phases out of maxEventPhase and falls back when Backend.CurrentPhase
itself points at a skipped phase, keeping the active marker on the
real activity (e.g. AppsDevice) instead of getting dragged into a
phase the policy disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-01 18:18:18 +02:00
parent 4a996070b8
commit ac3b3d82db
3 changed files with 63 additions and 43 deletions
@@ -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 */}
<div
className={onPhaseClick ? 'cursor-pointer' : ''}
onClick={onPhaseClick ? () => 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}
>
<div className={`relative z-10 w-12 h-12 rounded-full flex items-center justify-center border-2 transition-all font-semibold mx-auto ${getPhaseColor(status)}`}>
{status === 'completed' ? '✓' : status === 'failed' ? '✕' : phase.id + 1}
{status === 'completed' ? '✓' : status === 'failed' ? '✕' : status === 'skipped' ? '⊘' : phase.id + 1}
</div>
<div className="mt-3 text-center">
<div className="text-xs font-medium text-gray-700 whitespace-nowrap">
<div className={`text-xs font-medium whitespace-nowrap ${status === 'skipped' ? 'text-gray-400' : 'text-gray-700'}`}>
{phase.shortName}
</div>
{(status === 'completed' || status === 'failed') && getPhaseDuration(phase.id) && (
@@ -390,6 +404,9 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
{status === 'failed' && (
<div className="mt-0.5 text-[10px] text-red-500 font-semibold">Failed</div>
)}
{status === 'skipped' && (
<div className="mt-0.5 text-[10px] text-gray-400 italic">Skipped</div>
)}
{status === 'current' && getCurrentActivity(phase.id) && (
<div className="mt-1 max-w-[140px]">
<div className="text-[10px] text-blue-600 font-medium line-clamp-2 animate-pulse" title={getCurrentActivity(phase.id) || undefined}>
@@ -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<number> = 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<number> } {
if (params.enrollmentType === "v2") {
return { phases: V2_PHASES, useSkipLayout: false };
return { phases: V2_PHASES, useSkipLayout: false, skippedPhaseIds: new Set<number>() };
}
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<number>(),
};
}
@@ -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);
});
});