From 6940cfb3a81b57320c194689e62de86bb5adad7f Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 31 Jul 2026 20:24:34 +0200 Subject: [PATCH] Add resilient dual-radio network failover --- .env.example | 10 + PLAN.md | 3 +- README.md | 32 +++ install.sh | 33 +++- package.json | 2 +- scripts/pi-car-companion-network-setup | 94 +++++++++ scripts/pi-car-companion-update | 3 + server/src/app.ts | 36 ++++ server/src/config.ts | 13 ++ server/src/network-controller.ts | 238 +++++++++++++++++++++++ server/src/network.ts | 116 +++++++++++ server/tests/app.test.ts | 32 +++ server/tests/config.test.ts | 8 + server/tests/network.test.ts | 52 +++++ server/tests/update.test.ts | 5 + systemd/pi-car-companion-network.service | 30 +++ web/src/App.tsx | 121 +++++++++++- web/src/api.ts | 26 +++ web/src/styles.css | 14 ++ 19 files changed, 857 insertions(+), 11 deletions(-) create mode 100644 scripts/pi-car-companion-network-setup create mode 100644 server/src/network-controller.ts create mode 100644 server/src/network.ts create mode 100644 server/tests/network.test.ts create mode 100644 systemd/pi-car-companion-network.service diff --git a/.env.example b/.env.example index 7011e22..def0257 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,16 @@ COOKIE_SECURE=false ALLOWED_ORIGINS=http://mg4pi.local:8787 UPDATE_STATUS_PATH=/var/lib/pi-car-companion/update-status.json VERSION_FILE=/opt/pi-car-companion/current/REVISION +# Disabled until `sudo pi-car-companion-network-setup` creates the recovery hotspot. +NETWORK_MANAGER_ENABLED=false +NETWORK_STATUS_PATH=/var/lib/pi-car-companion/network-status.json +NETWORK_COMMAND_PATH=/var/lib/pi-car-companion/network-command.json +NETWORK_HOTSPOT_INTERFACE=wlan0 +NETWORK_UPSTREAM_INTERFACE=wlan1 +NETWORK_HOTSPOT_CONNECTION=pi-car-hotspot +NETWORK_PROBE_TIMEOUT_SECONDS=60 +NETWORK_STABILITY_SECONDS=10 +NETWORK_POLL_SECONDS=5 # Disabled by default. Leave ADB_SERIAL empty to auto-select only when exactly one device is attached. ADB_ENABLED=false ADB_PATH=adb diff --git a/PLAN.md b/PLAN.md index e5b81b3..837d961 100644 --- a/PLAN.md +++ b/PLAN.md @@ -49,6 +49,7 @@ Core operation must work without cloud access. Optional remote access is a later - Controlled artifact upload, listing, download, and deletion - SHA-256 metadata and storage quota reporting - Wi-Fi status/reconnect where supported by the host +- Dual-radio car hotspot with USB-upstream preference, 60-second built-in fallback, and automatic offline recovery - Log rotation/pruning job - Companion service restart job - Reboot and shutdown actions with strong confirmation @@ -321,7 +322,7 @@ The repository contains a working MVP and tracks remaining delivery work here. S ### Milestone 5 — Artifacts and operational actions - [ ] Implement controlled uploads/downloads/deletion, hashing, quotas, and metadata UI. -- [ ] Add Wi-Fi refresh/reconnect with capability detection. +- [~] Add Wi-Fi refresh/reconnect with capability detection. A disabled-by-default dual-radio NetworkManager controller, 60-second recovery watchdog, fixed dashboard actions, and status UI are implemented; destructive failover and unplug/replug behavior still require staged Pi and MG4 validation. - [ ] Add log pruning and companion-service restart jobs. - [ ] Add reboot and shutdown with explicit action-bound confirmation and narrowly scoped privilege configuration. - [ ] Exercise storage exhaustion, job timeout, service restart, and network-loss recovery. diff --git a/README.md b/README.md index 30108ec..3e632c1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ The current MVP slice includes: - a responsive 1920×720-oriented setup, login, and dashboard UI; - an idempotent Raspberry Pi installer with systemd start-on-boot support; - atomic, dashboard-triggered Git updates with automatic verification and rollback safety; +- optional dual-radio Wi-Fi failover with an always-recoverable offline car hotspot; - automated API security tests and reproducible quality commands. The ADB transport, job UI, artifacts, Android companion, and remaining operations work are tracked in [PLAN.md](./PLAN.md). @@ -153,6 +154,37 @@ Important values: - `ADB_ENABLED`: enables the read-only head-unit collector; defaults to `false` - `ADB_SERIAL`: optional exact serial; when empty, discovery requires exactly one attached device - `ADB_PATH` and `ADB_TIMEOUT_MS`: executable path and per-command timeout +- `NETWORK_MANAGER_ENABLED`: enables the separately supervised NetworkManager controller after explicit setup +- `NETWORK_STATUS_PATH` and `NETWORK_COMMAND_PATH`: validated state and fixed-action request files +- `NETWORK_HOTSPOT_INTERFACE` / `NETWORK_UPSTREAM_INTERFACE`: default to built-in `wlan0` and USB `wlan1` +- `NETWORK_PROBE_TIMEOUT_SECONDS`: maximum built-in upstream attempt before restoring the offline hotspot + +### Dual-radio car network + +The optional network controller prefers a USB Wi-Fi adapter for upstream internet and keeps the built-in adapter on a stable car hotspot. If the USB adapter disappears, the built-in adapter tries its saved upstream profiles for 60 seconds. When none connect, the controller restores the offline hotspot at `http://10.42.0.1:8787`. + +Automatic switching is disabled by default so installation cannot interrupt an existing SSH connection. First connect the USB adapter to a saved upstream profile: + +```bash +sudo nmcli --ask device wifi connect "HOME_SSID" ifname wlan1 name upstream-home +``` + +Then create the recovery hotspot and enable its controller: + +```bash +sudo pi-car-companion-network-setup +``` + +The helper requires `wlan1` to be connected before it enables switching, asks interactively for a unique hotspot password, and never places that password in the application configuration. Settings then shows the current mode and offers **Try upstream again** and **Restore hotspot** controls. + +Behavior by mode: + +- `wlan1` connected: `wlan0` hosts `MG4-Companion` and shares upstream access. +- `wlan1` unavailable: `wlan0` becomes upstream when a saved network connects. +- no upstream after 60 seconds: `wlan0` returns to the offline hotspot automatically. +- `wlan1` returns: it must connect and remain stable before `wlan0` returns to hotspot mode. + +Add phone hotspots as additional saved `wlan1` profiles. Dashboard actions only select existing NetworkManager profiles; Wi-Fi credentials are not accepted through the API. ### Read-only ADB setup diff --git a/install.sh b/install.sh index f7e1215..2cd8590 100755 --- a/install.sh +++ b/install.sh @@ -38,7 +38,7 @@ fi echo "[1/7] Installing operating-system packages" export DEBIAN_FRONTEND=noninteractive apt-get update -apt-get install -y ca-certificates curl gnupg git rsync sudo util-linux avahi-daemon sqlite3 android-tools-adb build-essential +apt-get install -y ca-certificates curl gnupg git rsync sudo util-linux avahi-daemon sqlite3 android-tools-adb build-essential network-manager node_major="" if command -v node >/dev/null 2>&1; then @@ -111,8 +111,10 @@ mv -Tf "${INSTALL_ROOT}/current.next" "${CURRENT}" echo "[5/7] Installing system services" install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update +install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service +install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service install -m 0440 "${SCRIPT_DIR}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null install -d -m 0755 /etc/avahi/services @@ -133,8 +135,34 @@ ADB_ENABLED=false ADB_PATH=adb ADB_SERIAL= ADB_TIMEOUT_MS=3000 +NETWORK_MANAGER_ENABLED=false +NETWORK_STATUS_PATH=${DATA_DIR}/network-status.json +NETWORK_COMMAND_PATH=${DATA_DIR}/network-command.json +NETWORK_HOTSPOT_INTERFACE=wlan0 +NETWORK_UPSTREAM_INTERFACE=wlan1 +NETWORK_HOTSPOT_CONNECTION=pi-car-hotspot +NETWORK_PROBE_TIMEOUT_SECONDS=60 +NETWORK_STABILITY_SECONDS=10 +NETWORK_POLL_SECONDS=5 EOF fi + +ensure_env_default() { + local key="$1" + local value="$2" + if ! grep -q "^${key}=" "${CONFIG_DIR}/companion.env"; then + printf '%s=%s\n' "${key}" "${value}" >> "${CONFIG_DIR}/companion.env" + fi +} +ensure_env_default NETWORK_MANAGER_ENABLED false +ensure_env_default NETWORK_STATUS_PATH "${DATA_DIR}/network-status.json" +ensure_env_default NETWORK_COMMAND_PATH "${DATA_DIR}/network-command.json" +ensure_env_default NETWORK_HOTSPOT_INTERFACE wlan0 +ensure_env_default NETWORK_UPSTREAM_INTERFACE wlan1 +ensure_env_default NETWORK_HOTSPOT_CONNECTION pi-car-hotspot +ensure_env_default NETWORK_PROBE_TIMEOUT_SECONDS 60 +ensure_env_default NETWORK_STABILITY_SECONDS 10 +ensure_env_default NETWORK_POLL_SECONDS 5 chown root:"${SERVICE_USER}" "${CONFIG_DIR}/companion.env" chmod 0640 "${CONFIG_DIR}/companion.env" @@ -148,6 +176,9 @@ echo "[6/7] Enabling start-on-boot services" systemctl daemon-reload systemctl enable --now avahi-daemon systemctl enable pi-car-companion.service +if grep -qx 'NETWORK_MANAGER_ENABLED=true' "${CONFIG_DIR}/companion.env"; then + systemctl enable --now pi-car-companion-network.service +fi systemctl restart pi-car-companion.service echo "[7/7] Verifying the service" diff --git a/package.json b/package.json index 0fe0d98..ea2adfc 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "typecheck": "npm run typecheck --workspaces --if-present", "test": "npm run test --workspaces --if-present", "build": "npm run build --workspaces --if-present", - "check:scripts": "bash -n install.sh scripts/pi-car-companion-update", + "check:scripts": "bash -n install.sh scripts/pi-car-companion-update scripts/pi-car-companion-network-setup", "check": "npm run check:scripts && npm run lint && npm run typecheck && npm test && npm run build" }, "devDependencies": { diff --git a/scripts/pi-car-companion-network-setup b/scripts/pi-car-companion-network-setup new file mode 100644 index 0000000..739c196 --- /dev/null +++ b/scripts/pi-car-companion-network-setup @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +CONFIG_FILE="/etc/pi-car-companion/companion.env" +HOTSPOT_CONNECTION="pi-car-hotspot" +HOTSPOT_INTERFACE="wlan0" +UPSTREAM_INTERFACE="wlan1" +HOTSPOT_ADDRESS="10.42.0.1/24" +HOTSPOT_SSID="${1:-MG4-Companion}" + +if [[ ${EUID} -ne 0 ]]; then + exec sudo -- "$0" "$@" +fi +if [[ ! -f "${CONFIG_FILE}" ]]; then + echo "Pi Car Companion is not installed. Run install.sh first." >&2 + exit 1 +fi +if [[ ! "${HOTSPOT_SSID}" =~ ^[[:alnum:]_.-]{1,32}$ ]]; then + echo "The hotspot SSID must contain 1-32 letters, numbers, dots, hyphens, or underscores." >&2 + exit 1 +fi +if [[ "$(nmcli --get-values GENERAL.STATE device show "${UPSTREAM_INTERFACE}" 2>/dev/null || true)" != 100* ]]; then + echo "${UPSTREAM_INTERFACE} must be connected to a saved upstream network before failover is enabled." >&2 + echo "Connect it first with: sudo nmcli --ask device wifi connect '' ifname ${UPSTREAM_INTERFACE} name upstream-home" >&2 + exit 1 +fi + +read -r -s -p "Password for ${HOTSPOT_SSID} (8-63 characters): " hotspot_password +echo +if (( ${#hotspot_password} < 8 || ${#hotspot_password} > 63 )) || [[ ! "${hotspot_password}" =~ ^[[:alnum:]_.-]+$ ]]; then + echo "The hotspot password must contain 8-63 letters, numbers, dots, hyphens, or underscores." >&2 + exit 1 +fi + +hotspot_file="/etc/NetworkManager/system-connections/${HOTSPOT_CONNECTION}.nmconnection" +hotspot_uuid="$(uuidgen)" +temporary_file="$(mktemp)" +trap 'rm -f -- "${temporary_file}"' EXIT +cat > "${temporary_file}" <> "${CONFIG_FILE}" + fi +} + +set_env NETWORK_MANAGER_ENABLED true +set_env NETWORK_HOTSPOT_INTERFACE "${HOTSPOT_INTERFACE}" +set_env NETWORK_UPSTREAM_INTERFACE "${UPSTREAM_INTERFACE}" +set_env NETWORK_HOTSPOT_CONNECTION "${HOTSPOT_CONNECTION}" +set_env NETWORK_PROBE_TIMEOUT_SECONDS 60 +set_env NETWORK_STABILITY_SECONDS 10 +set_env NETWORK_POLL_SECONDS 5 + +systemctl enable --now pi-car-companion-network.service +systemctl restart pi-car-companion.service + +upstream_address="$(nmcli --get-values IP4.ADDRESS device show "${UPSTREAM_INTERFACE}" | head -n 1)" +echo "Network failover is enabled." +echo "The dashboard will remain reachable through ${UPSTREAM_INTERFACE} at ${upstream_address:-its current address}." +echo "The car hotspot is ${HOTSPOT_SSID} and its dashboard address is http://10.42.0.1:8787." diff --git a/scripts/pi-car-companion-update b/scripts/pi-car-companion-update index d9a83f2..8d6912e 100755 --- a/scripts/pi-car-companion-update +++ b/scripts/pi-car-companion-update @@ -136,8 +136,10 @@ chown -R root:root "${release_final}" install_integration_files() { local source_release="$1" install -m 0755 "${source_release}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update + install -m 0755 "${source_release}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup install -m 0644 "${source_release}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service install -m 0644 "${source_release}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service + install -m 0644 "${source_release}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service install -m 0440 "${source_release}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion } @@ -175,3 +177,4 @@ if [[ "${healthy}" != "true" ]]; then fi write_status "success" "Update installed and the companion service is healthy." +systemctl try-restart pi-car-companion-network.service || true diff --git a/server/src/app.ts b/server/src/app.ts index 00c0f25..210003e 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -19,6 +19,7 @@ import { } from "./security.js"; import { collectSystemStatus } from "./status.js"; import { readUpdateStatus, triggerSystemUpdate } from "./update.js"; +import { networkActionSchema, readNetworkStatus, requestNetworkAction } from "./network.js"; import { collectAdbStatus } from "./adb.js"; import "./types.js"; @@ -313,6 +314,41 @@ export async function buildApp(config: AppConfig): Promise { return reply.code(202).send(run); }); + app.get("/api/network", async (request, reply) => { + if (!requireUser(request, reply)) return; + return readNetworkStatus(config); + }); + + app.post("/api/network/actions", async (request, reply) => { + if (!requireUser(request, reply)) return; + const parsed = z.object({ action: networkActionSchema }).safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: { code: "INVALID_NETWORK_ACTION", message: "That network action is not available" } }); + } + try { + const command = await requestNetworkAction(config, parsed.data.action, request.authUser!.id); + audit(database, { + actorUserId: request.authUser!.id, + action: `network.${parsed.data.action}`, + result: "success", + ipAddress: clientAddress(request), + details: { commandId: command.id } + }); + return reply.code(202).send({ accepted: true, commandId: command.id }); + } catch { + audit(database, { + actorUserId: request.authUser!.id, + action: `network.${parsed.data.action}`, + result: "failure", + ipAddress: clientAddress(request), + details: { reason: "controller_unavailable" } + }); + return reply.code(503).send({ + error: { code: "NETWORK_CONTROL_UNAVAILABLE", message: "Automatic network management is not configured" } + }); + } + }); + app.get("/api/system/update", async (request, reply) => { if (!requireUser(request, reply)) return; return readUpdateStatus(config); diff --git a/server/src/config.ts b/server/src/config.ts index 81f8e02..d6eab75 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -17,6 +17,9 @@ const schema = z.object({ ALLOWED_ORIGINS: z.string().default("http://mg4pi.local:8787"), UPDATE_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/update-status.json"), VERSION_FILE: z.string().default(resolve("REVISION")), + NETWORK_MANAGER_ENABLED: booleanFromString, + NETWORK_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/network-status.json"), + NETWORK_COMMAND_PATH: z.string().default("/var/lib/pi-car-companion/network-command.json"), ADB_ENABLED: booleanFromString, ADB_PATH: z.string().min(1).default("adb"), ADB_SERIAL: z.string().max(128).regex(/^[a-zA-Z0-9._:-]*$/).default(""), @@ -33,6 +36,11 @@ export type AppConfig = { allowedOrigins: ReadonlySet; updateStatusPath: string; versionFile: string; + network: { + enabled: boolean; + statusPath: string; + commandPath: string; + }; adb: AdbConfig; }; @@ -60,6 +68,11 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon allowedOrigins, updateStatusPath: parsed.UPDATE_STATUS_PATH, versionFile: parsed.VERSION_FILE, + network: { + enabled: parsed.NETWORK_MANAGER_ENABLED, + statusPath: parsed.NETWORK_STATUS_PATH, + commandPath: parsed.NETWORK_COMMAND_PATH + }, adb: { enabled: parsed.ADB_ENABLED, executable: parsed.ADB_PATH, diff --git a/server/src/network-controller.ts b/server/src/network-controller.ts new file mode 100644 index 0000000..c862a82 --- /dev/null +++ b/server/src/network-controller.ts @@ -0,0 +1,238 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { z } from "zod"; +import { + consumeNetworkCommand, + networkStatusSchema, + writeNetworkStatus, + type NetworkAction, + type NetworkMode, + type NetworkStatus +} from "./network.js"; + +const execFileAsync = promisify(execFile); +const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const controllerConfigSchema = z.object({ + NETWORK_MANAGER_ENABLED: z.enum(["true", "false"]).default("false").transform((value) => value === "true"), + NETWORK_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/network-status.json"), + NETWORK_COMMAND_PATH: z.string().default("/var/lib/pi-car-companion/network-command.json"), + NETWORK_HOTSPOT_INTERFACE: z.string().regex(/^[a-zA-Z0-9_.-]+$/).default("wlan0"), + NETWORK_UPSTREAM_INTERFACE: z.string().regex(/^[a-zA-Z0-9_.-]+$/).default("wlan1"), + NETWORK_HOTSPOT_CONNECTION: z.string().min(1).max(128).default("pi-car-hotspot"), + NETWORK_PROBE_TIMEOUT_SECONDS: z.coerce.number().int().min(15).max(180).default(60), + NETWORK_STABILITY_SECONDS: z.coerce.number().int().min(3).max(60).default(10), + NETWORK_POLL_SECONDS: z.coerce.number().int().min(2).max(30).default(5) +}); + +type ControllerConfig = z.infer; +type Adapter = NetworkStatus["hotspot"]; + +async function nmcli(args: string[], timeoutMs = 20_000): Promise { + const { stdout } = await execFileAsync("/usr/bin/nmcli", args, { timeout: timeoutMs, maxBuffer: 32 * 1024 }); + return stdout.trim(); +} + +async function field(device: string, name: string): Promise { + try { + return (await nmcli(["--get-values", name, "device", "show", device])).split("\n")[0]?.trim() || null; + } catch { + return null; + } +} + +async function inspectAdapter(device: string): Promise { + const type = await field(device, "GENERAL.TYPE"); + if (type !== "wifi") return { interface: device, available: false, connected: false, connection: null, address: null }; + const state = await field(device, "GENERAL.STATE"); + return { + interface: device, + available: true, + connected: state?.startsWith("100") ?? false, + connection: await field(device, "GENERAL.CONNECTION"), + address: await field(device, "IP4.ADDRESS") + }; +} + +async function connectivity(): Promise { + try { + const value = await nmcli(["networking", "connectivity", "check"], 10_000); + return ["full", "limited", "portal", "none", "unknown"].includes(value) + ? (value as NetworkStatus["connectivity"]) + : "unknown"; + } catch { + return "unknown"; + } +} + +class NetworkController { + private mode: NetworkMode = "starting"; + private message = "Inspecting network adapters."; + private lastTransitionAt = new Date().toISOString(); + private lastFailure: string | null = null; + private probeDeadline: string | null = null; + private fallbackAttempted = false; + private nextAwusAttemptAt = 0; + private stopped = false; + + constructor(private readonly config: ControllerConfig) {} + + stop(): void { + this.stopped = true; + } + + private transition(mode: NetworkMode, message: string, failure: string | null = null): void { + if (this.mode !== mode) this.lastTransitionAt = new Date().toISOString(); + this.mode = mode; + this.message = message; + this.lastFailure = failure; + } + + private async publish(): Promise<{ hotspot: Adapter; upstream: Adapter }> { + const [hotspot, upstream, internet] = await Promise.all([ + inspectAdapter(this.config.NETWORK_HOTSPOT_INTERFACE), + inspectAdapter(this.config.NETWORK_UPSTREAM_INTERFACE), + connectivity() + ]); + await writeNetworkStatus(this.config.NETWORK_STATUS_PATH, { + enabled: true, + mode: this.mode, + message: this.message, + updatedAt: new Date().toISOString(), + lastTransitionAt: this.lastTransitionAt, + probeDeadline: this.probeDeadline, + lastFailure: this.lastFailure, + connectivity: internet, + hotspot, + upstream + }); + return { hotspot, upstream }; + } + + private async connectUpstreamAdapter(): Promise { + try { + await nmcli(["--wait", "20", "device", "connect", this.config.NETWORK_UPSTREAM_INTERFACE], 25_000); + await sleep(this.config.NETWORK_STABILITY_SECONDS * 1000); + return (await inspectAdapter(this.config.NETWORK_UPSTREAM_INTERFACE)).connected; + } catch (error) { + this.lastFailure = error instanceof Error ? error.message.slice(0, 500) : "The upstream adapter could not connect."; + return false; + } + } + + private async restoreHotspot(message = "Offline hotspot restored; the dashboard remains available without internet."): Promise { + this.probeDeadline = null; + try { + await nmcli( + ["--wait", "20", "connection", "up", this.config.NETWORK_HOTSPOT_CONNECTION, "ifname", this.config.NETWORK_HOTSPOT_INTERFACE], + 25_000 + ); + const upstream = await inspectAdapter(this.config.NETWORK_UPSTREAM_INTERFACE); + this.transition(upstream.connected ? "dual_radio" : "offline_hotspot", upstream.connected ? "Car hotspot is active with AWUS upstream internet." : message); + return true; + } catch (error) { + const failure = error instanceof Error ? error.message.slice(0, 500) : "The hotspot could not be restored."; + this.transition("error", "The recovery hotspot could not be activated. Local intervention is required.", failure); + return false; + } + } + + private async probeBuiltIn(): Promise { + const timeout = this.config.NETWORK_PROBE_TIMEOUT_SECONDS; + this.probeDeadline = new Date(Date.now() + timeout * 1000).toISOString(); + this.transition("probing_built_in", `Trying saved upstream networks on the built-in adapter for up to ${timeout} seconds.`); + await this.publish(); + try { + await nmcli(["--wait", "10", "connection", "down", this.config.NETWORK_HOTSPOT_CONNECTION], 15_000).catch(() => undefined); + await nmcli(["--wait", String(timeout), "device", "connect", this.config.NETWORK_HOTSPOT_INTERFACE], (timeout + 5) * 1000); + if ((await inspectAdapter(this.config.NETWORK_HOTSPOT_INTERFACE)).connected) { + this.probeDeadline = null; + this.transition("built_in_upstream", "The built-in adapter is connected upstream; the car hotspot is temporarily unavailable."); + this.fallbackAttempted = false; + return; + } + throw new Error("The built-in adapter did not activate a saved upstream profile."); + } catch (error) { + this.lastFailure = error instanceof Error ? error.message.slice(0, 500) : "No saved upstream network was available."; + this.fallbackAttempted = true; + await this.restoreHotspot(); + } + } + + private async handleAction(action: NetworkAction): Promise { + if (action === "restore-hotspot") { + this.fallbackAttempted = true; + await this.restoreHotspot("The offline hotspot was restored manually."); + return; + } + this.fallbackAttempted = false; + const upstream = await inspectAdapter(this.config.NETWORK_UPSTREAM_INTERFACE); + if (upstream.available && (upstream.connected || (await this.connectUpstreamAdapter()))) { + await this.restoreHotspot(); + return; + } + await this.probeBuiltIn(); + } + + async run(): Promise { + try { + const prior = networkStatusSchema.parse(JSON.parse(await readFile(this.config.NETWORK_STATUS_PATH, "utf8"))); + this.mode = prior.mode; + this.message = prior.message; + this.lastTransitionAt = prior.lastTransitionAt; + this.lastFailure = prior.lastFailure; + this.fallbackAttempted = prior.mode === "offline_hotspot"; + } catch { + // A first start intentionally discovers the live NetworkManager state. + } + let previous = await this.publish(); + + while (!this.stopped) { + const command = await consumeNetworkCommand(this.config.NETWORK_COMMAND_PATH); + if (command) await this.handleAction(command.action); + + let { hotspot, upstream } = await this.publish(); + const hotspotActive = hotspot.connected && hotspot.connection === this.config.NETWORK_HOTSPOT_CONNECTION; + const builtInUpstream = hotspot.connected && !hotspotActive; + + if (upstream.connected) { + if (!hotspotActive) await this.restoreHotspot(); + else this.transition("dual_radio", "Car hotspot is active with AWUS upstream internet."); + this.fallbackAttempted = false; + } else if (builtInUpstream) { + this.transition("built_in_upstream", "The built-in adapter is connected upstream; the car hotspot is temporarily unavailable."); + if (upstream.available && Date.now() >= this.nextAwusAttemptAt) { + this.nextAwusAttemptAt = Date.now() + 60_000; + if (await this.connectUpstreamAdapter()) await this.restoreHotspot(); + } + } else { + const awusJustFailed = previous.upstream.connected && !upstream.connected; + if (upstream.available && Date.now() >= this.nextAwusAttemptAt) { + this.nextAwusAttemptAt = Date.now() + 60_000; + if (await this.connectUpstreamAdapter()) { + await this.restoreHotspot(); + ({ hotspot, upstream } = await this.publish()); + } + } + if (!upstream.connected && !this.fallbackAttempted && (awusJustFailed || hotspotActive || !hotspot.connected)) { + await this.probeBuiltIn(); + } else if (!upstream.connected && hotspotActive) { + this.transition("offline_hotspot", "Offline hotspot is active; no upstream network is currently connected.", this.lastFailure); + } + } + + previous = await this.publish(); + await sleep(this.config.NETWORK_POLL_SECONDS * 1000); + } + } +} + +const config = controllerConfigSchema.parse(process.env); +if (!config.NETWORK_MANAGER_ENABLED) { + process.exit(0); +} +const controller = new NetworkController(config); +process.on("SIGINT", () => controller.stop()); +process.on("SIGTERM", () => controller.stop()); +await controller.run(); diff --git a/server/src/network.ts b/server/src/network.ts new file mode 100644 index 0000000..c06432d --- /dev/null +++ b/server/src/network.ts @@ -0,0 +1,116 @@ +import { randomUUID } from "node:crypto"; +import { chmod, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { z } from "zod"; +import type { AppConfig } from "./config.js"; + +export const networkModeSchema = z.enum([ + "disabled", + "starting", + "dual_radio", + "built_in_upstream", + "probing_built_in", + "offline_hotspot", + "degraded", + "error" +]); +export type NetworkMode = z.infer; + +const adapterSchema = z.object({ + interface: z.string().max(64), + available: z.boolean(), + connected: z.boolean(), + connection: z.string().max(128).nullable(), + address: z.string().max(128).nullable() +}); + +export const networkStatusSchema = z.object({ + enabled: z.boolean(), + mode: networkModeSchema, + message: z.string().max(500), + updatedAt: z.string(), + lastTransitionAt: z.string(), + probeDeadline: z.string().nullable(), + lastFailure: z.string().max(500).nullable(), + connectivity: z.enum(["full", "limited", "portal", "none", "unknown"]), + hotspot: adapterSchema, + upstream: adapterSchema +}); + +export type NetworkStatus = z.infer & { supported: boolean }; +export const networkActionSchema = z.enum(["retry-upstream", "restore-hotspot"]); +export type NetworkAction = z.infer; + +const commandSchema = z.object({ + id: z.string().uuid(), + action: networkActionSchema, + requestedAt: z.string(), + actorUserId: z.number().int().positive() +}); +export type NetworkCommand = z.infer; + +async function atomicJsonWrite(path: string, value: unknown): Promise { + const temporary = join(dirname(path), `.${randomUUID()}.tmp`); + await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o640 }); + await chmod(temporary, 0o640); + await rename(temporary, path); +} + +export async function readNetworkStatus(config: AppConfig): Promise { + if (!config.network.enabled) { + return unsupportedStatus("Automatic network management is disabled until its hotspot and upstream profiles are configured."); + } + try { + return { ...networkStatusSchema.parse(JSON.parse(await readFile(config.network.statusPath, "utf8"))), supported: true }; + } catch { + return { + ...unsupportedStatus("The network controller is starting or its status is unavailable."), + enabled: true, + mode: "starting" + }; + } +} + +export async function requestNetworkAction( + config: AppConfig, + action: NetworkAction, + actorUserId: number +): Promise { + if (!config.network.enabled) throw new Error("NETWORK_MANAGER_DISABLED"); + const command = commandSchema.parse({ id: randomUUID(), action, requestedAt: new Date().toISOString(), actorUserId }); + await atomicJsonWrite(config.network.commandPath, command); + return command; +} + +export async function consumeNetworkCommand(path: string): Promise { + try { + const parsed = commandSchema.parse(JSON.parse(await readFile(path, "utf8"))); + await writeFile(path, "", { mode: 0o640 }); + return parsed; + } catch { + return null; + } +} + +function unsupportedStatus(message: string): NetworkStatus { + const epoch = new Date(0).toISOString(); + const adapter = { interface: "unconfigured", available: false, connected: false, connection: null, address: null }; + return { + supported: false, + enabled: false, + mode: "disabled", + message, + updatedAt: epoch, + lastTransitionAt: epoch, + probeDeadline: null, + lastFailure: null, + connectivity: "unknown", + hotspot: adapter, + upstream: adapter + }; +} + +export async function writeNetworkStatus(path: string, status: z.infer): Promise { + await atomicJsonWrite(path, networkStatusSchema.parse(status)); + await chmod(path, 0o644); +} diff --git a/server/tests/app.test.ts b/server/tests/app.test.ts index 2605aab..d704af8 100644 --- a/server/tests/app.test.ts +++ b/server/tests/app.test.ts @@ -17,6 +17,11 @@ function testConfig(): AppConfig { allowedOrigins: new Set(["http://mg4pi.local:8787"]), updateStatusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-update.json`, versionFile: `/tmp/pi-car-companion-test-${process.pid}-missing-revision`, + network: { + enabled: false, + statusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-network-status.json`, + commandPath: `/tmp/pi-car-companion-test-${process.pid}-network-command.json` + }, adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 } }; } @@ -180,6 +185,33 @@ describe("authentication boundary", () => { expect(response.statusCode).toBe(401); }); + it("keeps network status and controls behind authentication and explicit configuration", async () => { + const app = await createApp(); + expect((await app.inject({ method: "GET", url: "/api/network" })).statusCode).toBe(401); + + const token = await csrf(app); + await setup(app, token); + const login = await app.inject({ + method: "POST", + url: "/api/auth/login", + headers: mutationHeaders(token), + payload: { username: "owner", password: "Correct-horse1" } + }); + const session = login.cookies.find((item) => item.name === SESSION_COOKIE)?.value ?? ""; + const cookie = `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${session}`; + const status = await app.inject({ method: "GET", url: "/api/network", headers: { cookie } }); + expect(status.statusCode).toBe(200); + expect(status.json()).toMatchObject({ supported: false, enabled: false, mode: "disabled" }); + + const action = await app.inject({ + method: "POST", + url: "/api/network/actions", + headers: { ...mutationHeaders(token), cookie }, + payload: { action: "retry-upstream" } + }); + expect(action.statusCode).toBe(503); + }); + it("exposes only allowlisted jobs and authenticated run history", async () => { const app = await createApp(); const token = await csrf(app); diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 7909545..52af6dc 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -21,4 +21,12 @@ describe("ADB configuration", () => { serial: "10.0.0.10:5555" }); }); + + it("keeps automatic network management disabled by default", () => { + expect(loadConfig({ NODE_ENV: "test" }).network).toEqual({ + enabled: false, + statusPath: "/var/lib/pi-car-companion/network-status.json", + commandPath: "/var/lib/pi-car-companion/network-command.json" + }); + }); }); diff --git a/server/tests/network.test.ts b/server/tests/network.test.ts new file mode 100644 index 0000000..a28be29 --- /dev/null +++ b/server/tests/network.test.ts @@ -0,0 +1,52 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AppConfig } from "../src/config.js"; +import { consumeNetworkCommand, readNetworkStatus, requestNetworkAction } from "../src/network.js"; + +const directories: string[] = []; + +function config(directory: string, enabled = true): AppConfig { + return { + nodeEnv: "test", + host: "127.0.0.1", + port: 8787, + databasePath: ":memory:", + sessionTtlMs: 60_000, + cookieSecure: false, + allowedOrigins: new Set(), + updateStatusPath: join(directory, "update.json"), + versionFile: join(directory, "REVISION"), + network: { enabled, statusPath: join(directory, "status.json"), commandPath: join(directory, "command.json") }, + adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 } + }; +} + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("network control boundary", () => { + it("writes and consumes only fixed action requests", async () => { + const directory = await mkdtemp(join(tmpdir(), "pi-car-network-test-")); + directories.push(directory); + const testConfig = config(directory); + const requested = await requestNetworkAction(testConfig, "retry-upstream", 7); + expect(JSON.parse(await readFile(testConfig.network.commandPath, "utf8"))).toMatchObject({ + id: requested.id, + action: "retry-upstream", + actorUserId: 7 + }); + await expect(consumeNetworkCommand(testConfig.network.commandPath)).resolves.toMatchObject({ action: "retry-upstream" }); + await expect(consumeNetworkCommand(testConfig.network.commandPath)).resolves.toBeNull(); + }); + + it("rejects malformed controller status", async () => { + const directory = await mkdtemp(join(tmpdir(), "pi-car-network-test-")); + directories.push(directory); + const testConfig = config(directory); + await writeFile(testConfig.network.statusPath, '{"mode":"shell"}'); + await expect(readNetworkStatus(testConfig)).resolves.toMatchObject({ supported: false, mode: "starting" }); + }); +}); diff --git a/server/tests/update.test.ts b/server/tests/update.test.ts index ad608bb..a79bc37 100644 --- a/server/tests/update.test.ts +++ b/server/tests/update.test.ts @@ -18,6 +18,11 @@ function config(updateStatusPath: string, versionFile: string): AppConfig { allowedOrigins: new Set(), updateStatusPath, versionFile, + network: { + enabled: false, + statusPath: join(tmpdir(), "missing-network-status.json"), + commandPath: join(tmpdir(), "network-command.json") + }, adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 } }; } diff --git a/systemd/pi-car-companion-network.service b/systemd/pi-car-companion-network.service new file mode 100644 index 0000000..167e670 --- /dev/null +++ b/systemd/pi-car-companion-network.service @@ -0,0 +1,30 @@ +[Unit] +Description=Pi Car Companion network failover controller +After=NetworkManager.service +Wants=NetworkManager.service + +[Service] +Type=simple +EnvironmentFile=/etc/pi-car-companion/companion.env +Environment=HOME=/var/lib/pi-car-companion +WorkingDirectory=/opt/pi-car-companion/current +ExecStart=/usr/bin/node /opt/pi-car-companion/current/server/dist/network-controller.js +Restart=on-failure +RestartSec=5s +TimeoutStopSec=15s +UMask=0027 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictRealtime=true +LockPersonality=true +SystemCallArchitectures=native +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +ReadWritePaths=/var/lib/pi-car-companion + +[Install] +WantedBy=multi-user.target diff --git a/web/src/App.tsx b/web/src/App.tsx index 377e300..9be55fc 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,10 +18,12 @@ import { ApiError, getAuthState, getStatus, + getNetworkStatus, getUpdateStatus, post, type AuthState, type SystemStatus, + type NetworkStatus, type UpdateStatus } from "./api"; @@ -224,35 +226,67 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () => function SettingsPage({ csrfToken }: { csrfToken: string }) { const [update, setUpdate] = useState(null); - const [error, setError] = useState(""); + const [network, setNetwork] = useState(null); + const [updateError, setUpdateError] = useState(""); + const [networkError, setNetworkError] = useState(""); const [requesting, setRequesting] = useState(false); const [confirming, setConfirming] = useState(false); + const [networkAction, setNetworkAction] = useState<"retry-upstream" | "restore-hotspot" | null>(null); const running = update?.state === "checking" || update?.state === "building"; const refreshUpdate = useCallback(async () => { try { setUpdate(await getUpdateStatus()); - setError(""); + setUpdateError(""); } catch (caught) { - setError(caught instanceof Error ? caught.message : "Update status is unavailable"); + setUpdateError(caught instanceof Error ? caught.message : "Update status is unavailable"); + } + }, []); + + const refreshNetwork = useCallback(async () => { + try { + setNetwork(await getNetworkStatus()); + setNetworkError(""); + } catch (caught) { + setNetworkError(caught instanceof Error ? caught.message : "Network status is unavailable"); } }, []); useEffect(() => { void refreshUpdate(); - const interval = window.setInterval(() => void refreshUpdate(), 3_000); + void refreshNetwork(); + const interval = window.setInterval(() => { + void refreshUpdate(); + void refreshNetwork(); + }, 3_000); return () => window.clearInterval(interval); - }, [refreshUpdate]); + }, [refreshNetwork, refreshUpdate]); async function startUpdate() { setConfirming(false); setRequesting(true); - setError(""); + setUpdateError(""); try { await post("/api/system/update", {}, csrfToken); await refreshUpdate(); } catch (caught) { - setError(caught instanceof ApiError ? caught.message : "The update could not be started"); + setUpdateError(caught instanceof ApiError ? caught.message : "The update could not be started"); + } finally { + setRequesting(false); + } + } + + async function requestNetworkAction() { + if (!networkAction) return; + const action = networkAction; + setNetworkAction(null); + setRequesting(true); + setNetworkError(""); + try { + await post("/api/network/actions", { action }, csrfToken); + await refreshNetwork(); + } catch (caught) { + setNetworkError(caught instanceof ApiError ? caught.message : "The network action could not be requested"); } finally { setRequesting(false); } @@ -260,9 +294,57 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) { const revision = update?.installedRevision?.slice(0, 12) ?? "Unavailable"; const statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? "Updating" : "Ready"; + const modeLabels: Record["mode"], string> = { + disabled: "Not configured", + starting: "Starting", + dual_radio: "Hotspot + internet", + built_in_upstream: "Built-in upstream", + probing_built_in: "Looking for internet", + offline_hotspot: "Offline hotspot", + degraded: "Needs attention", + error: "Recovery failed" + }; + const probeSeconds = network?.probeDeadline + ? Math.max(0, Math.ceil((Date.parse(network.probeDeadline) - Date.now()) / 1000)) + : null; return (
+
+
+
+

