From a9f6ccd86d1a8a0e95ae557ed15f69d562eee644 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 21 Aug 2026 18:56:04 +0200 Subject: [PATCH] feat(web): power + network status chips on the progress portal Taskbar-style chips in the progress page status header showing the device's last-reported power state (AC/battery with percent, low-battery warning) and network state (WiFi with SSID tooltip + signal tiers, Ethernet, offline). Derived purely client-side by folding the event stream the portal already downloads (power_state_check/change, network_interface_info, network_state_change, wifi_signal_info) - no backend, API, or auth change. Chips render only while InProgress and disappear gracefully for sessions from agents without these events; devices without a battery show no power chip (Windows-tray parity). Co-Authored-By: Claude Fable 5 --- .../progress/components/DeviceStatusChips.tsx | 151 ++++++++++++ .../hooks/__tests__/deviceStatus.test.ts | 227 ++++++++++++++++++ .../app/progress/hooks/deviceStatus.ts | 149 ++++++++++++ .../progress/hooks/useProgressDerivedData.ts | 5 + .../app/progress/page.tsx | 6 +- 5 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 src/Web/autopilot-monitor-web/app/progress/components/DeviceStatusChips.tsx create mode 100644 src/Web/autopilot-monitor-web/app/progress/hooks/__tests__/deviceStatus.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/progress/hooks/deviceStatus.ts diff --git a/src/Web/autopilot-monitor-web/app/progress/components/DeviceStatusChips.tsx b/src/Web/autopilot-monitor-web/app/progress/components/DeviceStatusChips.tsx new file mode 100644 index 00000000..c0e09421 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/progress/components/DeviceStatusChips.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { DeviceStatus, DeviceNetworkStatus, DevicePowerStatus } from "../hooks/deviceStatus"; + +/** + * Taskbar-style power/network status chips for the progress page header. + * + * Values are "last reported by the agent", not continuously sampled: power + * events ride the normal batched upload cadence (only warning-level + * transitions upload immediately), so AC/battery flips can lag by tens of + * seconds — and an agent that dies cannot report going offline. + */ + +function BatteryIcon({ percent, low }: { percent: number | null; low: boolean }) { + // Fill width scales into the 13-unit body (x 4..17); null percent renders outline only. + const fillWidth = percent === null ? 0 : Math.max(0, Math.min(13, (13 * percent) / 100)); + return ( + + + + {fillWidth > 0 && ( + + )} + + ); +} + +function BoltIcon() { + return ( + + + + ); +} + +function WifiIcon({ signalPercent, offline }: { signalPercent?: number; offline?: boolean }) { + // Arc tiers mirror the taskbar: unknown signal renders as fully connected. + const tier = offline ? 0 : signalPercent === undefined ? 3 : signalPercent >= 66 ? 3 : signalPercent >= 33 ? 2 : signalPercent > 0 ? 1 : 0; + return ( + + = 3 ? "" : "opacity-25"} /> + = 2 ? "" : "opacity-25"} /> + = 1 ? "" : "opacity-25"} /> + + {offline && } + + ); +} + +function EthernetIcon() { + return ( + + + + + + ); +} + +function chipClass(danger?: boolean) { + return `inline-flex items-center gap-1 rounded-full bg-white/70 border border-black/5 px-2 py-0.5 text-xs ${ + danger ? "text-red-600" : "text-gray-600" + }`; +} + +function PowerChip({ power }: { power: DevicePowerStatus }) { + // Windows-tray parity: desktops/VMs without a battery show no power chip. + if (!power.hasBattery) return null; + const pct = power.batteryPercent; + const low = pct !== null && pct < 20 && !power.onAcPower; + return ( + + + {power.onAcPower && } + {pct !== null && {pct}%} + + ); +} + +function NetworkChip({ network }: { network: DeviceNetworkStatus }) { + if (network.type === "None") { + return ( + + + Offline + + ); + } + if (network.type === "Ethernet") { + return ( + + + Ethernet + + ); + } + return ( + + + WiFi + {network.signalPercent !== undefined && ( + {network.signalPercent}% + )} + + ); +} + +export function DeviceStatusChips({ status }: { status: DeviceStatus }) { + const showPower = status.power !== null && status.power.hasBattery; + // Old agents emit none of the source events — render nothing, not an empty row. + if (!showPower && status.network === null) return null; + return ( +
+ {status.network !== null && } + {status.power !== null && } +
+ ); +} 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" && }