mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
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:
+1
-1
Submodule internal updated: ee5b6604af...66ebb0511e
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user