Car network

Keep the dashboard reachable through the built-in hotspot while preferring the AWUS1900 for upstream internet.

+
+ +
+
+ Current mode + {network ? modeLabels[network.mode] : "Loading"} +

{network?.message ?? "Reading network controller status..."}

+ {probeSeconds !== null && Offline hotspot recovery in at most {probeSeconds} seconds} +
+ + +
+ Internet check + {network?.connectivity ?? "unknown"} + Reported by NetworkManager +
+
+ + {networkError &&
{networkError}
} + {network?.lastFailure &&

Last issue: {network.lastFailure}

} +
+ + +
+ {!network?.supported &&

Run the network setup helper on the Pi after creating a saved AWUS upstream profile. Automatic switching stays disabled until then.

} +
+
@@ -281,7 +363,7 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) { {!running && update?.state !== "failed" && } {update?.message ?? "Reading update status..."}
- {error &&
{error}
} + {updateError &&
{updateError}
}
} + + {networkAction &&
setNetworkAction(null)}> +
event.stopPropagation()}> +

{networkAction === "retry-upstream" ? "Try upstream networking?" : "Restore the car hotspot?"}

+

{networkAction === "retry-upstream" + ? "If the AWUS1900 cannot connect, the built-in hotspot will disappear for up to 60 seconds while saved upstream networks are tried. It will be restored automatically after failure." + : "The built-in adapter will leave its upstream network and restore the car hotspot. This dashboard connection may briefly disconnect."}

