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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-21 18:56:04 +02:00
parent 7ba67df1a1
commit a9f6ccd86d
5 changed files with 537 additions and 1 deletions
@@ -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 (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<rect x="2.5" y="7.5" width="16" height="9" rx="2" />
<path strokeLinecap="round" d="M21 10.5v3" />
{fillWidth > 0 && (
<rect
x="4.5"
y="9.5"
width={fillWidth}
height="5"
rx="0.5"
fill="currentColor"
stroke="none"
className={low ? "text-red-600" : ""}
/>
)}
</svg>
);
}
function BoltIcon() {
return (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24" stroke="none">
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
);
}
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 (
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
>
<path d="M2.25 8.51a13.5 13.5 0 0 1 19.5 0" className={tier >= 3 ? "" : "opacity-25"} />
<path d="M5.13 11.7a9.38 9.38 0 0 1 13.74 0" className={tier >= 2 ? "" : "opacity-25"} />
<path d="M8.01 14.89a5.25 5.25 0 0 1 7.98 0" className={tier >= 1 ? "" : "opacity-25"} />
<path d="M12 18.75h.008v.008H12z" strokeWidth={2.5} />
{offline && <path d="M4 4l16 16" />}
</svg>
);
}
function EthernetIcon() {
return (
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 9h16v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9z" />
<path d="M8 9V6h8v3" />
<path d="M7.5 12.5v2M10.5 12.5v2M13.5 12.5v2M16.5 12.5v2" />
</svg>
);
}
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 (
<span
className={chipClass(low)}
title={
pct === null
? "Battery level unknown"
: power.onAcPower
? `On AC power${power.isCharging ? ", charging" : ""}`
: "On battery"
}
>
<BatteryIcon percent={pct} low={low} />
{power.onAcPower && <BoltIcon />}
{pct !== null && <span className="tabular-nums">{pct}%</span>}
</span>
);
}
function NetworkChip({ network }: { network: DeviceNetworkStatus }) {
if (network.type === "None") {
return (
<span className={chipClass(true)} title="No network connection reported">
<WifiIcon offline />
<span>Offline</span>
</span>
);
}
if (network.type === "Ethernet") {
return (
<span className={chipClass()} title="Ethernet">
<EthernetIcon />
<span>Ethernet</span>
</span>
);
}
return (
<span className={chipClass()} title={network.ssid ? `WiFi: ${network.ssid}` : "WiFi"}>
<WifiIcon signalPercent={network.signalPercent} />
<span>WiFi</span>
{network.signalPercent !== undefined && (
<span className="tabular-nums text-gray-400">{network.signalPercent}%</span>
)}
</span>
);
}
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 (
<div className="mt-2 flex items-center justify-center gap-2">
{status.network !== null && <NetworkChip network={status.network} />}
{status.power !== null && <PowerChip power={status.power} />}
</div>
);
}
@@ -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<string, unknown>,
): 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 });
});
});
@@ -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 };
}
@@ -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<DeviceStatus>(() => computeDeviceStatus(events), [events]);
const currentInstall = useMemo<CurrentInstall | null>(() => {
const installTypes = new Set([
"app_install_started",
@@ -278,5 +282,6 @@ export function useProgressDerivedData(
currentInstall,
installElapsedMs,
overallProgress,
deviceStatus,
};
}
@@ -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}
</p>
{/* Live chips only while in progress — on finished sessions the
last-known values would masquerade as current state. */}
{session.status === "InProgress" && <DeviceStatusChips status={deviceStatus} />}
</div>
</div>