+ );
+}
diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/__tests__/deviceStatus.test.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/__tests__/deviceStatus.test.ts
new file mode 100644
index 00000000..cc34b0b1
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/progress/hooks/__tests__/deviceStatus.test.ts
@@ -0,0 +1,227 @@
+import { describe, it, expect } from "vitest";
+import { computeDeviceStatus } from "../deviceStatus";
+import { EnrollmentEvent } from "@/types";
+
+/**
+ * computeDeviceStatus feeds the progress page's taskbar-style power/network
+ * chips: a sequence-ordered fold where later events win, "unknown" battery
+ * percent maps to null, and sessions from agents without these events must
+ * yield nulls so the chips simply don't render.
+ */
+
+function ev(
+ sequence: number,
+ eventType: string,
+ data?: Record,
+): EnrollmentEvent {
+ return {
+ eventId: `evt-${sequence}`,
+ sessionId: "session-1",
+ timestamp: "2026-08-21T10:00:00Z",
+ eventType,
+ severity: "Info",
+ source: "Test",
+ phase: 2,
+ message: `event ${sequence}`,
+ sequence,
+ data,
+ };
+}
+
+describe("computeDeviceStatus", () => {
+ it("returns nulls for no events or only unrelated events", () => {
+ expect(computeDeviceStatus([])).toEqual({ power: null, network: null });
+ expect(
+ computeDeviceStatus([ev(1, "info_event", { foo: "bar" }), ev(2, "download_progress")]),
+ ).toEqual({ power: null, network: null });
+ });
+
+ it("captures a desktop baseline (hasBattery false) from power_state_check", () => {
+ const { power } = computeDeviceStatus([
+ ev(1, "power_state_check", { onAcPower: true, hasBattery: false, isCharging: false }),
+ ]);
+ expect(power).toEqual({
+ onAcPower: true,
+ hasBattery: false,
+ batteryPercent: null,
+ isCharging: false,
+ });
+ });
+
+ it("lets a later power_state_change win over the baseline and implies hasBattery", () => {
+ const { power } = computeDeviceStatus([
+ ev(1, "power_state_check", {
+ onAcPower: true,
+ hasBattery: true,
+ batteryPercent: 90,
+ isCharging: true,
+ }),
+ ev(2, "power_state_change", {
+ transition: "ac_to_battery",
+ onAcPower: false,
+ batteryPercent: 87,
+ isCharging: false,
+ }),
+ ]);
+ expect(power).toEqual({
+ onAcPower: false,
+ hasBattery: true,
+ batteryPercent: 87,
+ isCharging: false,
+ });
+ });
+
+ it("tracks threshold_crossed percent updates while on battery", () => {
+ const { power } = computeDeviceStatus([
+ ev(1, "power_state_change", {
+ transition: "ac_to_battery",
+ onAcPower: false,
+ batteryPercent: 55,
+ isCharging: false,
+ }),
+ ev(2, "power_state_change", {
+ transition: "threshold_crossed",
+ thresholdPercent: 30,
+ onAcPower: false,
+ batteryPercent: 29,
+ isCharging: false,
+ }),
+ ]);
+ expect(power?.batteryPercent).toBe(29);
+ expect(power?.onAcPower).toBe(false);
+ });
+
+ it('maps the literal "unknown" battery percent to null on both event types', () => {
+ const check = computeDeviceStatus([
+ ev(1, "power_state_check", { onAcPower: false, hasBattery: true, batteryPercent: "unknown" }),
+ ]);
+ expect(check.power?.batteryPercent).toBeNull();
+
+ const change = computeDeviceStatus([
+ ev(1, "power_state_change", { onAcPower: false, batteryPercent: "unknown" }),
+ ]);
+ expect(change.power?.batteryPercent).toBeNull();
+ });
+
+ it("skips a power_state_check that carries a probe error", () => {
+ const { power } = computeDeviceStatus([
+ ev(1, "power_state_change", { onAcPower: false, batteryPercent: 42, isCharging: false }),
+ ev(2, "power_state_check", { onAcPower: true, hasBattery: false, error: "GetSystemPowerStatus failed" }),
+ ]);
+ expect(power).toEqual({
+ onAcPower: false,
+ hasBattery: true,
+ batteryPercent: 42,
+ isCharging: false,
+ });
+ });
+
+ it("reads Ethernet from network_interface_info", () => {
+ const { network } = computeDeviceStatus([
+ ev(1, "network_interface_info", { connectionType: "Ethernet", linkSpeedMbps: 1000 }),
+ ]);
+ expect(network).toEqual({ type: "Ethernet" });
+ });
+
+ it("merges wifi_signal_info into a WiFi connection and clears on switch to Ethernet", () => {
+ const events = [
+ ev(1, "network_interface_info", { connectionType: "WiFi" }),
+ ev(2, "wifi_signal_info", { wifiSsid: "CorpNet", wifiSignalPercent: 84 }),
+ ];
+ expect(computeDeviceStatus(events).network).toEqual({
+ type: "WiFi",
+ ssid: "CorpNet",
+ signalPercent: 84,
+ });
+
+ events.push(
+ ev(3, "network_state_change", {
+ changeType: "type_change",
+ after_connectionType: "Ethernet",
+ hasNetwork: true,
+ }),
+ );
+ expect(computeDeviceStatus(events).network).toEqual({ type: "Ethernet" });
+ });
+
+ it("goes offline on hasNetwork false and recovers on network_restored", () => {
+ const events = [
+ ev(1, "network_interface_info", { connectionType: "WiFi" }),
+ ev(2, "network_state_change", {
+ changeType: "network_lost",
+ after_connectionType: "None",
+ hasNetwork: false,
+ }),
+ ];
+ expect(computeDeviceStatus(events).network).toEqual({ type: "None" });
+
+ events.push(
+ ev(3, "network_state_change", {
+ changeType: "network_restored",
+ after_connectionType: "WiFi",
+ after_wifiSsid: "CorpNet",
+ hasNetwork: true,
+ }),
+ );
+ expect(computeDeviceStatus(events).network).toEqual({ type: "WiFi", ssid: "CorpNet" });
+ });
+
+ it('treats the "n/a" SSID sentinel as absent and carries the prior ssid', () => {
+ const { network } = computeDeviceStatus([
+ ev(1, "wifi_signal_info", { wifiSsid: "CorpNet", wifiSignalPercent: 70 }),
+ ev(2, "network_state_change", {
+ changeType: "ip_change",
+ after_connectionType: "WiFi",
+ after_wifiSsid: "n/a",
+ hasNetwork: true,
+ }),
+ ]);
+ expect(network).toEqual({ type: "WiFi", ssid: "CorpNet", signalPercent: 70 });
+ });
+
+ it("keeps ssid/signal across the enrollment-end network_interface_info re-emit", () => {
+ const { network } = computeDeviceStatus([
+ ev(1, "network_interface_info", { connectionType: "WiFi" }),
+ ev(2, "wifi_signal_info", { wifiSsid: "CorpNet", wifiSignalPercent: 61 }),
+ ev(3, "network_interface_info", { connectionType: "WiFi", linkSpeedMbps: 866 }),
+ ]);
+ expect(network).toEqual({ type: "WiFi", ssid: "CorpNet", signalPercent: 61 });
+ });
+
+ it("maps no_active_interface to None", () => {
+ const { network } = computeDeviceStatus([
+ ev(1, "network_interface_info", { status: "no_active_interface" }),
+ ]);
+ expect(network).toEqual({ type: "None" });
+ });
+
+ it("coerces string-serialized booleans and numbers", () => {
+ const { power, network } = computeDeviceStatus([
+ ev(1, "power_state_check", {
+ onAcPower: "true",
+ hasBattery: "true",
+ batteryPercent: "73",
+ isCharging: "false",
+ }),
+ ev(2, "wifi_signal_info", { wifiSsid: "CorpNet", wifiSignalPercent: "58" }),
+ ev(3, "network_state_change", {
+ changeType: "network_lost",
+ after_connectionType: "None",
+ hasNetwork: "false",
+ }),
+ ]);
+ expect(power).toEqual({
+ onAcPower: true,
+ hasBattery: true,
+ batteryPercent: 73,
+ isCharging: false,
+ });
+ expect(network).toEqual({ type: "None" });
+ });
+
+ it("ignores events without a data payload", () => {
+ expect(
+ computeDeviceStatus([ev(1, "power_state_change"), ev(2, "network_state_change")]),
+ ).toEqual({ power: null, network: null });
+ });
+});
diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/deviceStatus.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/deviceStatus.ts
new file mode 100644
index 00000000..c9e56b65
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/progress/hooks/deviceStatus.ts
@@ -0,0 +1,149 @@
+import { EnrollmentEvent } from "@/types";
+
+export interface DevicePowerStatus {
+ onAcPower: boolean;
+ hasBattery: boolean;
+ /** null when the agent reported the literal "unknown". */
+ batteryPercent: number | null;
+ isCharging: boolean;
+}
+
+export type NetworkKind = "WiFi" | "Ethernet" | "None";
+
+export interface DeviceNetworkStatus {
+ type: NetworkKind;
+ ssid?: string;
+ signalPercent?: number;
+}
+
+export interface DeviceStatus {
+ power: DevicePowerStatus | null;
+ network: DeviceNetworkStatus | null;
+}
+
+// Some agent paths serialize payload values as strings, so every read is coerced.
+function asBool(value: unknown): boolean {
+ return value === true || value === "true" || value === "True";
+}
+
+function asIntOrNull(value: unknown): number | null {
+ if (typeof value === "number" && Number.isFinite(value)) return Math.round(value);
+ if (typeof value === "string") {
+ const parsed = parseInt(value, 10);
+ if (!isNaN(parsed)) return parsed;
+ }
+ return null;
+}
+
+// Non-empty string, filtering the agent's "n/a"/"None" placeholder sentinels.
+function asStr(value: unknown): string | undefined {
+ if (typeof value !== "string") return undefined;
+ const trimmed = value.trim();
+ if (!trimmed || trimmed === "n/a" || trimmed === "None") return undefined;
+ return trimmed;
+}
+
+function asNetworkKind(value: unknown): NetworkKind {
+ return value === "WiFi" || value === "Ethernet" ? value : "None";
+}
+
+// Snapshot of the WiFi details worth carrying across events that don't restate
+// them. Extracted as a typed helper — inlining these reads into the `network`
+// assignments makes TS's control-flow analysis circular (TS7022).
+function wifiCarry(prev: DeviceNetworkStatus | null): { ssid?: string; signalPercent?: number } {
+ return prev !== null && prev.type === "WiFi"
+ ? { ssid: prev.ssid, signalPercent: prev.signalPercent }
+ : {};
+}
+
+/**
+ * Latest-known device power + network state, folded from the raw event stream
+ * (already merged by eventId and sorted by sequence — later events win).
+ *
+ * Sources: power_state_check (once per agent start), power_state_change (live
+ * watcher, battery devices only), network_interface_info (start + enrollment
+ * end), network_state_change (live), wifi_signal_info (WiFi NICs).
+ * Sessions from agents without these events yield { power: null, network: null }.
+ */
+export function computeDeviceStatus(events: EnrollmentEvent[]): DeviceStatus {
+ let power: DevicePowerStatus | null = null;
+ let network: DeviceNetworkStatus | null = null;
+
+ for (const evt of events) {
+ const d = evt.data;
+ if (!d) continue;
+
+ switch (evt.eventType) {
+ case "power_state_check": {
+ // A failed probe carries defaults, not truth — keep the previous state.
+ if (d.error !== undefined && d.error !== null) break;
+ power = {
+ onAcPower: asBool(d.onAcPower),
+ hasBattery: asBool(d.hasBattery),
+ batteryPercent: asIntOrNull(d.batteryPercent),
+ isCharging: asBool(d.isCharging),
+ };
+ break;
+ }
+ case "power_state_change": {
+ // Emitted only on devices with a battery; the payload carries no hasBattery.
+ power = {
+ onAcPower: asBool(d.onAcPower),
+ hasBattery: true,
+ batteryPercent: asIntOrNull(d.batteryPercent),
+ isCharging: asBool(d.isCharging),
+ };
+ break;
+ }
+ case "network_interface_info": {
+ if (d.status === "no_active_interface") {
+ network = { type: "None" };
+ break;
+ }
+ const type = asNetworkKind(d.connectionType);
+ if (type === "None") break; // malformed/legacy payload — keep previous state
+ if (type === "WiFi") {
+ // This event never carries an SSID; keep the one wifi_signal_info gave us
+ // so the enrollment-end re-emit doesn't blank the chip.
+ const prior = wifiCarry(network);
+ network = { type, ssid: prior.ssid, signalPercent: prior.signalPercent };
+ } else {
+ network = { type };
+ }
+ break;
+ }
+ case "network_state_change": {
+ if (d.hasNetwork === false || d.hasNetwork === "false" || d.hasNetwork === "False") {
+ network = { type: "None" };
+ break;
+ }
+ const type = asNetworkKind(d.after_connectionType);
+ if (type === "WiFi") {
+ const prior = wifiCarry(network);
+ const ssid = asStr(d.after_wifiSsid) ?? prior.ssid;
+ network = {
+ type,
+ ssid,
+ // A signal reading only stays meaningful on the same network.
+ signalPercent: prior.ssid === ssid ? prior.signalPercent : undefined,
+ };
+ } else {
+ network = { type };
+ }
+ break;
+ }
+ case "wifi_signal_info": {
+ // Existence of this event implies the active NIC is WiFi.
+ const prior = wifiCarry(network);
+ network = {
+ type: "WiFi",
+ ssid: asStr(d.wifiSsid) ?? prior.ssid,
+ signalPercent: asIntOrNull(d.wifiSignalPercent) ?? prior.signalPercent,
+ };
+ break;
+ }
+ }
+ }
+
+ return { power, network };
+}
diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressDerivedData.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressDerivedData.ts
index 36bbfb8f..a3c9d717 100644
--- a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressDerivedData.ts
+++ b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressDerivedData.ts
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { EnrollmentEvent, Session } from "@/types";
+import { computeDeviceStatus, DeviceStatus } from "./deviceStatus";
// Minimal structural view of the event payloads this hook reads — only fields used in a
// typed position are declared (the agent serializes them as strings); everything else
@@ -116,6 +117,7 @@ export interface UseProgressDerivedDataReturn {
currentInstall: CurrentInstall | null;
installElapsedMs: number | null;
overallProgress: number;
+ deviceStatus: DeviceStatus;
}
/**
@@ -171,6 +173,8 @@ export function useProgressDerivedData(
[events]
);
+ const deviceStatus = useMemo(() => computeDeviceStatus(events), [events]);
+
const currentInstall = useMemo(() => {
const installTypes = new Set([
"app_install_started",
@@ -278,5 +282,6 @@ export function useProgressDerivedData(
currentInstall,
installElapsedMs,
overallProgress,
+ deviceStatus,
};
}
diff --git a/src/Web/autopilot-monitor-web/app/progress/page.tsx b/src/Web/autopilot-monitor-web/app/progress/page.tsx
index b2120d0f..7f9a1de1 100644
--- a/src/Web/autopilot-monitor-web/app/progress/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/progress/page.tsx
@@ -10,6 +10,7 @@ import { useProgressSearch } from "./hooks/useProgressSearch";
import { useProgressEvents } from "./hooks/useProgressEvents";
import { useProgressSignalR } from "./hooks/useProgressSignalR";
import { useProgressDerivedData } from "./hooks/useProgressDerivedData";
+import { DeviceStatusChips } from "./components/DeviceStatusChips";
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
@@ -71,7 +72,7 @@ export default function ProgressPortalPage() {
addNotification,
});
- const { appSummary, currentDownload, currentInstall, installElapsedMs, overallProgress } =
+ const { appSummary, currentDownload, currentInstall, installElapsedMs, overallProgress, deviceStatus } =
useProgressDerivedData(events, session);
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -253,6 +254,9 @@ export default function ProgressPortalPage() {
{session.deviceName || session.serialNumber} |{" "}
{session.manufacturer} {session.model}
+ {/* Live chips only while in progress — on finished sessions the
+ last-known values would masquerade as current state. */}
+ {session.status === "InProgress" && }