feat(web): session detail — the Download, Install and Script panels mark where the user phase begins: a quiet "Device phase" caption over the first row and a "User phase" divider before the first row that started after the device entered Account Setup, so user-targeted apps and user-context scripts stand out; sessions without a user phase render unchanged

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-11 17:28:41 +02:00
parent b84c467255
commit a22c26eb9d
9 changed files with 185 additions and 20 deletions
@@ -13,6 +13,7 @@ import PerformanceChart from '../../components/PerformanceChart';
import DownloadProgress from '../../components/DownloadProgress';
import InstallProgress from '../../components/InstallProgress';
import ScriptExecutions from '../../components/ScriptExecutions';
import { findUserPhaseStartMs } from '@/lib/userPhaseBoundary';
import { useLatestVersions } from '@/lib/useLatestVersions';
import { useScriptDisplayNames } from '@/lib/scriptDisplayNames';
import { api } from "@/lib/api";
@@ -215,6 +216,8 @@ function SessionDetailContent() {
// without a terminal event stop being "still running" — it becomes incomplete, its timer
// bounded by the agent's last report. Live sessions pass null and keep ticking.
const observedUntilMs = isTerminalStatus(displayStatus) ? lastObservedAtMs : null;
// One boundary for the Download / Install / Script panels, so all three split at the same moment.
const userPhaseStartMs = findUserPhaseStartMs(events);
// All phase keys currently present. For WhiteGlove sessions we use prefixed keys
// (pre-X, user-X) to avoid collisions.
@@ -675,6 +678,7 @@ function SessionDetailContent() {
latestBootstrapVersion={latestBootstrapVersion}
displayNamesByRefKey={scriptDisplayNamesByRefKey}
observedUntilMs={observedUntilMs}
userPhaseStartMs={userPhaseStartMs}
/>
</div>
)}
@@ -688,6 +692,7 @@ function SessionDetailContent() {
)}
summaryStats={appSummaryStats}
observedUntilMs={observedUntilMs}
userPhaseStartMs={userPhaseStartMs}
/>
</div>
)}
@@ -707,6 +712,7 @@ function SessionDetailContent() {
)}
summaryStats={appSummaryStats}
observedUntilMs={observedUntilMs}
userPhaseStartMs={userPhaseStartMs}
/>
</div>
)}
@@ -1,9 +1,11 @@
"use client";
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { partitionHistoricReplayEvents, type ReplayInputEvent } from "@/lib/historicReplay";
import TruncatedLabel from "@/components/TruncatedLabel";
import PendingAppRow from "@/components/PendingAppRow";
import PhaseDivider from "@/components/PhaseDivider";
import { userPhaseSplitIndex } from "@/lib/userPhaseBoundary";
import { shouldSkipLowBytesTotal, shouldSkipNoActivity, hasByteActivity } from "@/lib/downloadProgressFilters";
import { formatBytes, formatThroughput, formatDuration } from "@/lib/formatting";
import DoBreakdownBar from "./DoBreakdownBar";
@@ -64,6 +66,8 @@ interface DownloadProgressProps {
// Epoch ms of the agent's last report, passed only once the session is terminal. A download
// that never finished by then is incomplete, not active.
observedUntilMs?: number | null;
// Epoch ms the device entered Account Setup; null when the session has no user phase.
userPhaseStartMs?: number | null;
}
interface DoStats {
@@ -147,7 +151,7 @@ function effectiveDurationMs(dl: DownloadItem): number {
return 0;
}
export default function DownloadProgress({ events, summaryStats, observedUntilMs = null }: DownloadProgressProps) {
export default function DownloadProgress({ events, summaryStats, observedUntilMs = null, userPhaseStartMs = null }: DownloadProgressProps) {
// Legacy-agent guard: drop download events replayed from a previous enrollment's IME log.
// Silent (empty finals set) — the InstallProgress panel already reports the hidden count
// for the same apps; a second note here would double-report them.
@@ -352,6 +356,8 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
? summaryStats.installed + summaryStats.installing + summaryStats.failed
: null;
const userSplit = userPhaseSplitIndex(filteredDownloads, d => d.startedMs, userPhaseStartMs);
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<button
@@ -454,12 +460,18 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
</button>
{expanded && <div className="space-y-3 mt-4">
{filteredDownloads.map((dl) => {
{userSplit > 0 && <PhaseDivider phase="device" />}
{filteredDownloads.map((dl, i) => {
const progressPercent = dl.bytesTotal > 0
? Math.min(100, (dl.bytesDownloaded / dl.bytesTotal) * 100)
: (dl.isComplete ? 100 : 0);
return <DownloadItem key={dl.appName} download={dl} progressPercent={progressPercent} />;
return (
<Fragment key={dl.appName}>
{i === userSplit && <PhaseDivider phase="user" />}
<DownloadItem download={dl} progressPercent={progressPercent} />
</Fragment>
);
})}
{showPending && pendingNames.map((name) => (
<PendingAppRow key={`pending|${name}`} name={name} />
@@ -1,11 +1,13 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { getErrorCodeEntry, formatErrorCode, errorCodeTooltip } from "@/utils/errorCodeMap";
import { partitionHistoricReplayEvents } from "@/lib/historicReplay";
import { applyObservationEnd, buildInstallItems, isRebootOrRetryClass, type InstallEvent, type InstallItem, type InstallSource } from "@/lib/installProgress";
import TruncatedLabel from "@/components/TruncatedLabel";
import PendingAppRow from "@/components/PendingAppRow";
import PhaseDivider from "@/components/PhaseDivider";
import { userPhaseSplitIndex } from "@/lib/userPhaseBoundary";
interface SummaryStats {
totalApps?: number;
@@ -24,6 +26,8 @@ interface InstallProgressProps {
// Epoch ms of the agent's last report, passed only once the session is terminal. A row still
// installing at that point renders as Incomplete instead of ticking against the wall clock.
observedUntilMs?: number | null;
// Epoch ms the device entered Account Setup; null when the session has no user phase.
userPhaseStartMs?: number | null;
}
// Finals counted by the historic-replay partition — one per hidden install, so the note
@@ -59,7 +63,7 @@ function formatDuration(ms: number): string {
return `${hours}h ${remainingMinutes}m`;
}
export default function InstallProgress({ events, summaryStats, observedUntilMs = null }: InstallProgressProps) {
export default function InstallProgress({ events, summaryStats, observedUntilMs = null, userPhaseStartMs = null }: InstallProgressProps) {
// Legacy-agent guard: split off app events replayed from a previous enrollment's IME log
// (newer agents suppress them at the source) so week-old installs never render as current.
// office_*/realmjoin_* events never carry rejectedSourceTimestamp and pass through untouched.
@@ -129,6 +133,8 @@ export default function InstallProgress({ events, summaryStats, observedUntilMs
const totalFromSummary = summaryStats?.totalApps;
const installedFromSummary = summaryStats?.installed;
const userSplit = userPhaseSplitIndex(filteredInstalls, i => Date.parse(i.firstSeenAt), userPhaseStartMs);
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<button
@@ -227,8 +233,12 @@ export default function InstallProgress({ events, summaryStats, observedUntilMs
</button>
{expanded && <div className="space-y-3 mt-4">
{filteredInstalls.map((item) => (
<InstallItemRow key={item.key} item={item} />
{userSplit > 0 && <PhaseDivider phase="device" />}
{filteredInstalls.map((item, i) => (
<Fragment key={item.key}>
{i === userSplit && <PhaseDivider phase="user" />}
<InstallItemRow item={item} />
</Fragment>
))}
{showPending && pendingNames.map((name) => (
<PendingAppRow key={`pending|${name}`} name={name} />
@@ -0,0 +1,22 @@
// Quiet section marker for the progress panels: rows above started in the device part of the
// enrollment, rows below after the device entered Account Setup (see lib/userPhaseBoundary).
const PHASES = {
device: {
label: "Device phase",
title: "Started before the device entered Account Setup — the device part of the Enrollment Status Page.",
},
user: {
label: "User phase",
title: "Started after the device entered Account Setup — the user part of the Enrollment Status Page.",
},
} as const;
export default function PhaseDivider({ phase }: { phase: keyof typeof PHASES }) {
const { label, title } = PHASES[phase];
return (
<div className="flex items-center gap-2" title={title} role="separator" aria-label={label}>
<span className="text-[11px] font-medium uppercase tracking-wider text-gray-400 whitespace-nowrap">{label}</span>
<div className="h-px flex-1 bg-gray-200" />
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { compareVersions } from "@/utils/bootstrapVersion";
import {
applyObservationEnd,
@@ -22,6 +22,8 @@ import {
} from "@/lib/scriptExecutions";
import { lookupScriptDisplayName, type DisplayNamesByRefKey } from "@/lib/scriptDisplayNames";
import { getErrorCodeEntry, formatErrorCode, errorCodeTooltip } from "@/utils/errorCodeMap";
import { userPhaseSplitIndex } from "@/lib/userPhaseBoundary";
import PhaseDivider from "@/components/PhaseDivider";
interface ScriptExecutionsProps {
events: ScriptInputEvent[];
@@ -41,9 +43,11 @@ interface ScriptExecutionsProps {
// Epoch ms of the agent's last report, passed only once the session is terminal. A script
// still running at that point renders as Incomplete instead of ticking against the wall clock.
observedUntilMs?: number | null;
// Epoch ms the device entered Account Setup; null when the session has no user phase.
userPhaseStartMs?: number | null;
}
export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey, observedUntilMs = null }: ScriptExecutionsProps) {
export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey, observedUntilMs = null, userPhaseStartMs = null }: ScriptExecutionsProps) {
// Legacy-agent guard: split off script events replayed from a previous enrollment's IME
// log (newer agents suppress them at the source) so week-old runs never render as current
// executions. The muted note below keeps the gap explainable.
@@ -70,6 +74,8 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
const platformCount = cards.filter(c => c.scriptType === "platform").length;
const remediationCount = cards.filter(c => c.scriptType === "remediation").length;
const userSplit = userPhaseSplitIndex(cards, c => Date.parse(c.timestamp), userPhaseStartMs);
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<button
@@ -130,14 +136,17 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
{expanded && (
<div className="space-y-3 mt-4">
{cards.map((card) => (
<ScriptCardView
key={scriptCardKey(card)}
card={card}
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
displayNamesByRefKey={displayNamesByRefKey}
/>
{userSplit > 0 && <PhaseDivider phase="device" />}
{cards.map((card, i) => (
<Fragment key={scriptCardKey(card)}>
{i === userSplit && <PhaseDivider phase="user" />}
<ScriptCardView
card={card}
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
displayNamesByRefKey={displayNamesByRefKey}
/>
</Fragment>
))}
{historicCount > 0 && (
<div
@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import { findUserPhaseStartMs, userPhaseSplitIndex } from "@/lib/userPhaseBoundary";
import { buildInstallItems, type InstallEvent } from "@/lib/installProgress";
function phaseEvt(phase: number, timestamp: string) {
return { phase, timestamp };
}
function appEvt(eventType: string, timestamp: string, appName: string): InstallEvent {
return { eventType, timestamp, data: { appName, appId: appName } };
}
describe("findUserPhaseStartMs", () => {
it("returns the first Account Setup / Apps (User) declaration", () => {
const ms = findUserPhaseStartMs([
phaseEvt(2, "2026-09-11T06:57:00Z"),
phaseEvt(-1, "2026-09-11T06:58:00Z"),
phaseEvt(5, "2026-09-11T07:05:00Z"),
phaseEvt(4, "2026-09-11T07:01:50Z"),
]);
expect(ms).toBe(Date.parse("2026-09-11T07:01:50Z"));
});
it("is null for sessions without a user phase (device-only, pre-provisioning part 1, device preparation)", () => {
expect(findUserPhaseStartMs([phaseEvt(2, "2026-09-11T06:57:00Z"), phaseEvt(3, "2026-09-11T06:58:00Z"), phaseEvt(6, "2026-09-11T07:00:00Z")])).toBeNull();
expect(findUserPhaseStartMs([])).toBeNull();
});
it("ignores declarations with an unparseable timestamp", () => {
expect(findUserPhaseStartMs([phaseEvt(4, "not-a-date")])).toBeNull();
});
});
describe("userPhaseSplitIndex", () => {
const boundary = Date.parse("2026-09-11T07:00:00Z");
const rows = ["06:58", "06:59", "07:00", "07:03"].map(t => Date.parse(`2026-09-11T${t}:00Z`));
it("points at the first row that started at or after the boundary", () => {
expect(userPhaseSplitIndex(rows, r => r, boundary)).toBe(2);
});
it("is -1 without a boundary or when every row started before it", () => {
expect(userPhaseSplitIndex(rows, r => r, null)).toBe(-1);
expect(userPhaseSplitIndex(rows.slice(0, 2), r => r, boundary)).toBe(-1);
});
it("is 0 when the whole list belongs to the user phase", () => {
expect(userPhaseSplitIndex(rows.slice(2), r => r, boundary)).toBe(0);
});
it("never lets a row without a start open the user section", () => {
expect(userPhaseSplitIndex([NaN, rows[3]], r => r, boundary)).toBe(1);
});
});
describe("InstallItem.firstSeenAt", () => {
it("keeps the row's first event time, so an install that finishes after the boundary stays in the device phase", () => {
const items = buildInstallItems([
appEvt("app_install_started", "2026-09-11T06:59:00Z", "Device App"),
appEvt("app_install_completed", "2026-09-11T07:02:00Z", "Device App"),
appEvt("app_install_started", "2026-09-11T07:04:00Z", "User App"),
]);
expect(items.map(i => i.firstSeenAt)).toEqual(["2026-09-11T06:59:00Z", "2026-09-11T07:04:00Z"]);
const boundary = findUserPhaseStartMs([phaseEvt(4, "2026-09-11T07:01:50Z")]);
expect(userPhaseSplitIndex(items, i => Date.parse(i.firstSeenAt), boundary)).toBe(1);
});
});
@@ -100,6 +100,8 @@ export interface InstallItem {
isIncomplete: boolean;
observedMs?: number;
firstSeenIndex: number;
// Timestamp of the row's first event — places the row before or after the user-phase boundary.
firstSeenAt: string;
eventData?: Record<string, unknown>;
}
@@ -190,7 +192,7 @@ export function buildInstallItems(events: InstallEvent[]): InstallItem[] {
const isCompleted = (type === "app_install_completed" && !isSkippedCompletion) || type === "office_install_completed" || (type === "realmjoin_package_completed" && !rjFailed);
const isFailed = type === "app_install_failed" || type === "office_install_failed" || rjFailed;
const base = { key, source, appName, appId, intent: intent ?? existing?.intent, isIncomplete: false };
const base = { key, source, appName, appId, intent: intent ?? existing?.intent, isIncomplete: false, firstSeenAt: existing?.firstSeenAt ?? eventTs };
if (isStarted) {
// Don't reset an app that already completed — later batch re-scans
@@ -0,0 +1,36 @@
// Where the enrollment's user part begins, and where that moment falls in a chronological row
// list. Shared by the Download / Install / Script panels so all three split at the same instant
// the phase timeline draws between Device Setup and Account Setup.
// Account Setup (4) and its Apps (User) sub-phase (5). Only phase-declaration events carry a
// phase — app and script events are Unknown (-1) — so this never keys off an app event.
const USER_PHASE_IDS: ReadonlySet<number> = new Set([4, 5]);
export function findUserPhaseStartMs(
events: ReadonlyArray<{ phase?: number | null; timestamp: string }>
): number | null {
let min: number | null = null;
for (const e of events) {
if (e.phase == null || !USER_PHASE_IDS.has(e.phase)) continue;
const t = Date.parse(e.timestamp);
if (Number.isFinite(t) && (min == null || t < min)) min = t;
}
return min;
}
/**
* Index of the first row that started at or after the user-phase boundary; -1 when there is no
* boundary or no such row. Rows must be in chronological order (every panel lists by first
* appearance), so everything from the index on belongs to the user phase.
*/
export function userPhaseSplitIndex<T>(
rows: readonly T[],
startMsOf: (row: T) => number,
boundaryMs: number | null
): number {
if (boundaryMs == null) return -1;
return rows.findIndex(row => {
const t = startMsOf(row);
return Number.isFinite(t) && t >= boundaryMs;
});
}