From c36e5e811f151647dd251c61b5e3c55200873fc9 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 31 Jul 2026 21:30:51 +0200 Subject: [PATCH] Add tabbed settings and system shell --- README.md | 20 ++- install.sh | 5 +- package-lock.json | 106 +++++++++++++++ package.json | 2 +- scripts/pi-car-companion-power | 24 ++++ scripts/pi-car-companion-update | 23 +++- server/package.json | 3 + server/src/app.ts | 73 ++++++++++- server/src/shell.ts | 124 ++++++++++++++++++ server/src/system.ts | 15 +++ server/src/update.ts | 12 +- server/tests/app.test.ts | 23 ++++ server/tests/update.test.ts | 26 ++++ systemd/pi-car-companion-update-check.service | 13 ++ systemd/pi-car-companion.sudoers | 3 + web/package.json | 2 + web/src/App.tsx | 103 +++++++++++++-- web/src/ShellTerminal.tsx | 107 +++++++++++++++ web/src/api.ts | 2 +- web/src/styles.css | 41 +++++- 20 files changed, 698 insertions(+), 29 deletions(-) create mode 100644 scripts/pi-car-companion-power create mode 100644 server/src/shell.ts create mode 100644 server/src/system.ts create mode 100644 systemd/pi-car-companion-update-check.service create mode 100644 web/src/ShellTerminal.tsx diff --git a/README.md b/README.md index ac71be3..dea29de 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The current MVP slice includes: - atomic, dashboard-triggered Git updates with automatic verification and rollback safety; - optional dual-radio Wi-Fi failover with an always-recoverable offline car hotspot; - authenticated live network diagnostics for hotspot clients, DNS query metadata, and active destination flows; +- tabbed Network, System, and restricted Shell settings for on-device administration; - 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). @@ -42,7 +43,7 @@ cd pi-car-companion The script asks for sudo access and then: -- installs Node.js 22, Git, Avahi, SQLite, and native build requirements; +- installs Node.js 22, Git, Avahi, SQLite, the complete hotspot/Realtek networking stack, and native build requirements; - creates a dedicated `pi-companion` service account; - builds and verifies the server and dashboard; - installs an atomic release under `/opt/pi-car-companion`; @@ -62,7 +63,7 @@ git pull --ff-only ## Dashboard updates -Open **Settings** and select **Update now**. The Pi will: +Open **Settings → System**, select **Check for updates**, then choose **Install update**. The Pi will: 1. fetch the configured branch from `origin`; 2. refuse non-fast-forward history changes; @@ -178,7 +179,7 @@ Then create the recovery hotspot and enable its controller: 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. Alternatively, an authenticated administrator can configure or rotate the hotspot SSID and password directly in **Settings → Car network**. The dashboard uses a narrowly scoped root service; the one-time password request is deleted before processing and NetworkManager stores only the derived WPA key. +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. Alternatively, an authenticated administrator can configure or rotate the hotspot SSID and password directly in **Settings → Network**. The dashboard uses a narrowly scoped root service; the one-time password request is deleted before processing and NetworkManager stores only the derived WPA key. Settings also shows the current mode and offers **Try upstream again** and **Restore hotspot** controls. Applying new hotspot credentials can disconnect the dashboard, after which devices must reconnect using the new values. @@ -233,6 +234,17 @@ If the device is disconnected, offline, unauthorized, partially readable, or ADB missing, the dashboard reports that state without substituting generated data. Do not add shell strings or state-changing commands to this collector. +### System controls and shell + +**Settings → System** provides confirmed reboot and power-off actions through a fixed, +sudo-allowlisted helper. It also separates remote update checks from installation. + +**Settings → Shell** opens an authenticated WebSocket and a real local pseudo-terminal. +The shell runs as the restricted `pi-companion` service account inside the companion +service sandbox; it is not a root shell and does not request or store SSH credentials. +The WebSocket requires the active session, a trusted browser origin, and the session's +CSRF token. A terminal closes when its tab is left and expires after 30 minutes. + ## Safety boundary -This project does not modify the MG4 firmware, launcher, vehicle controls, CAN bus, or safety systems. It does not expose arbitrary shell commands, SSH, ADB, the Docker socket, or unrestricted filesystem access through the dashboard. +This project does not modify the MG4 firmware, launcher, vehicle controls, CAN bus, or safety systems. The authenticated dashboard includes a restricted `pi-companion` shell for local diagnostics, but it does not expose a root shell, SSH credentials, the Docker socket, unrestricted filesystem access, or arbitrary ADB commands. diff --git a/install.sh b/install.sh index bbbf4f7..68521ad 100755 --- a/install.sh +++ b/install.sh @@ -52,6 +52,7 @@ apt-get install -y \ iproute2 \ network-manager \ nftables \ + python3 \ rsync \ sqlite3 \ sudo \ @@ -137,9 +138,11 @@ ln -sfn "releases/${revision}" "${INSTALL_ROOT}/current.next" 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 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power +install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update 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-check.service" /etc/systemd/system/pi-car-companion-update-check.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 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service diff --git a/package-lock.json b/package-lock.json index 11adeee..2cf833e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1113,6 +1113,27 @@ ], "license": "MIT" }, + "node_modules/@fastify/websocket": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz", + "integrity": "sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.3", + "fastify-plugin": "^6.0.0", + "ws": "^8.16.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1748,6 +1769,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -2166,6 +2197,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -2725,6 +2771,18 @@ "node": ">=8" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.399", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", @@ -3783,6 +3841,22 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.51", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", @@ -4448,6 +4522,12 @@ "dev": true, "license": "MIT" }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4980,6 +5060,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -5015,14 +5116,17 @@ "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/rate-limit": "^10.3.0", + "@fastify/websocket": "^11.3.0", "argon2": "^0.44.0", "better-sqlite3": "^12.2.0", "fastify": "^5.4.0", + "node-pty": "^1.1.0", "zod": "^4.0.14" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.17.0", + "@types/ws": "^8.18.1", "tsx": "^4.20.3" } }, @@ -5031,6 +5135,8 @@ "version": "0.1.0", "dependencies": { "@phosphor-icons/react": "^2.1.10", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/package.json b/package.json index ea2adfc..5980d6c 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 scripts/pi-car-companion-network-setup", + "check:scripts": "bash -n install.sh scripts/pi-car-companion-update scripts/pi-car-companion-network-setup scripts/pi-car-companion-power", "check": "npm run check:scripts && npm run lint && npm run typecheck && npm test && npm run build" }, "devDependencies": { diff --git a/scripts/pi-car-companion-power b/scripts/pi-car-companion-power new file mode 100644 index 0000000..1db4df6 --- /dev/null +++ b/scripts/pi-car-companion-power @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "The power helper must run as root." >&2 + exit 1 +fi + +case "${1:-}" in + reboot|poweroff) + action="$1" + ;; + *) + echo "Usage: pi-car-companion-power reboot|poweroff" >&2 + exit 2 + ;; +esac + +/usr/bin/systemd-run \ + --quiet \ + --collect \ + --on-active=3s \ + --unit="pi-car-companion-${action}" \ + /usr/bin/systemctl "${action}" diff --git a/scripts/pi-car-companion-update b/scripts/pi-car-companion-update index 79eb60b..bc4ded0 100755 --- a/scripts/pi-car-companion-update +++ b/scripts/pi-car-companion-update @@ -18,6 +18,14 @@ RELEASE_TMP="" FROM_REVISION="" TO_REVISION="" PHASE="checking for updates" +CHECK_ONLY=false + +if [[ "${1:-}" == "--check-only" ]]; then + CHECK_ONLY=true +elif [[ $# -gt 0 ]]; then + echo "Usage: pi-car-companion-update [--check-only]" >&2 + exit 2 +fi if [[ ${EUID} -ne 0 ]]; then echo "The update helper must run as root." >&2 @@ -57,8 +65,10 @@ 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 0755 "${source_release}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power 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-check.service" /etc/systemd/system/pi-car-companion-update-check.service install -m 0644 "${source_release}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service install -m 0644 "${source_release}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service install -m 0644 "${source_release}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service @@ -115,9 +125,11 @@ fi TO_REVISION="$(git -C "${REPOSITORY}" rev-parse "origin/${branch}")" if [[ -n "${FROM_REVISION}" && "${FROM_REVISION}" == "${TO_REVISION}" ]]; then - install_integration_files "${CURRENT}" - visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null - systemctl daemon-reload + if [[ "${CHECK_ONLY}" != "true" ]]; then + install_integration_files "${CURRENT}" + visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null + systemctl daemon-reload + fi write_status "current" "Pi Car Companion is already up to date." exit 0 fi @@ -127,6 +139,11 @@ if [[ -n "${FROM_REVISION}" ]] && ! git -C "${REPOSITORY}" merge-base --is-ances exit 1 fi +if [[ "${CHECK_ONLY}" == "true" ]]; then + write_status "available" "A newer Pi Car Companion release is available to install." + exit 0 +fi + PHASE="building the new release" write_status "building" "Downloading dependencies and verifying the new release." install -d -m 0755 "${STAGING_ROOT}" "${RELEASES}" diff --git a/server/package.json b/server/package.json index 3c26e1b..85407ee 100644 --- a/server/package.json +++ b/server/package.json @@ -15,14 +15,17 @@ "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/rate-limit": "^10.3.0", + "@fastify/websocket": "^11.3.0", "argon2": "^0.44.0", "better-sqlite3": "^12.2.0", "fastify": "^5.4.0", + "node-pty": "^1.1.0", "zod": "^4.0.14" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.17.0", + "@types/ws": "^8.18.1", "tsx": "^4.20.3" } } diff --git a/server/src/app.ts b/server/src/app.ts index cb06de0..3f2ad06 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import argon2 from "argon2"; import cookie from "@fastify/cookie"; import rateLimit from "@fastify/rate-limit"; +import websocket from "@fastify/websocket"; import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"; import { z } from "zod"; import type { AppConfig } from "./config.js"; @@ -28,6 +29,8 @@ import { requestNetworkAction } from "./network.js"; import { collectAdbStatus } from "./adb.js"; +import { openShell } from "./shell.js"; +import { systemPowerActionSchema, triggerSystemPower } from "./system.js"; import "./types.js"; const usernameSchema = z @@ -114,6 +117,7 @@ export async function buildApp(config: AppConfig): Promise { app.decorateRequest("authUser", null); await app.register(cookie); await app.register(rateLimit, { global: false }); + await app.register(websocket); app.addHook("onClose", async () => { jobRunner.close(); @@ -405,6 +409,37 @@ export async function buildApp(config: AppConfig): Promise { return readUpdateStatus(config); }); + app.post( + "/api/system/update/check", + { config: { rateLimit: { max: 6, timeWindow: "10 minutes" } } }, + async (request, reply) => { + if (!requireUser(request, reply)) return; + const current = await readUpdateStatus(config); + if (!current.supported) { + return reply.code(503).send({ + error: { code: "UPDATES_UNAVAILABLE", message: "Install the system service before checking for updates" } + }); + } + if (["checking", "building"].includes(current.state)) { + return reply.code(409).send({ error: { code: "UPDATE_IN_PROGRESS", message: "An update task is already running" } }); + } + try { + await triggerSystemUpdate("check"); + audit(database, { + actorUserId: request.authUser!.id, + action: "system.update-check", + result: "success", + ipAddress: clientAddress(request) + }); + return reply.code(202).send({ queued: true }); + } catch { + return reply.code(503).send({ + error: { code: "UPDATE_CHECK_FAILED", message: "The update check could not be started" } + }); + } + } + ); + app.post( "/api/system/update", { config: { rateLimit: { max: 2, timeWindow: "10 minutes" } } }, @@ -421,7 +456,7 @@ export async function buildApp(config: AppConfig): Promise { } try { - await triggerSystemUpdate(); + await triggerSystemUpdate("install"); audit(database, { actorUserId: request.authUser!.id, action: "system.update", @@ -445,6 +480,42 @@ export async function buildApp(config: AppConfig): Promise { } ); + app.post( + "/api/system/power", + { config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } }, + async (request, reply) => { + if (!requireUser(request, reply)) return; + const parsed = z.object({ action: systemPowerActionSchema }).safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: { code: "INVALID_POWER_ACTION", message: "That power action is not available" } }); + } + try { + await triggerSystemPower(parsed.data.action); + audit(database, { + actorUserId: request.authUser!.id, + action: `system.${parsed.data.action}`, + result: "success", + ipAddress: clientAddress(request) + }); + return reply.code(202).send({ queued: true, action: parsed.data.action }); + } catch { + audit(database, { + actorUserId: request.authUser!.id, + action: `system.${parsed.data.action}`, + result: "failure", + ipAddress: clientAddress(request) + }); + return reply.code(503).send({ + error: { code: "POWER_ACTION_FAILED", message: "The Pi could not schedule that power action" } + }); + } + } + ); + + app.get("/api/system/shell", { websocket: true }, (socket, request) => { + openShell(socket, request, config); + }); + app.get("/api/events", async (request, reply) => { if (!requireUser(request, reply)) return; reply.hijack(); diff --git a/server/src/shell.ts b/server/src/shell.ts new file mode 100644 index 0000000..a12ef9e --- /dev/null +++ b/server/src/shell.ts @@ -0,0 +1,124 @@ +import type { FastifyRequest } from "fastify"; +import { dirname } from "node:path"; +import * as pty from "node-pty"; +import type { WebSocket } from "ws"; +import type { AppConfig } from "./config.js"; +import { CSRF_COOKIE, safeEqual } from "./security.js"; + +type ClientMessage = + | { type: "authenticate"; csrfToken: string } + | { type: "input"; data: string } + | { type: "resize"; cols: number; rows: number }; + +function send(socket: WebSocket, value: Record): void { + if (socket.readyState === 1) socket.send(JSON.stringify(value)); +} + +function trustedOrigin(request: FastifyRequest, config: AppConfig): boolean { + const origin = request.headers.origin; + if (!origin) return config.nodeEnv === "test"; + try { + const parsed = new URL(origin); + return ( + (["http:", "https:"].includes(parsed.protocol) && Boolean(request.headers.host) && parsed.host === request.headers.host) || + config.allowedOrigins.has(origin) + ); + } catch { + return false; + } +} + +export function openShell(socket: WebSocket, request: FastifyRequest, config: AppConfig): void { + if (!request.authUser || !trustedOrigin(request, config)) { + socket.close(1008, "Authentication required"); + return; + } + + let terminal: pty.IPty | null = null; + const authenticationTimer = setTimeout(() => socket.close(1008, "Authentication timed out"), 5_000); + const lifetimeTimer = setTimeout(() => socket.close(1000, "Terminal session expired"), 30 * 60 * 1_000); + + const closeTerminal = () => { + clearTimeout(authenticationTimer); + clearTimeout(lifetimeTimer); + if (terminal) { + try { + terminal.kill(); + } catch { + // The shell may already have exited. + } + terminal = null; + } + }; + + socket.on("close", closeTerminal); + socket.on("error", closeTerminal); + socket.on("message", (raw) => { + const serialized = Array.isArray(raw) + ? Buffer.concat(raw).toString("utf8") + : raw instanceof ArrayBuffer + ? Buffer.from(new Uint8Array(raw)).toString("utf8") + : raw.toString("utf8"); + if (Buffer.byteLength(serialized) > 16_384) { + socket.close(1009, "Message too large"); + return; + } + + let message: ClientMessage; + try { + message = JSON.parse(serialized) as ClientMessage; + } catch { + socket.close(1003, "Invalid terminal message"); + return; + } + + if (!terminal) { + const cookieToken = request.cookies[CSRF_COOKIE]; + if ( + message.type !== "authenticate" || + typeof message.csrfToken !== "string" || + !cookieToken || + !safeEqual(cookieToken, message.csrfToken) + ) { + socket.close(1008, "Authentication failed"); + return; + } + clearTimeout(authenticationTimer); + const shellHome = dirname(config.databasePath); + terminal = pty.spawn("/bin/bash", ["--noprofile", "--norc"], { + name: "xterm-256color", + cols: 100, + rows: 28, + cwd: shellHome, + env: { + HOME: shellHome, + LANG: process.env.LANG ?? "C.UTF-8", + PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + SHELL: "/bin/bash", + TERM: "xterm-256color", + USER: "pi-companion" + } + }); + terminal.onData((data) => send(socket, { type: "output", data })); + terminal.onExit(({ exitCode }) => { + terminal = null; + send(socket, { type: "exit", exitCode }); + socket.close(1000, "Shell exited"); + }); + send(socket, { type: "ready" }); + return; + } + + if (message.type === "input" && typeof message.data === "string" && message.data.length <= 8_192) { + terminal.write(message.data); + } else if ( + message.type === "resize" && + Number.isInteger(message.cols) && + Number.isInteger(message.rows) && + message.cols >= 20 && message.cols <= 240 && + message.rows >= 5 && message.rows <= 100 + ) { + terminal.resize(message.cols, message.rows); + } + }); +} diff --git a/server/src/system.ts b/server/src/system.ts new file mode 100644 index 0000000..6da5dec --- /dev/null +++ b/server/src/system.ts @@ -0,0 +1,15 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { z } from "zod"; + +const execFileAsync = promisify(execFile); + +export const systemPowerActionSchema = z.enum(["reboot", "poweroff"]); +export type SystemPowerAction = z.infer; + +export async function triggerSystemPower(action: SystemPowerAction): Promise { + await execFileAsync("/usr/bin/sudo", ["-n", "/usr/local/sbin/pi-car-companion-power", action], { + timeout: 10_000, + maxBuffer: 16 * 1024 + }); +} diff --git a/server/src/update.ts b/server/src/update.ts index 55eda62..2e12961 100644 --- a/server/src/update.ts +++ b/server/src/update.ts @@ -7,7 +7,7 @@ import type { AppConfig } from "./config.js"; const execFileAsync = promisify(execFile); const updateStatusSchema = z.object({ - state: z.enum(["idle", "checking", "building", "current", "success", "failed"]), + state: z.enum(["idle", "checking", "available", "building", "current", "success", "failed"]), message: z.string().max(500), fromRevision: z.string().max(64).nullable().default(null), toRevision: z.string().max(64).nullable().default(null), @@ -56,10 +56,16 @@ export async function readUpdateStatus(config: AppConfig): Promise } } -export async function triggerSystemUpdate(): Promise { +export async function triggerSystemUpdate(mode: "check" | "install" = "install"): Promise { await execFileAsync( "/usr/bin/sudo", - ["-n", "/usr/bin/systemctl", "start", "--no-block", "pi-car-companion-update.service"], + [ + "-n", + "/usr/bin/systemctl", + "start", + "--no-block", + mode === "check" ? "pi-car-companion-update-check.service" : "pi-car-companion-update.service" + ], { timeout: 10_000, maxBuffer: 16 * 1024 } ); } diff --git a/server/tests/app.test.ts b/server/tests/app.test.ts index 58ce402..7896fd5 100644 --- a/server/tests/app.test.ts +++ b/server/tests/app.test.ts @@ -179,6 +179,29 @@ describe("authentication boundary", () => { }); expect(update.statusCode).toBe(503); expect(update.json()).toMatchObject({ error: { code: "UPDATES_UNAVAILABLE" } }); + + const check = await app.inject({ + method: "POST", + url: "/api/system/update/check", + headers: { + ...mutationHeaders(token), + cookie: `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${sessionCookie?.value ?? ""}` + } + }); + expect(check.statusCode).toBe(503); + expect(check.json()).toMatchObject({ error: { code: "UPDATES_UNAVAILABLE" } }); + + const invalidPower = await app.inject({ + method: "POST", + url: "/api/system/power", + headers: { + ...mutationHeaders(token), + cookie: `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${sessionCookie?.value ?? ""}` + }, + payload: { action: "factory-reset" } + }); + expect(invalidPower.statusCode).toBe(400); + expect(invalidPower.json()).toMatchObject({ error: { code: "INVALID_POWER_ACTION" } }); }); it("does not expose update state without authentication", async () => { diff --git a/server/tests/update.test.ts b/server/tests/update.test.ts index 7721244..89d362b 100644 --- a/server/tests/update.test.ts +++ b/server/tests/update.test.ts @@ -58,6 +58,32 @@ describe("system update status", () => { }); }); + it("reports a check-only result when a newer revision is available", async () => { + const directory = await mkdtemp(join(tmpdir(), "pi-car-update-test-")); + temporaryDirectories.push(directory); + const statusPath = join(directory, "status.json"); + const versionPath = join(directory, "REVISION"); + await writeFile( + statusPath, + JSON.stringify({ + state: "available", + message: "A newer release is available.", + fromRevision: "a".repeat(40), + toRevision: "b".repeat(40), + updatedAt: "2026-07-31T18:00:00.000Z" + }) + ); + await writeFile(versionPath, `${"a".repeat(40)}\n`); + + await expect(readUpdateStatus(config(statusPath, versionPath))).resolves.toMatchObject({ + state: "available", + fromRevision: "a".repeat(40), + toRevision: "b".repeat(40), + installedRevision: "a".repeat(40), + supported: true + }); + }); + it("treats missing or malformed status files as unsupported", async () => { const directory = await mkdtemp(join(tmpdir(), "pi-car-update-test-")); temporaryDirectories.push(directory); diff --git a/systemd/pi-car-companion-update-check.service b/systemd/pi-car-companion-update-check.service new file mode 100644 index 0000000..347343e --- /dev/null +++ b/systemd/pi-car-companion-update-check.service @@ -0,0 +1,13 @@ +[Unit] +Description=Check for Pi Car Companion updates +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=root +Group=root +ExecStart=/usr/local/sbin/pi-car-companion-update --check-only +TimeoutStartSec=5min +Nice=10 +IOSchedulingClass=idle diff --git a/systemd/pi-car-companion.sudoers b/systemd/pi-car-companion.sudoers index b108efe..f1d8c66 100644 --- a/systemd/pi-car-companion.sudoers +++ b/systemd/pi-car-companion.sudoers @@ -1,2 +1,5 @@ pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-companion-update.service +pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-companion-update-check.service pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-companion-network-configure.service +pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-power reboot +pi-companion ALL=(root) NOPASSWD: /usr/local/sbin/pi-car-companion-power poweroff diff --git a/web/package.json b/web/package.json index 64140bd..e9ab6e3 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,8 @@ }, "dependencies": { "@phosphor-icons/react": "^2.1.10", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index 812c135..39d7236 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type FormEvent } from "react"; +import { lazy, Suspense, useCallback, useEffect, useState, type FormEvent } from "react"; import { ArrowClockwise, CheckCircle, @@ -9,6 +9,7 @@ import { Gear, GitBranch, DownloadSimple, + Power, SignOut, TerminalWindow, WarningCircle, @@ -29,6 +30,11 @@ import { type UpdateStatus } from "./api"; +const ShellTerminal = lazy(async () => { + const module = await import("./ShellTerminal"); + return { default: module.ShellTerminal }; +}); + type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal"; export function App() { @@ -311,12 +317,15 @@ function portLabel(port: number): string { } function SettingsPage({ csrfToken }: { csrfToken: string }) { + const [tab, setTab] = useState<"network" | "system" | "shell">("network"); const [update, setUpdate] = useState(null); const [network, setNetwork] = useState(null); const [updateError, setUpdateError] = useState(""); const [networkError, setNetworkError] = useState(""); + const [systemNotice, setSystemNotice] = useState(""); const [requesting, setRequesting] = useState(false); const [confirming, setConfirming] = useState(false); + const [powerAction, setPowerAction] = useState<"reboot" | "poweroff" | null>(null); const [networkAction, setNetworkAction] = useState<"retry-upstream" | "restore-hotspot" | null>(null); const [hotspotSsid, setHotspotSsid] = useState("MG4-Companion"); const [hotspotPassword, setHotspotPassword] = useState(""); @@ -372,6 +381,35 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) { } } + async function checkForUpdates() { + setRequesting(true); + setUpdateError(""); + try { + await post("/api/system/update/check", {}, csrfToken); + window.setTimeout(() => void refreshUpdate(), 700); + } catch (caught) { + setUpdateError(caught instanceof ApiError ? caught.message : "The update check could not be started"); + } finally { + setRequesting(false); + } + } + + async function requestPowerAction() { + if (!powerAction) return; + const action = powerAction; + setPowerAction(null); + setRequesting(true); + setUpdateError(""); + try { + await post("/api/system/power", { action }, csrfToken); + setSystemNotice(action === "reboot" ? "Reboot scheduled. The dashboard will disconnect briefly." : "Power off scheduled. The Pi will shut down safely."); + } catch (caught) { + setUpdateError(caught instanceof ApiError ? caught.message : "The power action could not be scheduled"); + } finally { + setRequesting(false); + } + } + async function requestNetworkAction() { if (!networkAction) return; const action = networkAction; @@ -426,7 +464,7 @@ 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 statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "available" ? "Update available" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? (update.state === "checking" ? "Checking" : "Updating") : "Ready"; const modeLabels: Record["mode"], string> = { disabled: "Not configured", starting: "Starting", @@ -442,11 +480,18 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) { : null; return ( -
+
+ + + {tab === "network" &&
-

Car network

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

+

Network

The built-in radio hosts the car network while the AWUS1900 provides upstream internet whenever it is available.

@@ -456,7 +501,11 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {

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

{probeSeconds !== null && Offline hotspot recovery in at most {probeSeconds} seconds}
- +
+ Hotspot + {network?.hotspotSsid ?? (network ? "Not active" : "Loading")} + {network?.hotspot.address ?? network?.hotspot.interface ?? "Waiting for status"} +
Internet check @@ -500,17 +549,33 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {

The password is sent only to the local Pi configuration service and is stored by NetworkManager as a derived WPA key.

+
} -
+ {tab === "system" &&
+
+
+
+

General system

Restart or safely shut down the Raspberry Pi.

+
+ {systemNotice &&
{systemNotice}
} +
+ + +
+

Both actions use systemd for a clean shutdown. Power can be removed only after the Pi has stopped.

+
+ +
-

Software update

Fetch, verify, build, and activate the newest release from the configured Git branch.

+

Software update

Check the configured Git branch, then verify and activate a newer release when you choose.

Status
{statusLabel}
Installed revision
{revision}
-
Last activity
{update && update.updatedAt !== new Date(0).toISOString() ? new Date(update.updatedAt).toLocaleString() : "Never"}
+
Available revision
{update?.toRevision?.slice(0, 12) ?? "Not checked"}
+
Last check
{update && update.updatedAt !== new Date(0).toISOString() ? new Date(update.updatedAt).toLocaleString() : "Never"}
@@ -522,10 +587,12 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) { {updateError &&
{updateError}
}
- + -

The current release stays active unless the new version passes installation, lint, type checks, tests, and production builds.

@@ -538,6 +605,9 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
Update channel
{update?.supported ? "Configured Git branch" : "Not configured"}
+
} + + {tab === "shell" &&
Loading terminal…
}>
} {confirming &&
setConfirming(false)}>
event.stopPropagation()}> @@ -573,6 +643,17 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
} + + {powerAction &&
setPowerAction(null)}> +
event.stopPropagation()}> +

