diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionDerivedData.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionDerivedData.ts index 458c9085..8a7baa80 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionDerivedData.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionDerivedData.ts @@ -3,7 +3,7 @@ import { useMemo } from "react"; import { EnrollmentEvent, Session } from "@/types"; import { V1_PHASE_NAMES, V2_PHASE_NAMES, V1_PHASE_ORDER, V2_PHASE_ORDER } from "../utils/phaseConstants"; -import { computeWhiteGloveSplitSequence, groupEventsByPhase } from "../utils/eventHelpers"; +import { computeWhiteGloveDurations, computeWhiteGloveSplitSequence, groupEventsByPhase } from "../utils/eventHelpers"; interface PhaseGrouping { eventsByPhase: Record; @@ -176,33 +176,8 @@ export function useSessionDerivedData( if (!isWhiteGloveSession) { return { preProvDuration: null as string | null, userEnrollDuration: null as string | null, combinedDuration: null as string | null }; } - - const preProvEvts = whiteGloveSplitSequence < 0 ? events : events.filter(e => e.sequence <= whiteGloveSplitSequence); - const userEnrollEvts = whiteGloveSplitSequence < 0 ? [] : events.filter(e => e.sequence > whiteGloveSplitSequence); - - const calcMs = (evts: EnrollmentEvent[]): number => { - if (evts.length === 0) return 0; - const ts = evts.map(e => new Date(e.timestamp).getTime()); - return Math.max(...ts) - Math.min(...ts); - }; - - const fmt = (ms: number): string | null => { - const sec = Math.round(ms / 1000); - if (sec < 1) return null; - if (sec < 60) return `${sec}s`; - if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`; - return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`; - }; - - const preProvMs = calcMs(preProvEvts); - const userEnrollMs = calcMs(userEnrollEvts); - - return { - preProvDuration: fmt(preProvMs), - userEnrollDuration: fmt(userEnrollMs), - combinedDuration: fmt(preProvMs + userEnrollMs), - }; - }, [events, isWhiteGloveSession, whiteGloveSplitSequence]); + return computeWhiteGloveDurations(events, whiteGloveSplitSequence, session?.startedAt); + }, [events, isWhiteGloveSession, whiteGloveSplitSequence, session?.startedAt]); // Group events by phase — single timeline for normal sessions, two groups for WhiteGlove const { eventsByPhase, orderedPhases } = useMemo(() => { diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/eventHelpers.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/eventHelpers.ts index 517b5c1f..79d0f1b3 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/eventHelpers.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/utils/eventHelpers.ts @@ -66,6 +66,69 @@ export function computeWhiteGloveSplitSequence(events: EnrollmentEvent[]): numbe return -1; } +// Pure helper — per-block durations for WhiteGlove sessions. +// Duration 1 = pre-provisioning, Duration 2 = user enrollment, combined = D1 + D2 +// (the sealed pause between reseal and resume is excluded). +// +// Block spans are anchored on the SEQUENCE-canonical edge events (first/last event by +// sequence), NOT on min/max over all timestamps in the block. Two real-world cases break +// the min/max approach: +// - Events re-emitted after the Part-2 resume can legitimately carry historical event +// times (e.g. an app_install_started the ImeLogTracker re-parses from the IME log, +// stamped with the Part-1 install start). min() would stretch the User Enrollment +// block back across the sealed pause. +// - A single clock-skewed source timestamp (e.g. an IME log line with a wrong timezone +// bias that slips under the agent's 1h future-skew clamp) would stretch the block +// the other way. +// Sequence is the canonical event order, so the block edges by sequence are the robust +// span anchors. Part 1 additionally starts at session.startedAt (registration) when +// provided — the same anchor the backend uses for DurationSeconds. +export interface WhiteGloveDurations { + preProvDuration: string | null; + userEnrollDuration: string | null; + combinedDuration: string | null; +} + +export function computeWhiteGloveDurations( + events: EnrollmentEvent[], + splitSequence: number, + startedAt?: string, +): WhiteGloveDurations { + const preProvEvts = splitSequence < 0 ? events : events.filter(e => e.sequence <= splitSequence); + const userEnrollEvts = splitSequence < 0 ? [] : events.filter(e => e.sequence > splitSequence); + + const spanMs = (evts: EnrollmentEvent[], startOverrideMs?: number): number => { + if (evts.length === 0) return 0; + let first = evts[0]; + let last = evts[0]; + for (const e of evts) { + if (e.sequence < first.sequence) first = e; + if (e.sequence > last.sequence) last = e; + } + const start = startOverrideMs ?? new Date(first.timestamp).getTime(); + const end = new Date(last.timestamp).getTime(); + return Math.max(0, end - start); + }; + + const fmt = (ms: number): string | null => { + const sec = Math.round(ms / 1000); + if (sec < 1) return null; + if (sec < 60) return `${sec}s`; + if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`; + return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`; + }; + + const startedAtMs = startedAt ? new Date(startedAt).getTime() : NaN; + const preProvMs = spanMs(preProvEvts, Number.isFinite(startedAtMs) ? startedAtMs : undefined); + const userEnrollMs = spanMs(userEnrollEvts); + + return { + preProvDuration: fmt(preProvMs), + userEnrollDuration: fmt(userEnrollMs), + combinedDuration: fmt(preProvMs + userEnrollMs), + }; +} + // 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. // diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/whiteGloveDurations.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/whiteGloveDurations.test.ts new file mode 100644 index 00000000..e7ac60e0 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/whiteGloveDurations.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { computeWhiteGloveDurations } from "../../app/sessions/[sessionId]/utils/eventHelpers"; +import type { EnrollmentEvent } from "@/types"; + +// Fixture builder — sequence and timestamp are the only fields that matter here. +function ev(seq: number, eventType: string, isoTs: string): EnrollmentEvent { + return { + eventId: `e${seq}`, + sessionId: "s1", + timestamp: isoTs, + eventType, + severity: "Info", + source: "Agent", + phase: 0, + message: eventType, + sequence: seq, + }; +} + +describe("computeWhiteGloveDurations", () => { + it("computes both blocks from the sequence-canonical edge events", () => { + const events = [ + ev(1, "agent_started", "2026-07-23T08:31:00Z"), + ev(2, "esp_phase_changed", "2026-07-23T08:35:00Z"), + ev(3, "agent_shutting_down", "2026-07-23T09:28:00Z"), // Part 1 ends (reseal) + ev(4, "agent_started", "2026-07-23T11:00:00Z"), // Part 2 boot after sealed pause + ev(5, "enrollment_complete", "2026-07-23T11:56:00Z"), + ]; + const d = computeWhiteGloveDurations(events, 3); + expect(d.preProvDuration).toBe("57m 0s"); + expect(d.userEnrollDuration).toBe("56m 0s"); + expect(d.combinedDuration).toBe("1h 53m"); // pause 09:28→11:00 excluded + }); + + it("ignores a historical event time re-emitted into the user block (post-resume IME re-parse)", () => { + // Real case (session fcb44595): after the Part-2 resume the ImeLogTracker re-emits an + // app_install_started carrying the ORIGINAL Part-1 install time. min/max over timestamps + // stretched the user block back across the sealed pause (56m became 3h18m). + const events = [ + ev(1, "agent_started", "2026-07-23T08:31:00Z"), + ev(2, "agent_shutting_down", "2026-07-23T09:28:00Z"), + ev(3, "agent_started", "2026-07-23T11:00:00Z"), + ev(4, "app_install_started", "2026-07-23T08:37:57Z"), // historical event time, Part-2 sequence + ev(5, "enrollment_complete", "2026-07-23T11:56:00Z"), + ]; + const d = computeWhiteGloveDurations(events, 2); + expect(d.userEnrollDuration).toBe("56m 0s"); + expect(d.combinedDuration).toBe("1h 53m"); + }); + + it("ignores a clock-skewed outlier timestamp inside the pre-provisioning block", () => { + // Real case (session fcb44595): a do_telemetry event carried a +1h source timestamp + // (IME log line with a wrong timezone bias, just under the agent's future-skew clamp). + // min/max stretched Part 1 from 57m to 1h44m. + const events = [ + ev(1, "agent_started", "2026-07-23T08:31:00Z"), + ev(2, "do_telemetry", "2026-07-23T10:13:42Z"), // +1h skewed, mid-block by sequence + ev(3, "agent_shutting_down", "2026-07-23T09:28:00Z"), + ev(4, "agent_started", "2026-07-23T11:00:00Z"), + ev(5, "enrollment_complete", "2026-07-23T11:56:00Z"), + ]; + const d = computeWhiteGloveDurations(events, 3); + expect(d.preProvDuration).toBe("57m 0s"); + }); + + it("anchors Part 1 at session.startedAt when provided (backend DurationSeconds parity)", () => { + const events = [ + ev(1, "agent_started", "2026-07-23T08:31:00Z"), + ev(2, "agent_shutting_down", "2026-07-23T09:28:00Z"), + ev(3, "agent_started", "2026-07-23T11:00:00Z"), + ev(4, "enrollment_complete", "2026-07-23T11:56:00Z"), + ]; + const d = computeWhiteGloveDurations(events, 2, "2026-07-23T08:29:41Z"); + expect(d.preProvDuration).toBe("58m 19s"); // 08:29:41 → 09:28:00 + }); + + it("treats everything as Part 1 while no split point exists yet", () => { + const events = [ + ev(1, "agent_started", "2026-07-23T08:31:00Z"), + ev(2, "esp_phase_changed", "2026-07-23T08:45:00Z"), + ]; + const d = computeWhiteGloveDurations(events, -1); + expect(d.preProvDuration).toBe("14m 0s"); + expect(d.userEnrollDuration).toBeNull(); + expect(d.combinedDuration).toBe("14m 0s"); + }); + + it("returns nulls for an empty event list", () => { + const d = computeWhiteGloveDurations([], -1); + expect(d.preProvDuration).toBeNull(); + expect(d.userEnrollDuration).toBeNull(); + expect(d.combinedDuration).toBeNull(); + }); + + it("never returns a negative span when edge timestamps are disordered", () => { + const events = [ + ev(1, "agent_started", "2026-07-23T09:00:00Z"), + ev(2, "agent_shutting_down", "2026-07-23T08:59:00Z"), // straggler stamped before the boot + ]; + const d = computeWhiteGloveDurations(events, 2, "2026-07-23T09:30:00Z"); + expect(d.preProvDuration).toBeNull(); // clamped to 0 → formatted as null + }); +});