Add resilient dual-radio network failover
This commit is contained in:
@@ -8,6 +8,16 @@ COOKIE_SECURE=false
|
|||||||
ALLOWED_ORIGINS=http://mg4pi.local:8787
|
ALLOWED_ORIGINS=http://mg4pi.local:8787
|
||||||
UPDATE_STATUS_PATH=/var/lib/pi-car-companion/update-status.json
|
UPDATE_STATUS_PATH=/var/lib/pi-car-companion/update-status.json
|
||||||
VERSION_FILE=/opt/pi-car-companion/current/REVISION
|
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.
|
# Disabled by default. Leave ADB_SERIAL empty to auto-select only when exactly one device is attached.
|
||||||
ADB_ENABLED=false
|
ADB_ENABLED=false
|
||||||
ADB_PATH=adb
|
ADB_PATH=adb
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ Core operation must work without cloud access. Optional remote access is a later
|
|||||||
- Controlled artifact upload, listing, download, and deletion
|
- Controlled artifact upload, listing, download, and deletion
|
||||||
- SHA-256 metadata and storage quota reporting
|
- SHA-256 metadata and storage quota reporting
|
||||||
- Wi-Fi status/reconnect where supported by the host
|
- 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
|
- Log rotation/pruning job
|
||||||
- Companion service restart job
|
- Companion service restart job
|
||||||
- Reboot and shutdown actions with strong confirmation
|
- 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
|
### Milestone 5 — Artifacts and operational actions
|
||||||
|
|
||||||
- [ ] Implement controlled uploads/downloads/deletion, hashing, quotas, and metadata UI.
|
- [ ] 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 log pruning and companion-service restart jobs.
|
||||||
- [ ] Add reboot and shutdown with explicit action-bound confirmation and narrowly scoped privilege configuration.
|
- [ ] 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.
|
- [ ] Exercise storage exhaustion, job timeout, service restart, and network-loss recovery.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ The current MVP slice includes:
|
|||||||
- a responsive 1920×720-oriented setup, login, and dashboard UI;
|
- a responsive 1920×720-oriented setup, login, and dashboard UI;
|
||||||
- an idempotent Raspberry Pi installer with systemd start-on-boot support;
|
- an idempotent Raspberry Pi installer with systemd start-on-boot support;
|
||||||
- atomic, dashboard-triggered Git updates with automatic verification and rollback safety;
|
- 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.
|
- 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).
|
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_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_SERIAL`: optional exact serial; when empty, discovery requires exactly one attached device
|
||||||
- `ADB_PATH` and `ADB_TIMEOUT_MS`: executable path and per-command timeout
|
- `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
|
### Read-only ADB setup
|
||||||
|
|
||||||
|
|||||||
+32
-1
@@ -38,7 +38,7 @@ fi
|
|||||||
echo "[1/7] Installing operating-system packages"
|
echo "[1/7] Installing operating-system packages"
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
apt-get update
|
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=""
|
node_major=""
|
||||||
if command -v node >/dev/null 2>&1; then
|
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"
|
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-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.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-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
|
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
|
visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null
|
||||||
install -d -m 0755 /etc/avahi/services
|
install -d -m 0755 /etc/avahi/services
|
||||||
@@ -133,8 +135,34 @@ ADB_ENABLED=false
|
|||||||
ADB_PATH=adb
|
ADB_PATH=adb
|
||||||
ADB_SERIAL=
|
ADB_SERIAL=
|
||||||
ADB_TIMEOUT_MS=3000
|
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
|
EOF
|
||||||
fi
|
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"
|
chown root:"${SERVICE_USER}" "${CONFIG_DIR}/companion.env"
|
||||||
chmod 0640 "${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 daemon-reload
|
||||||
systemctl enable --now avahi-daemon
|
systemctl enable --now avahi-daemon
|
||||||
systemctl enable pi-car-companion.service
|
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
|
systemctl restart pi-car-companion.service
|
||||||
|
|
||||||
echo "[7/7] Verifying the service"
|
echo "[7/7] Verifying the service"
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"build": "npm run build --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"
|
"check": "npm run check:scripts && npm run lint && npm run typecheck && npm test && npm run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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 '<SSID>' 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}" <<EOF
|
||||||
|
[connection]
|
||||||
|
id=${HOTSPOT_CONNECTION}
|
||||||
|
uuid=${hotspot_uuid}
|
||||||
|
type=wifi
|
||||||
|
interface-name=${HOTSPOT_INTERFACE}
|
||||||
|
autoconnect=false
|
||||||
|
|
||||||
|
[wifi]
|
||||||
|
band=bg
|
||||||
|
mode=ap
|
||||||
|
ssid=${HOTSPOT_SSID}
|
||||||
|
|
||||||
|
[wifi-security]
|
||||||
|
key-mgmt=wpa-psk
|
||||||
|
psk=${hotspot_password}
|
||||||
|
|
||||||
|
[ipv4]
|
||||||
|
address1=${HOTSPOT_ADDRESS}
|
||||||
|
method=shared
|
||||||
|
|
||||||
|
[ipv6]
|
||||||
|
method=disabled
|
||||||
|
EOF
|
||||||
|
if nmcli --get-values NAME connection show | grep -Fxq "${HOTSPOT_CONNECTION}"; then
|
||||||
|
nmcli connection delete "${HOTSPOT_CONNECTION}"
|
||||||
|
fi
|
||||||
|
install -m 0600 -o root -g root "${temporary_file}" "${hotspot_file}"
|
||||||
|
nmcli connection load "${hotspot_file}"
|
||||||
|
hotspot_password=""
|
||||||
|
|
||||||
|
set_env() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
if grep -q "^${key}=" "${CONFIG_FILE}"; then
|
||||||
|
sed -i "s|^${key}=.*|${key}=${value}|" "${CONFIG_FILE}"
|
||||||
|
else
|
||||||
|
printf '%s=%s\n' "${key}" "${value}" >> "${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."
|
||||||
@@ -136,8 +136,10 @@ chown -R root:root "${release_final}"
|
|||||||
install_integration_files() {
|
install_integration_files() {
|
||||||
local source_release="$1"
|
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-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.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-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
|
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
|
fi
|
||||||
|
|
||||||
write_status "success" "Update installed and the companion service is healthy."
|
write_status "success" "Update installed and the companion service is healthy."
|
||||||
|
systemctl try-restart pi-car-companion-network.service || true
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "./security.js";
|
} from "./security.js";
|
||||||
import { collectSystemStatus } from "./status.js";
|
import { collectSystemStatus } from "./status.js";
|
||||||
import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
|
import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
|
||||||
|
import { networkActionSchema, readNetworkStatus, requestNetworkAction } from "./network.js";
|
||||||
import { collectAdbStatus } from "./adb.js";
|
import { collectAdbStatus } from "./adb.js";
|
||||||
import "./types.js";
|
import "./types.js";
|
||||||
|
|
||||||
@@ -313,6 +314,41 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||||||
return reply.code(202).send(run);
|
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) => {
|
app.get("/api/system/update", async (request, reply) => {
|
||||||
if (!requireUser(request, reply)) return;
|
if (!requireUser(request, reply)) return;
|
||||||
return readUpdateStatus(config);
|
return readUpdateStatus(config);
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const schema = z.object({
|
|||||||
ALLOWED_ORIGINS: z.string().default("http://mg4pi.local:8787"),
|
ALLOWED_ORIGINS: z.string().default("http://mg4pi.local:8787"),
|
||||||
UPDATE_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/update-status.json"),
|
UPDATE_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/update-status.json"),
|
||||||
VERSION_FILE: z.string().default(resolve("REVISION")),
|
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_ENABLED: booleanFromString,
|
||||||
ADB_PATH: z.string().min(1).default("adb"),
|
ADB_PATH: z.string().min(1).default("adb"),
|
||||||
ADB_SERIAL: z.string().max(128).regex(/^[a-zA-Z0-9._:-]*$/).default(""),
|
ADB_SERIAL: z.string().max(128).regex(/^[a-zA-Z0-9._:-]*$/).default(""),
|
||||||
@@ -33,6 +36,11 @@ export type AppConfig = {
|
|||||||
allowedOrigins: ReadonlySet<string>;
|
allowedOrigins: ReadonlySet<string>;
|
||||||
updateStatusPath: string;
|
updateStatusPath: string;
|
||||||
versionFile: string;
|
versionFile: string;
|
||||||
|
network: {
|
||||||
|
enabled: boolean;
|
||||||
|
statusPath: string;
|
||||||
|
commandPath: string;
|
||||||
|
};
|
||||||
adb: AdbConfig;
|
adb: AdbConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,6 +68,11 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
|
|||||||
allowedOrigins,
|
allowedOrigins,
|
||||||
updateStatusPath: parsed.UPDATE_STATUS_PATH,
|
updateStatusPath: parsed.UPDATE_STATUS_PATH,
|
||||||
versionFile: parsed.VERSION_FILE,
|
versionFile: parsed.VERSION_FILE,
|
||||||
|
network: {
|
||||||
|
enabled: parsed.NETWORK_MANAGER_ENABLED,
|
||||||
|
statusPath: parsed.NETWORK_STATUS_PATH,
|
||||||
|
commandPath: parsed.NETWORK_COMMAND_PATH
|
||||||
|
},
|
||||||
adb: {
|
adb: {
|
||||||
enabled: parsed.ADB_ENABLED,
|
enabled: parsed.ADB_ENABLED,
|
||||||
executable: parsed.ADB_PATH,
|
executable: parsed.ADB_PATH,
|
||||||
|
|||||||
@@ -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<typeof controllerConfigSchema>;
|
||||||
|
type Adapter = NetworkStatus["hotspot"];
|
||||||
|
|
||||||
|
async function nmcli(args: string[], timeoutMs = 20_000): Promise<string> {
|
||||||
|
const { stdout } = await execFileAsync("/usr/bin/nmcli", args, { timeout: timeoutMs, maxBuffer: 32 * 1024 });
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function field(device: string, name: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return (await nmcli(["--get-values", name, "device", "show", device])).split("\n")[0]?.trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectAdapter(device: string): Promise<Adapter> {
|
||||||
|
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<NetworkStatus["connectivity"]> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
@@ -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<typeof networkModeSchema>;
|
||||||
|
|
||||||
|
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<typeof networkStatusSchema> & { supported: boolean };
|
||||||
|
export const networkActionSchema = z.enum(["retry-upstream", "restore-hotspot"]);
|
||||||
|
export type NetworkAction = z.infer<typeof networkActionSchema>;
|
||||||
|
|
||||||
|
const commandSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
action: networkActionSchema,
|
||||||
|
requestedAt: z.string(),
|
||||||
|
actorUserId: z.number().int().positive()
|
||||||
|
});
|
||||||
|
export type NetworkCommand = z.infer<typeof commandSchema>;
|
||||||
|
|
||||||
|
async function atomicJsonWrite(path: string, value: unknown): Promise<void> {
|
||||||
|
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<NetworkStatus> {
|
||||||
|
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<NetworkCommand> {
|
||||||
|
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<NetworkCommand | null> {
|
||||||
|
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<typeof networkStatusSchema>): Promise<void> {
|
||||||
|
await atomicJsonWrite(path, networkStatusSchema.parse(status));
|
||||||
|
await chmod(path, 0o644);
|
||||||
|
}
|
||||||
@@ -17,6 +17,11 @@ function testConfig(): AppConfig {
|
|||||||
allowedOrigins: new Set(["http://mg4pi.local:8787"]),
|
allowedOrigins: new Set(["http://mg4pi.local:8787"]),
|
||||||
updateStatusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-update.json`,
|
updateStatusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-update.json`,
|
||||||
versionFile: `/tmp/pi-car-companion-test-${process.pid}-missing-revision`,
|
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 }
|
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -180,6 +185,33 @@ describe("authentication boundary", () => {
|
|||||||
expect(response.statusCode).toBe(401);
|
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 () => {
|
it("exposes only allowlisted jobs and authenticated run history", async () => {
|
||||||
const app = await createApp();
|
const app = await createApp();
|
||||||
const token = await csrf(app);
|
const token = await csrf(app);
|
||||||
|
|||||||
@@ -21,4 +21,12 @@ describe("ADB configuration", () => {
|
|||||||
serial: "10.0.0.10:5555"
|
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"
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,11 @@ function config(updateStatusPath: string, versionFile: string): AppConfig {
|
|||||||
allowedOrigins: new Set(),
|
allowedOrigins: new Set(),
|
||||||
updateStatusPath,
|
updateStatusPath,
|
||||||
versionFile,
|
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 }
|
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
+113
-8
@@ -18,10 +18,12 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
getAuthState,
|
getAuthState,
|
||||||
getStatus,
|
getStatus,
|
||||||
|
getNetworkStatus,
|
||||||
getUpdateStatus,
|
getUpdateStatus,
|
||||||
post,
|
post,
|
||||||
type AuthState,
|
type AuthState,
|
||||||
type SystemStatus,
|
type SystemStatus,
|
||||||
|
type NetworkStatus,
|
||||||
type UpdateStatus
|
type UpdateStatus
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
@@ -224,35 +226,67 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
|
|||||||
|
|
||||||
function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||||
const [update, setUpdate] = useState<UpdateStatus | null>(null);
|
const [update, setUpdate] = useState<UpdateStatus | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [network, setNetwork] = useState<NetworkStatus | null>(null);
|
||||||
|
const [updateError, setUpdateError] = useState("");
|
||||||
|
const [networkError, setNetworkError] = useState("");
|
||||||
const [requesting, setRequesting] = useState(false);
|
const [requesting, setRequesting] = useState(false);
|
||||||
const [confirming, setConfirming] = 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 running = update?.state === "checking" || update?.state === "building";
|
||||||
|
|
||||||
const refreshUpdate = useCallback(async () => {
|
const refreshUpdate = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setUpdate(await getUpdateStatus());
|
setUpdate(await getUpdateStatus());
|
||||||
setError("");
|
setUpdateError("");
|
||||||
} catch (caught) {
|
} 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(() => {
|
useEffect(() => {
|
||||||
void refreshUpdate();
|
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);
|
return () => window.clearInterval(interval);
|
||||||
}, [refreshUpdate]);
|
}, [refreshNetwork, refreshUpdate]);
|
||||||
|
|
||||||
async function startUpdate() {
|
async function startUpdate() {
|
||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
setRequesting(true);
|
setRequesting(true);
|
||||||
setError("");
|
setUpdateError("");
|
||||||
try {
|
try {
|
||||||
await post("/api/system/update", {}, csrfToken);
|
await post("/api/system/update", {}, csrfToken);
|
||||||
await refreshUpdate();
|
await refreshUpdate();
|
||||||
} catch (caught) {
|
} 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 {
|
} finally {
|
||||||
setRequesting(false);
|
setRequesting(false);
|
||||||
}
|
}
|
||||||
@@ -260,9 +294,57 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
|
|
||||||
const revision = update?.installedRevision?.slice(0, 12) ?? "Unavailable";
|
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 statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? "Updating" : "Ready";
|
||||||
|
const modeLabels: Record<NonNullable<NetworkStatus>["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 (
|
return (
|
||||||
<div className="settings-layout">
|
<div className="settings-layout">
|
||||||
|
<section className="settings-panel network-panel">
|
||||||
|
<div className="settings-title">
|
||||||
|
<div className="settings-icon"><WifiHigh size={30} weight="duotone" /></div>
|
||||||
|
<div><h2>Car network</h2><p>Keep the dashboard reachable through the built-in hotspot while preferring the AWUS1900 for upstream internet.</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="network-overview">
|
||||||
|
<div className={`network-mode mode-${network?.mode ?? "starting"}`}>
|
||||||
|
<span>Current mode</span>
|
||||||
|
<strong>{network ? modeLabels[network.mode] : "Loading"}</strong>
|
||||||
|
<p>{network?.message ?? "Reading network controller status..."}</p>
|
||||||
|
{probeSeconds !== null && <small>Offline hotspot recovery in at most {probeSeconds} seconds</small>}
|
||||||
|
</div>
|
||||||
|
<NetworkAdapter label="Built-in / car hotspot" adapter={network?.hotspot ?? null} />
|
||||||
|
<NetworkAdapter label="AWUS1900 / upstream" adapter={network?.upstream ?? null} />
|
||||||
|
<div className="network-adapter">
|
||||||
|
<span>Internet check</span>
|
||||||
|
<strong>{network?.connectivity ?? "unknown"}</strong>
|
||||||
|
<small>Reported by NetworkManager</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{networkError && <div className="form-error" role="alert"><WarningCircle size={20} />{networkError}</div>}
|
||||||
|
{network?.lastFailure && <p className="network-failure"><WarningCircle size={18} /> Last issue: {network.lastFailure}</p>}
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="primary-button" onClick={() => setNetworkAction("retry-upstream")} disabled={!network?.supported || requesting || network.mode === "probing_built_in"}>
|
||||||
|
<ArrowClockwise size={22} /> Try upstream again
|
||||||
|
</button>
|
||||||
|
<button className="secondary-button text-button" onClick={() => setNetworkAction("restore-hotspot")} disabled={!network?.supported || requesting || network.mode === "offline_hotspot" || network.mode === "dual_radio"}>
|
||||||
|
<WifiHigh size={22} /> Restore hotspot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!network?.supported && <p className="settings-note">Run the network setup helper on the Pi after creating a saved AWUS upstream profile. Automatic switching stays disabled until then.</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="settings-panel">
|
<section className="settings-panel">
|
||||||
<div className="settings-title">
|
<div className="settings-title">
|
||||||
<div className="settings-icon"><DownloadSimple size={30} weight="duotone" /></div>
|
<div className="settings-icon"><DownloadSimple size={30} weight="duotone" /></div>
|
||||||
@@ -281,7 +363,7 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
{!running && update?.state !== "failed" && <CheckCircle size={22} />}
|
{!running && update?.state !== "failed" && <CheckCircle size={22} />}
|
||||||
<span>{update?.message ?? "Reading update status..."}</span>
|
<span>{update?.message ?? "Reading update status..."}</span>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
|
{updateError && <div className="form-error" role="alert"><WarningCircle size={20} />{updateError}</div>}
|
||||||
|
|
||||||
<div className="settings-actions">
|
<div className="settings-actions">
|
||||||
<button className="primary-button" onClick={() => setConfirming(true)} disabled={!update?.supported || running || requesting}>
|
<button className="primary-button" onClick={() => setConfirming(true)} disabled={!update?.supported || running || requesting}>
|
||||||
@@ -311,6 +393,29 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
|
{networkAction && <div className="modal-backdrop" role="presentation" onMouseDown={() => setNetworkAction(null)}>
|
||||||
|
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="network-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
||||||
|
<h2 id="network-dialog-title">{networkAction === "retry-upstream" ? "Try upstream networking?" : "Restore the car hotspot?"}</h2>
|
||||||
|
<p>{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."}</p>
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="secondary-button" onClick={() => setNetworkAction(null)}>Cancel</button>
|
||||||
|
<button className="primary-button" onClick={() => void requestNetworkAction()}>Continue</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NetworkAdapter({ label, adapter }: { label: string; adapter: NetworkStatus["hotspot"] | null }) {
|
||||||
|
return (
|
||||||
|
<div className="network-adapter">
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{!adapter ? "Loading" : !adapter.available ? "Unavailable" : adapter.connected ? adapter.connection ?? "Connected" : "Disconnected"}</strong>
|
||||||
|
<small>{adapter?.address ?? adapter?.interface ?? "Waiting for status"}</small>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,28 @@ export type UpdateStatus = {
|
|||||||
supported: boolean;
|
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 } };
|
type ErrorResponse = { error?: { code?: string; message?: string } };
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -108,3 +130,7 @@ export async function getStatus(): Promise<SystemStatus> {
|
|||||||
export async function getUpdateStatus(): Promise<UpdateStatus> {
|
export async function getUpdateStatus(): Promise<UpdateStatus> {
|
||||||
return parseResponse<UpdateStatus>(await fetch("/api/system/update", { credentials: "same-origin" }));
|
return parseResponse<UpdateStatus>(await fetch("/api/system/update", { credentials: "same-origin" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getNetworkStatus(): Promise<NetworkStatus> {
|
||||||
|
return parseResponse<NetworkStatus>(await fetch("/api/network", { credentials: "same-origin" }));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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-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); }
|
.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 { display: flex; align-items: flex-start; gap: 18px; }
|
||||||
.settings-title h2, .compact-panel h2 { margin-bottom: 8px; font-size: 1.45rem; }
|
.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; }
|
.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; }
|
.health-summary { grid-template-columns: 1fr; gap: 24px; }
|
||||||
.metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.settings-layout { grid-template-columns: 1fr; }
|
.settings-layout { grid-template-columns: 1fr; }
|
||||||
|
.network-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
@@ -167,6 +180,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
.summary-details div { padding: 0; border: 0; }
|
.summary-details div { padding: 0; border: 0; }
|
||||||
.metrics-grid, .system-strip { grid-template-columns: 1fr; }
|
.metrics-grid, .system-strip { grid-template-columns: 1fr; }
|
||||||
.update-facts { 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, .dialog-actions { flex-direction: column; }
|
||||||
.settings-actions button, .dialog-actions button { width: 100%; }
|
.settings-actions button, .dialog-actions button { width: 100%; }
|
||||||
.skeleton-grid { grid-template-columns: 1fr; }
|
.skeleton-grid { grid-template-columns: 1fr; }
|
||||||
|
|||||||
Reference in New Issue
Block a user