+
+ + +
+
+
} + + ); +} + +function NetworkAdapter({ label, adapter }: { label: string; adapter: NetworkStatus["hotspot"] | null }) { + return ( +
+ {label} + {!adapter ? "Loading" : !adapter.available ? "Unavailable" : adapter.connected ? adapter.connection ?? "Connected" : "Disconnected"} + {adapter?.address ?? adapter?.interface ?? "Waiting for status"}
); } diff --git a/web/src/api.ts b/web/src/api.ts index 723a7f0..39f99cb 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -66,6 +66,28 @@ export type UpdateStatus = { supported: boolean; }; +export type NetworkStatus = { + supported: boolean; + enabled: boolean; + mode: "disabled" | "starting" | "dual_radio" | "built_in_upstream" | "probing_built_in" | "offline_hotspot" | "degraded" | "error"; + message: string; + updatedAt: string; + lastTransitionAt: string; + probeDeadline: string | null; + lastFailure: string | null; + connectivity: "full" | "limited" | "portal" | "none" | "unknown"; + hotspot: NetworkAdapterStatus; + upstream: NetworkAdapterStatus; +}; + +export type NetworkAdapterStatus = { + interface: string; + available: boolean; + connected: boolean; + connection: string | null; + address: string | null; +}; + type ErrorResponse = { error?: { code?: string; message?: string } }; export class ApiError extends Error { @@ -108,3 +130,7 @@ export async function getStatus(): Promise { export async function getUpdateStatus(): Promise { return parseResponse(await fetch("/api/system/update", { credentials: "same-origin" })); } + +export async function getNetworkStatus(): Promise { + return parseResponse(await fetch("/api/network", { credentials: "same-origin" })); +} diff --git a/web/src/styles.css b/web/src/styles.css index 63b73a1..1413ae5 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -105,6 +105,18 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .settings-layout { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.7fr); gap: 18px; align-items: start; } .settings-panel { padding: 28px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); } +.network-panel { grid-column: 1 / -1; } +.network-overview { display: grid; grid-template-columns: 1.3fr repeat(3, minmax(0, 1fr)); gap: 1px; margin: 28px 0 20px; overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: var(--line); } +.network-mode, .network-adapter { min-width: 0; padding: 19px; background: #121610; } +.network-mode span, .network-adapter span { display: block; margin-bottom: 10px; color: var(--muted); font-size: 0.78rem; } +.network-mode strong, .network-adapter strong { display: block; overflow: hidden; font-size: 1.05rem; text-overflow: ellipsis; white-space: nowrap; } +.network-mode p { min-height: 2.7em; margin: 9px 0 0; color: var(--muted); font-size: 0.82rem; line-height: 1.35; } +.network-mode small, .network-adapter small { display: block; overflow: hidden; margin-top: 9px; color: #899183; font: 0.72rem/1.3 ui-monospace, "Cascadia Code", monospace; text-overflow: ellipsis; white-space: nowrap; } +.mode-dual_radio strong, .mode-offline_hotspot strong { color: var(--accent); } +.mode-error strong, .mode-degraded strong { color: var(--danger); } +.network-failure { display: flex; align-items: flex-start; gap: 8px; margin: 0 0 16px; color: #ffc8bf; font-size: 0.82rem; line-height: 1.45; } +.network-failure svg { flex: none; } +.text-button { width: auto; padding: 0 20px; font-size: inherit; } .settings-title { display: flex; align-items: flex-start; gap: 18px; } .settings-title h2, .compact-panel h2 { margin-bottom: 8px; font-size: 1.45rem; } .settings-title p { max-width: 620px; margin: 0; color: var(--muted); line-height: 1.5; } @@ -144,6 +156,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .health-summary { grid-template-columns: 1fr; gap: 24px; } .metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .settings-layout { grid-template-columns: 1fr; } + .network-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 767px) { @@ -167,6 +180,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .summary-details div { padding: 0; border: 0; } .metrics-grid, .system-strip { grid-template-columns: 1fr; } .update-facts { grid-template-columns: 1fr; } + .network-overview { grid-template-columns: 1fr; } .settings-actions, .dialog-actions { flex-direction: column; } .settings-actions button, .dialog-actions button { width: 100%; } .skeleton-grid { grid-template-columns: 1fr; }