{powerAction === "reboot" ? "Reboot the Pi?" : "Power off the Pi?"}

+

{powerAction === "reboot" ? "The dashboard and hotspot will disconnect while the Pi restarts, then return automatically." : "The dashboard and hotspot will disconnect. Physical access may be required to turn the Pi back on."}

+
+ + +
+
+
} ); } diff --git a/web/src/ShellTerminal.tsx b/web/src/ShellTerminal.tsx new file mode 100644 index 0000000..801ac72 --- /dev/null +++ b/web/src/ShellTerminal.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from "react"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; + +type ServerMessage = + | { type: "ready" } + | { type: "output"; data: string } + | { type: "exit"; exitCode: number } + | { type: "error"; message: string }; + +export function ShellTerminal({ csrfToken }: { csrfToken: string }) { + const host = useRef(null); + const [generation, setGeneration] = useState(0); + const [state, setState] = useState<"connecting" | "ready" | "closed" | "error">("connecting"); + + useEffect(() => { + if (!host.current) return; + const terminal = new Terminal({ + cursorBlink: true, + cursorStyle: "bar", + fontFamily: 'ui-monospace, "Cascadia Code", "SFMono-Regular", monospace', + fontSize: 14, + lineHeight: 1.25, + scrollback: 3_000, + theme: { + background: "#0b0e0a", + foreground: "#dce4d6", + cursor: "#b9e769", + selectionBackground: "#43542c", + black: "#10130f", + green: "#b9e769", + brightGreen: "#d0f397", + red: "#ff9b8d" + } + }); + const fit = new FitAddon(); + terminal.loadAddon(fit); + terminal.open(host.current); + fit.fit(); + terminal.writeln("\x1b[38;2;185;231;105mPi Car Companion shell\x1b[0m"); + terminal.writeln("Restricted local session running as pi-companion.\r\n"); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket(`${protocol}//${window.location.host}/api/system/shell`); + const sendSize = () => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: "resize", cols: terminal.cols, rows: terminal.rows })); + } + }; + socket.addEventListener("open", () => { + socket.send(JSON.stringify({ type: "authenticate", csrfToken })); + }); + socket.addEventListener("message", (event) => { + try { + const message = JSON.parse(String(event.data)) as ServerMessage; + if (message.type === "ready") { + setState("ready"); + sendSize(); + terminal.focus(); + } else if (message.type === "output") terminal.write(message.data); + else if (message.type === "exit") { + terminal.writeln(`\r\n\x1b[90mShell exited with status ${message.exitCode}.\x1b[0m`); + setState("closed"); + } else if (message.type === "error") { + terminal.writeln(`\r\n\x1b[31m${message.message}\x1b[0m`); + setState("error"); + } + } catch { + terminal.writeln("\r\n\x1b[31mInvalid response from the shell service.\x1b[0m"); + } + }); + socket.addEventListener("close", (event) => { + if (event.code !== 1000) { + terminal.writeln(`\r\n\x1b[31mTerminal disconnected${event.reason ? `: ${event.reason}` : "."}\x1b[0m`); + setState("error"); + } else setState((current) => current === "error" ? "error" : "closed"); + }); + socket.addEventListener("error", () => setState("error")); + const input = terminal.onData((data) => { + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "input", data })); + }); + const resize = new ResizeObserver(() => { + fit.fit(); + sendSize(); + }); + resize.observe(host.current); + + return () => { + resize.disconnect(); + input.dispose(); + socket.close(1000, "Terminal tab closed"); + terminal.dispose(); + }; + }, [csrfToken, generation]); + + return ( +
+
+
+ {state !== "ready" && } +
+
+

This authenticated terminal runs locally as the restricted pi-companion service account. Sessions close when you leave this tab and expire after 30 minutes.

+
+ ); +} diff --git a/web/src/api.ts b/web/src/api.ts index 5420903..f9526f7 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -57,7 +57,7 @@ export type SystemStatus = { }; export type UpdateStatus = { - state: "idle" | "checking" | "building" | "current" | "success" | "failed"; + state: "idle" | "checking" | "available" | "building" | "current" | "success" | "failed"; message: string; fromRevision: string | null; toRevision: string | null; diff --git a/web/src/styles.css b/web/src/styles.css index 6eedf62..c4f987f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -39,12 +39,14 @@ h2 { letter-spacing: -0.025em; } .error-screen svg { color: var(--danger); margin-bottom: 22px; } .screen-copy { max-width: 520px; color: var(--muted); line-height: 1.6; } -.primary-button, .secondary-button { min-height: 56px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; border-radius: 10px; padding: 0 24px; border: 0; font-weight: 750; cursor: pointer; transition: transform 120ms ease, background 120ms ease; white-space: nowrap; } +.primary-button, .secondary-button, .danger-button { min-height: 56px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; border-radius: 10px; padding: 0 24px; border: 0; font-weight: 750; cursor: pointer; transition: transform 120ms ease, background 120ms ease; white-space: nowrap; } .primary-button { color: var(--accent-ink); background: var(--accent); } .secondary-button { color: var(--text); background: var(--surface-raised); border: 1px solid var(--line); } +.danger-button { color: #ffe3df; background: #552820; border: 1px solid #804035; } .primary-button:hover { background: #c8f17f; } .secondary-button:hover { background: #262c24; } -.primary-button:active, .secondary-button:active, .nav-item:active { transform: scale(0.98); } +.danger-button:hover { background: #683127; } +.primary-button:active, .secondary-button:active, .danger-button:active, .nav-item:active { transform: scale(0.98); } button:disabled { opacity: 0.55; cursor: not-allowed; } .account-layout { min-height: 100dvh; display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(420px, 0.95fr); } @@ -135,7 +137,15 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .activity-empty { min-height: 74px; display: grid; place-items: center; padding: 18px; color: var(--muted); font-size: 0.8rem; text-align: center; } .activity-privacy { margin: 0; color: var(--muted); font-size: 0.72rem; text-align: right; } +.settings-page { display: grid; gap: 18px; } +.settings-tabs { width: fit-content; display: flex; gap: 5px; padding: 5px; background: #121610; border: 1px solid var(--line); border-radius: 12px; } +.settings-tabs button { min-height: 48px; display: inline-flex; align-items: center; gap: 9px; padding: 0 19px; color: var(--muted); background: transparent; border: 0; border-radius: 8px; font-weight: 700; cursor: pointer; transition: color 160ms ease, background 160ms ease, transform 120ms ease; } +.settings-tabs button:hover { color: var(--text); background: var(--surface-raised); } +.settings-tabs button:active { transform: scale(0.98); } +.settings-tabs button.active { color: var(--accent-ink); background: var(--accent); } .settings-layout { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.7fr); gap: 18px; align-items: start; } +.system-settings-layout { display: grid; grid-template-columns: minmax(280px, 0.65fr) minmax(0, 1.35fr); gap: 18px; align-items: start; } +.update-panel { grid-row: span 2; } .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); } @@ -162,21 +172,36 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .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-icon { flex: none; width: 56px; height: 56px; display: grid; place-items: center; color: var(--accent); background: rgba(185, 231, 105, 0.08); border-radius: 12px; } -.update-facts { display: grid; grid-template-columns: 0.8fr 1fr 1.2fr; gap: 20px; margin: 30px 0 20px; padding: 22px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.update-facts { display: grid; grid-template-columns: 0.7fr 1fr 1fr 1.2fr; gap: 20px; margin: 30px 0 20px; padding: 22px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } .update-facts div { display: grid; gap: 7px; } .update-facts dt, .behavior-list dt { color: var(--muted); font-size: 0.8rem; } .update-facts dd, .behavior-list dd { display: flex; align-items: center; gap: 7px; margin: 0; font-weight: 700; } .update-facts code { font-family: ui-monospace, "Cascadia Code", monospace; color: #dce4d6; } .update-state.failed { color: var(--danger); } -.update-state.success, .update-state.current, .update-state.building, .update-state.checking { color: var(--accent); } +.update-state.success, .update-state.current, .update-state.available, .update-state.building, .update-state.checking { color: var(--accent); } .update-message { min-height: 56px; display: flex; align-items: center; gap: 12px; margin-bottom: 18px; padding: 14px 16px; color: #dce4d6; background: #121610; border-radius: 10px; } .update-message svg { flex: none; color: var(--accent); } .update-message .ph-warning-circle { color: var(--danger); } .settings-actions { display: flex; gap: 12px; margin-top: 22px; } .settings-note { max-width: 680px; margin: 20px 0 0; color: var(--muted); font-size: 0.83rem; line-height: 1.5; } +.power-panel .network-notice { margin-top: 24px; } +.power-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 30px; } .behavior-list { display: grid; gap: 24px; margin: 26px 0 0; } .behavior-list div { display: grid; gap: 7px; } .behavior-list dd { color: #dce4d6; } +.shell-panel { overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); } +.shell-loading { min-height: 420px; display: grid; place-items: center; color: var(--muted); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); } +.shell-toolbar { min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 10px 14px 10px 20px; border-bottom: 1px solid var(--line); } +.shell-toolbar > div { display: grid; grid-template-columns: auto auto; align-items: center; gap: 3px 9px; } +.shell-toolbar strong { font-size: 0.87rem; } +.shell-toolbar small { grid-column: 2; color: var(--muted); font: 0.7rem/1.2 ui-monospace, "Cascadia Code", monospace; } +.shell-state { grid-row: 1 / 3; width: 9px; height: 9px; border-radius: 50%; background: #7c8477; } +.shell-state.ready { background: var(--accent); box-shadow: 0 0 0 5px rgba(185, 231, 105, 0.08); } +.shell-state.error { background: var(--danger); } +.terminal-host { height: min(590px, calc(100dvh - 290px)); min-height: 360px; padding: 16px 12px; background: #0b0e0a; } +.terminal-host .xterm { height: 100%; } +.shell-panel > .settings-note { margin: 0; padding: 14px 20px 16px; border-top: 1px solid var(--line); } +.shell-panel code { color: #dce4d6; } .modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 24px; background: rgba(8, 10, 8, 0.78); } .confirm-dialog { width: min(520px, 100%); padding: 28px; background: var(--surface-raised); border: 1px solid #465043; border-radius: var(--radius); box-shadow: 0 24px 80px rgba(3, 5, 3, 0.45); } .confirm-dialog h2 { margin-bottom: 12px; font-size: 1.6rem; } @@ -197,6 +222,8 @@ 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; } + .system-settings-layout { grid-template-columns: 1fr; } + .update-panel { grid-row: auto; } .network-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .hotspot-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } .activity-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -231,6 +258,12 @@ button:disabled { opacity: 0.55; cursor: not-allowed; } .activity-summary, .client-grid { grid-template-columns: 1fr; } .settings-actions, .dialog-actions { flex-direction: column; } .settings-actions button, .dialog-actions button { width: 100%; } + .settings-actions .text-button, .dialog-actions .text-button { font-size: inherit; } + .settings-tabs { width: 100%; } + .settings-tabs button { flex: 1; justify-content: center; padding: 0 10px; } + .power-actions { grid-template-columns: 1fr; } + .power-actions .secondary-button { width: 100%; padding: 0 18px; font-size: inherit; } + .terminal-host { height: 58dvh; min-height: 320px; } .skeleton-grid { grid-template-columns: 1fr; } }