mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user