mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
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:
+32
-15
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user