Build MVP with Pi installer and atomic updates
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
NODE_ENV=production
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=8787
|
||||||
|
DATABASE_PATH=/var/lib/pi-car-companion/companion.db
|
||||||
|
SESSION_TTL_HOURS=24
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
# Comma-separated exact origins. Set when the dashboard is served by another origin.
|
||||||
|
ALLOWED_ORIGINS=http://mg4pi.local:8787
|
||||||
|
UPDATE_STATUS_PATH=/var/lib/pi-car-companion/update-status.json
|
||||||
|
VERSION_FILE=/opt/pi-car-companion/current/REVISION
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
server/data/
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
# Pi Car Companion — Product and Delivery Plan
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Pi Car Companion is a local-first companion computer for an MG4. A Raspberry Pi 5 runs services, diagnostics, automations, and a touch-friendly dashboard; the car's existing Android infotainment system remains the display and input device.
|
||||||
|
|
||||||
|
The project supplements the vehicle. It must never replace or modify the OEM launcher, firmware, vehicle controls, CAN bus, safety systems, or any driving-critical function.
|
||||||
|
|
||||||
|
The intended daily flow is simple:
|
||||||
|
|
||||||
|
1. The Pi boots independently and starts the companion service.
|
||||||
|
2. The MG4 Android app opens the Pi-hosted dashboard at `http://mg4pi.local:8787`.
|
||||||
|
3. The user sees real system state and can run a small set of audited, server-defined actions.
|
||||||
|
4. If the native app is unavailable, the same dashboard works in a browser or as a PWA.
|
||||||
|
|
||||||
|
Core operation must work without cloud access. Optional remote access is a later, explicitly configured feature.
|
||||||
|
|
||||||
|
## 2. Product principles
|
||||||
|
|
||||||
|
- **Local first:** the Pi and dashboard remain useful on an isolated LAN.
|
||||||
|
- **Safe by construction:** privileged actions are allowlisted, validated, confirmed where necessary, and audited.
|
||||||
|
- **Truthful UI:** unavailable information is shown as unavailable with a reason; no fake telemetry, devices, logs, or success states.
|
||||||
|
- **Headless Pi:** routine use and administration require no dedicated Pi display.
|
||||||
|
- **Touch first:** the primary target is a roughly 1920×720 landscape infotainment screen with large controls and no hover-only interactions.
|
||||||
|
- **Recoverable:** service failures, network loss, partial jobs, and interrupted updates have explicit recovery paths.
|
||||||
|
- **Least privilege:** the service runs as a dedicated non-root user and receives only narrowly scoped privileges.
|
||||||
|
- **Progressive scope:** establish a secure end-to-end vertical slice before adding integrations or convenience features.
|
||||||
|
|
||||||
|
## 3. Scope
|
||||||
|
|
||||||
|
### Version 1
|
||||||
|
|
||||||
|
- First-run administrator creation, login, logout, and session management
|
||||||
|
- Real Raspberry Pi health and network status
|
||||||
|
- Live connection and job progress updates
|
||||||
|
- Server-defined jobs with timeouts, validation, logs, and audit history
|
||||||
|
- Initial jobs:
|
||||||
|
- refresh system health
|
||||||
|
- run network diagnostics
|
||||||
|
- generate a sanitized support bundle
|
||||||
|
- Touch-focused web dashboard
|
||||||
|
- Native Android WebView shell with editable trusted dashboard URL and reconnect controls
|
||||||
|
- Raspberry Pi bootstrap, systemd service, health check, update, and uninstall scripts
|
||||||
|
- Automated tests, production builds, and operator documentation
|
||||||
|
|
||||||
|
### Version 1.1
|
||||||
|
|
||||||
|
- Controlled artifact upload, listing, download, and deletion
|
||||||
|
- SHA-256 metadata and storage quota reporting
|
||||||
|
- Wi-Fi status/reconnect where supported by the host
|
||||||
|
- Log rotation/pruning job
|
||||||
|
- Companion service restart job
|
||||||
|
- Reboot and shutdown actions with strong confirmation
|
||||||
|
- Audit filtering and diagnostic export improvements
|
||||||
|
- Optional PWA installation support
|
||||||
|
|
||||||
|
### Later, only after real-world validation
|
||||||
|
|
||||||
|
- Tailscale-assisted remote access
|
||||||
|
- Additional local services and automations
|
||||||
|
- Media-adjacent utilities
|
||||||
|
- Read-only vehicle integrations, only with a separate threat model and explicit hardware testing
|
||||||
|
|
||||||
|
### Explicitly out of scope
|
||||||
|
|
||||||
|
- Arbitrary shell, terminal, SSH, ADB, Docker socket, or unrestricted file-browser access through the dashboard
|
||||||
|
- Public-internet exposure by default
|
||||||
|
- Hardcoded or default credentials
|
||||||
|
- Rooting, flashing, or modifying MG4 infotainment software
|
||||||
|
- Sending vehicle-control commands or interacting with safety-critical systems
|
||||||
|
- Claiming hardware behavior that has not been tested on the actual Pi/MG4 installation
|
||||||
|
|
||||||
|
## 4. Proposed architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
MG4 Android app / browser
|
||||||
|
|
|
||||||
|
| trusted local HTTP(S) origin
|
||||||
|
v
|
||||||
|
Fastify API + static React app <----> SQLite
|
||||||
|
|
|
||||||
|
+---- system status collectors
|
||||||
|
+---- allowlisted job handlers
|
||||||
|
+---- controlled artifact storage
|
||||||
|
+---- audit and diagnostic services
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repository layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
pi-car-companion/
|
||||||
|
├── server/ # Fastify API, auth, jobs, persistence, collectors
|
||||||
|
├── web/ # React touch dashboard and PWA assets
|
||||||
|
├── android-companion/ # Kotlin Android WebView application
|
||||||
|
├── config/ # Safe defaults and example configuration
|
||||||
|
├── docs/ # Operations, security, networking, validation
|
||||||
|
├── scripts/ # Pi bootstrap/install/update/uninstall utilities
|
||||||
|
├── systemd/ # Service and optional watchdog units
|
||||||
|
├── tests/ # Cross-package/integration tests and fixtures
|
||||||
|
├── .env.example
|
||||||
|
├── docker-compose.dev.yml # Development dependencies only, if needed
|
||||||
|
├── package.json # Workspace commands
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Technology baseline
|
||||||
|
|
||||||
|
- Raspberry Pi OS Lite / Debian arm64 on Raspberry Pi 5
|
||||||
|
- Node.js 22 and strict TypeScript
|
||||||
|
- Fastify, Zod, SQLite, and Argon2id
|
||||||
|
- React, Vite, and a PWA-capable frontend
|
||||||
|
- Server-Sent Events by default for one-way live status; use WebSockets only if bidirectional live messaging becomes necessary
|
||||||
|
- Native Kotlin Android app, minimum SDK 28, targeting a current stable Android SDK
|
||||||
|
- systemd for production process supervision
|
||||||
|
|
||||||
|
Use a JavaScript workspace setup that keeps server and web commands consistent. Do not introduce containers into the production Pi path unless a measured need emerges.
|
||||||
|
|
||||||
|
## 5. System design
|
||||||
|
|
||||||
|
### Authentication and first run
|
||||||
|
|
||||||
|
- There is no default account.
|
||||||
|
- Setup is available only while the user table is empty; the first successfully created user becomes administrator.
|
||||||
|
- Setup and account creation must be transaction-safe so concurrent requests cannot create multiple initial administrators.
|
||||||
|
- Passwords are hashed with Argon2id using parameters documented and tested on Pi hardware.
|
||||||
|
- Sessions use opaque, revocable identifiers in HttpOnly cookies.
|
||||||
|
- Cookies use `SameSite=Strict` where compatible, a narrow path, and `Secure` whenever HTTPS is enabled.
|
||||||
|
- Every state-changing browser request requires CSRF protection and origin validation.
|
||||||
|
- Login and setup endpoints are rate-limited without leaking account existence.
|
||||||
|
|
||||||
|
### Server and API
|
||||||
|
|
||||||
|
- Bind to loopback by default. LAN binding is an explicit configuration change documented during installation.
|
||||||
|
- Provide `GET /healthz` for process health and a separate authenticated system-status endpoint.
|
||||||
|
- Validate request params, query strings, bodies, and relevant configuration with Zod.
|
||||||
|
- Use structured logs with a central redaction policy.
|
||||||
|
- Return stable machine-readable error codes alongside safe user-facing messages.
|
||||||
|
- Keep collection failures isolated: one unavailable sensor must not fail the entire status response.
|
||||||
|
|
||||||
|
### System status
|
||||||
|
|
||||||
|
Collect actual values for:
|
||||||
|
|
||||||
|
- hostname, OS/version, uptime, and current timestamp
|
||||||
|
- CPU model, load average, and temperature when exposed by the OS
|
||||||
|
- RAM and disk usage
|
||||||
|
- network interfaces and addresses
|
||||||
|
- Wi-Fi connection and signal when supported
|
||||||
|
- companion service health
|
||||||
|
- time of the last successful collection
|
||||||
|
|
||||||
|
Each field that can fail should carry an availability state and safe reason. Never substitute generated values.
|
||||||
|
|
||||||
|
### Controlled jobs
|
||||||
|
|
||||||
|
Jobs are registered in code through a typed manifest. The client can discover and invoke only registered jobs; it never supplies an executable or command line.
|
||||||
|
|
||||||
|
Every job definition includes:
|
||||||
|
|
||||||
|
- stable ID, title, description, and risk level
|
||||||
|
- typed input schema and allowed values
|
||||||
|
- explicit timeout and concurrency policy
|
||||||
|
- confirmation policy
|
||||||
|
- server-side handler
|
||||||
|
- log sanitization rules
|
||||||
|
|
||||||
|
Every run records:
|
||||||
|
|
||||||
|
- unique run ID and job ID
|
||||||
|
- authenticated actor
|
||||||
|
- validated, safely serializable inputs
|
||||||
|
- queued, started, and completed timestamps
|
||||||
|
- progress and terminal state
|
||||||
|
- duration, result, and stable error code
|
||||||
|
- bounded, sanitized stdout/stderr or handler output
|
||||||
|
|
||||||
|
Jobs must handle timeout, cancellation caused by service shutdown, duplicate submission, and stale in-progress records after restart. Destructive jobs require a short-lived, action-bound confirmation rather than a reusable client-side checkbox.
|
||||||
|
|
||||||
|
### Artifacts
|
||||||
|
|
||||||
|
Artifacts live under one configured data directory; API callers never provide filesystem paths.
|
||||||
|
|
||||||
|
- Generate storage names server-side and retain a sanitized display name separately.
|
||||||
|
- Reject traversal, separators, reserved names, oversize files, and executable content by default.
|
||||||
|
- Enforce limits while streaming, not only from request headers.
|
||||||
|
- Record size, detected type, SHA-256, uploader, and upload time.
|
||||||
|
- Require authentication for list/download and confirmation plus audit logging for deletion.
|
||||||
|
- Define retention and quota behavior before enabling uploads in production.
|
||||||
|
|
||||||
|
### Audit and diagnostics
|
||||||
|
|
||||||
|
- Audit authentication, settings changes, job lifecycle events, artifact changes, and privileged actions.
|
||||||
|
- Audit records are append-oriented and distinct from verbose application logs.
|
||||||
|
- Redact passwords, cookies, CSRF values, tokens, authorization headers, environment secrets, and sensitive query parameters at ingestion and export.
|
||||||
|
- Support bundles contain an explicit manifest and only allowlisted diagnostic files/data.
|
||||||
|
- The UI provides filters, job output viewing, and a plaintext “copy diagnostics” view.
|
||||||
|
- Retention is configurable and pruning itself is audited.
|
||||||
|
|
||||||
|
### Android companion
|
||||||
|
|
||||||
|
- First launch asks for a dashboard URL, defaulting to `http://mg4pi.local:8787`; the saved value remains editable.
|
||||||
|
- Only the configured origin is trusted. External navigation is blocked or opened only after explicit confirmation.
|
||||||
|
- Cleartext traffic is permitted only through a narrow network-security configuration suitable for the configured local origin; production HTTPS remains supported.
|
||||||
|
- JavaScript, cookies, and DOM storage are enabled only as required for the trusted dashboard.
|
||||||
|
- Use `WebViewClient` and `WebChromeClient` with native loading, offline, certificate, and connection-error states.
|
||||||
|
- Provide a persistent native reconnect/refresh action.
|
||||||
|
- Preserve Android soft-keyboard behavior and support file chooser/download flows without broad storage permissions.
|
||||||
|
- Optimize for landscape and offer immersive mode without making navigation recovery difficult.
|
||||||
|
|
||||||
|
### Web experience
|
||||||
|
|
||||||
|
The web app must remain usable for desktop/mobile setup, but the primary runtime layout targets 1920×720 landscape.
|
||||||
|
|
||||||
|
- Minimum 48dp touch targets; prefer 56–72dp for primary actions.
|
||||||
|
- Dark, high-contrast UI with large readable type and restrained use of cards.
|
||||||
|
- Persistent connection state and obvious reconnect action.
|
||||||
|
- Keyboard-accessible controls and visible focus states.
|
||||||
|
- No hover-only behavior.
|
||||||
|
- Dialogs, logs, menus, and settings panes must fit and scroll at 720px height.
|
||||||
|
- Dangerous actions are visually distinct without relying on color alone.
|
||||||
|
|
||||||
|
Primary areas:
|
||||||
|
|
||||||
|
1. **Home:** connection, health, time, network, warnings, recent runs, and safe quick actions.
|
||||||
|
2. **Jobs:** server-discovered actions, validated inputs, confirmations, progress, history, and logs.
|
||||||
|
3. **Artifacts:** upload, usage, metadata, download, and confirmed deletion.
|
||||||
|
4. **Audit:** filterable event history, job output, and support-bundle export.
|
||||||
|
5. **Settings:** access mode, mDNS, optional Tailscale state, retention, sessions, and danger zone.
|
||||||
|
|
||||||
|
## 6. Delivery roadmap
|
||||||
|
|
||||||
|
The repository currently contains planning documentation only. Status should be updated as work lands: `[ ]` pending, `[~]` in progress, `[x]` complete, `[!]` blocked.
|
||||||
|
|
||||||
|
### Milestone 0 — Decisions and scaffold
|
||||||
|
|
||||||
|
- [ ] Record architecture decisions for workspace tooling, SQLite library/migrations, sessions, CSRF, and live events.
|
||||||
|
- [x] Create the monorepo layout and shared TypeScript/tooling configuration.
|
||||||
|
- [x] Add repeatable `dev`, `lint`, `typecheck`, `test`, and `build` workspace commands.
|
||||||
|
- [x] Establish environment parsing, structured logging/redaction, migrations, and test database helpers.
|
||||||
|
- [ ] Add CI for all host-runnable quality gates.
|
||||||
|
|
||||||
|
**Exit:** a clean checkout installs reproducibly and all empty/scaffolded packages build and test through root commands.
|
||||||
|
|
||||||
|
### Milestone 1 — Secure authenticated vertical slice
|
||||||
|
|
||||||
|
- [~] Implement SQLite migrations for users, sessions, settings, audit events, and job runs. User, session, and audit tables are complete; settings and job runs follow with Milestone 2.
|
||||||
|
- [x] Implement atomic first-admin setup, login, logout, session expiry/revocation, CSRF, and throttling.
|
||||||
|
- [x] Build setup/login screens and authenticated application shell.
|
||||||
|
- [x] Implement `GET /healthz` and authenticated system status with per-field availability.
|
||||||
|
- [x] Show real connection and health state in the dashboard with loading, error, empty, and reconnect behavior.
|
||||||
|
- [x] Stream status updates with reconnect/backoff semantics.
|
||||||
|
|
||||||
|
**Exit:** a new local installation can create one admin, authenticate securely, and view truthful host status end to end.
|
||||||
|
|
||||||
|
### Milestone 2 — Jobs, audit, and diagnostics
|
||||||
|
|
||||||
|
- [ ] Implement the typed job registry, runner, timeouts, progress events, persistence, and restart recovery.
|
||||||
|
- [ ] Add refresh-health, network-diagnostics, and sanitized-support-bundle jobs.
|
||||||
|
- [ ] Implement action-bound confirmation infrastructure for higher-risk future jobs.
|
||||||
|
- [ ] Build the jobs/history UI and audit timeline.
|
||||||
|
- [ ] Add bounded output, central redaction, support-bundle manifest, and retention behavior.
|
||||||
|
|
||||||
|
**Exit:** the three initial jobs run only through the allowlist, expose live progress, persist safe results, and create attributable audit events.
|
||||||
|
|
||||||
|
### Milestone 3 — Android client
|
||||||
|
|
||||||
|
- [ ] Create the Kotlin project with minimum SDK 28 and landscape support.
|
||||||
|
- [ ] Implement URL setup/storage, trusted-origin navigation, and narrow cleartext policy.
|
||||||
|
- [ ] Implement WebView session behavior, soft keyboard, refresh, loading, and offline/error screens.
|
||||||
|
- [ ] Implement safe file chooser/download behavior where supported.
|
||||||
|
- [ ] Produce a debug APK in CI or a documented compatible Android build environment.
|
||||||
|
|
||||||
|
**Exit:** the app builds and can load/authenticate against the local dashboard in an emulator or test device; actual MG4 behavior remains explicitly unverified until hardware testing.
|
||||||
|
|
||||||
|
### Milestone 4 — Pi deployment
|
||||||
|
|
||||||
|
- [~] Add idempotent bootstrap, install-service, healthcheck, update, and uninstall scripts. Installation, health verification, and atomic updates are complete; uninstall remains pending.
|
||||||
|
- [x] Create a dedicated non-root user, `/var/lib/pi-car-companion` data directory, and `/etc/pi-car-companion` configuration directory.
|
||||||
|
- [~] Add hardened systemd unit(s), restart policy, graceful shutdown, and optional watchdog. Service hardening and restart behavior are complete; an optional watchdog remains pending.
|
||||||
|
- [~] Document LAN opt-in, firewall expectations, `mg4pi.local` through Avahi/mDNS, backups, updates, rollback, and recovery. LAN access, mDNS service discovery, updates, and release safety are documented; backup and recovery procedures remain pending.
|
||||||
|
- [ ] Test a clean install and upgrade on Debian arm64 or Raspberry Pi OS.
|
||||||
|
|
||||||
|
**Exit:** a fresh supported Pi image can be installed, rebooted, updated, and removed using documented commands without leaving an insecure default service.
|
||||||
|
|
||||||
|
### Milestone 5 — Artifacts and operational actions
|
||||||
|
|
||||||
|
- [ ] Implement controlled uploads/downloads/deletion, hashing, quotas, and metadata UI.
|
||||||
|
- [ ] Add Wi-Fi refresh/reconnect with capability detection.
|
||||||
|
- [ ] Add log pruning and companion-service restart jobs.
|
||||||
|
- [ ] Add reboot and shutdown with explicit action-bound confirmation and narrowly scoped privilege configuration.
|
||||||
|
- [ ] Exercise storage exhaustion, job timeout, service restart, and network-loss recovery.
|
||||||
|
|
||||||
|
**Exit:** operational features remain path-safe and allowlisted, destructive actions are hard to trigger accidentally, and failure modes are documented and tested.
|
||||||
|
|
||||||
|
### Milestone 6 — Vehicle validation and release
|
||||||
|
|
||||||
|
- [ ] Validate Pi boot, power-loss behavior, thermals, storage durability, Wi-Fi, and mDNS in the intended installation.
|
||||||
|
- [ ] Validate the Android app on the MG4: resolution, touch sizing, keyboard, scrolling, downloads, reconnect, and immersive-mode recovery.
|
||||||
|
- [ ] Run a parked-vehicle distraction/usability review; remove or gate flows unsuitable while driving.
|
||||||
|
- [ ] Complete security review, dependency audit, backup/restore drill, and release checklist.
|
||||||
|
- [ ] Clearly record tested hardware/software versions and all remaining limitations.
|
||||||
|
|
||||||
|
**Exit:** all release gates pass on the actual target hardware, documentation matches observed behavior, and no hardware-dependent claim exceeds the evidence.
|
||||||
|
|
||||||
|
## 7. Verification strategy
|
||||||
|
|
||||||
|
### Automated tests
|
||||||
|
|
||||||
|
- Authentication: first-admin race, password verification, session expiry/revocation, setup lockout, rate limiting
|
||||||
|
- Request security: CSRF rejection, origin checks, schema validation, authorization boundaries
|
||||||
|
- Jobs: unknown-job rejection, input validation, confirmation enforcement, timeout, redaction, persistence, restart recovery
|
||||||
|
- Artifacts: traversal and filename attacks, streaming size limits, executable rejection, SHA-256, authorization, deletion audit
|
||||||
|
- Status: parser/collector fixtures, unavailable fields, partial collection failures
|
||||||
|
- UI: setup/login, loading/error/reconnect, live updates, empty states, confirmation flows, and scroll behavior at target viewport sizes
|
||||||
|
- Android: URL validation, origin policy, navigation decisions, and lifecycle state where practical
|
||||||
|
- Deployment: shell linting plus idempotency checks in a disposable supported environment
|
||||||
|
|
||||||
|
### Required quality gates
|
||||||
|
|
||||||
|
Before merging a milestone:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lint
|
||||||
|
typecheck
|
||||||
|
unit and integration tests
|
||||||
|
web production build
|
||||||
|
server production build
|
||||||
|
Android build (from Milestone 3 onward)
|
||||||
|
```
|
||||||
|
|
||||||
|
Security-sensitive changes also require focused negative tests. Commands and their actual results belong in release notes or CI; documentation must not claim a command passed unless it was run.
|
||||||
|
|
||||||
|
### Manual target checks
|
||||||
|
|
||||||
|
- 1920×720 layout at normal and enlarged font scale
|
||||||
|
- touch targets, focus order, soft keyboard, and scroll containment
|
||||||
|
- Pi temperature/status availability across supported kernels
|
||||||
|
- Wi-Fi disconnected/reconnected states
|
||||||
|
- service crash/restart and stale job recovery
|
||||||
|
- browser/app behavior during Pi reboot and network loss
|
||||||
|
- support-bundle inspection for secret leakage
|
||||||
|
- abrupt power loss and subsequent database integrity
|
||||||
|
|
||||||
|
## 8. Security invariants
|
||||||
|
|
||||||
|
These are release blockers, not optional enhancements:
|
||||||
|
|
||||||
|
- No default credentials or post-setup first-run endpoint
|
||||||
|
- No arbitrary command or arbitrary filesystem path accepted from a client
|
||||||
|
- No privileged action outside the server allowlist
|
||||||
|
- No destructive action without strong, action-specific confirmation and audit
|
||||||
|
- No secret-bearing data in logs, audit payloads, live events, or support bundles
|
||||||
|
- No LAN/public binding silently enabled by application defaults
|
||||||
|
- No broad root service; privileged host operations use narrowly scoped mechanisms
|
||||||
|
- No unauthenticated artifact or diagnostic access
|
||||||
|
- No unsupported claim of HTTPS security for a plain local HTTP deployment
|
||||||
|
- No vehicle-control or safety-system integration in this project scope
|
||||||
|
|
||||||
|
## 9. Configuration and operations
|
||||||
|
|
||||||
|
Configuration should be explicit, validated at startup, and documented in `.env.example`. At minimum it covers:
|
||||||
|
|
||||||
|
- listen host and port
|
||||||
|
- canonical dashboard origin
|
||||||
|
- database, artifact, and support-bundle locations
|
||||||
|
- session lifetime and secure-cookie mode
|
||||||
|
- upload limits and allowed content policy
|
||||||
|
- audit, job-output, and artifact retention
|
||||||
|
- log level and redaction-safe diagnostics mode
|
||||||
|
- optional mDNS and trusted-proxy behavior
|
||||||
|
|
||||||
|
Production secrets are generated during installation and stored outside the repository with restrictive permissions. Database and artifact backups must be consistent, restorable, and versioned with the application schema.
|
||||||
|
|
||||||
|
## 10. Definition of done for Version 1
|
||||||
|
|
||||||
|
Version 1 is complete only when:
|
||||||
|
|
||||||
|
- a clean Raspberry Pi installation can be completed from the documentation;
|
||||||
|
- the service runs unprivileged under systemd and survives reboot/restart;
|
||||||
|
- the first admin can be created exactly once and can log in/out securely;
|
||||||
|
- real status is visible without fabricated fallback data;
|
||||||
|
- the three initial jobs are allowlisted, validated, bounded, streamed, persisted, and audited;
|
||||||
|
- a sanitized support bundle can be generated and inspected;
|
||||||
|
- the web UI is usable at 1920×720 and handles connection failure honestly;
|
||||||
|
- the Android app builds and loads only its configured trusted origin;
|
||||||
|
- lint, typecheck, automated tests, server/web builds, and Android build pass;
|
||||||
|
- security and operations documentation is current;
|
||||||
|
- Pi/MG4-specific validation results are separated from emulator or desktop results.
|
||||||
|
|
||||||
|
## 11. Open decisions
|
||||||
|
|
||||||
|
Resolve these through short architecture decision records before their milestone begins:
|
||||||
|
|
||||||
|
1. Package manager/workspace tooling and lockfile policy
|
||||||
|
2. SQLite driver, migration mechanism, and backup approach
|
||||||
|
3. Session store and CSRF implementation
|
||||||
|
4. Server-Sent Events reconnect/replay contract
|
||||||
|
5. Safe privilege boundary for service restart, reboot, shutdown, and Wi-Fi operations
|
||||||
|
6. Android handling for local HTTP versus an optional locally trusted HTTPS setup
|
||||||
|
7. Pi power supply, graceful shutdown strategy, and storage medium for in-vehicle use
|
||||||
|
8. Whether the infotainment app can be installed and persist sessions under the MG4's Android restrictions
|
||||||
|
|
||||||
|
Until measured on target hardware, these remain decisions or risks—not assumptions disguised as completed functionality.
|
||||||
@@ -1,2 +1,154 @@
|
|||||||
# pi-car-companion
|
# Pi Car Companion
|
||||||
|
|
||||||
|
A local-first Raspberry Pi companion for the MG4. The Pi hosts a secure dashboard while the existing Android infotainment system remains the display and touch interface.
|
||||||
|
|
||||||
|
The current MVP slice includes:
|
||||||
|
|
||||||
|
- atomic first-run administrator creation with no default credentials;
|
||||||
|
- Argon2id password hashing and revocable, hashed server-side sessions;
|
||||||
|
- CSRF, trusted-origin, rate-limit, and secure-header protections;
|
||||||
|
- SQLite persistence and append-oriented authentication audit events;
|
||||||
|
- real host, CPU, memory, disk, network, uptime, and service status;
|
||||||
|
- field-level unavailable states when host data cannot be collected;
|
||||||
|
- authenticated Server-Sent Events for live status updates;
|
||||||
|
- a responsive 1920×720-oriented setup, login, and dashboard UI;
|
||||||
|
- an idempotent Raspberry Pi installer with systemd start-on-boot support;
|
||||||
|
- atomic, dashboard-triggered Git updates with automatic verification and rollback safety;
|
||||||
|
- automated API security tests and reproducible quality commands.
|
||||||
|
|
||||||
|
Jobs, artifacts, the Android companion, and remaining operations work are tracked in [PLAN.md](./PLAN.md).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js 22
|
||||||
|
- npm 10 or newer
|
||||||
|
- Linux, macOS, or Windows for development
|
||||||
|
|
||||||
|
Raspberry Pi OS Lite / Debian arm64 is the production target but has not yet been hardware-validated.
|
||||||
|
|
||||||
|
## Raspberry Pi installation
|
||||||
|
|
||||||
|
On a clean Raspberry Pi OS or Debian installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.molberg.cloud/alex/pi-car-companion.git
|
||||||
|
cd pi-car-companion
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script asks for sudo access and then:
|
||||||
|
|
||||||
|
- installs Node.js 22, Git, Avahi, SQLite, 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`;
|
||||||
|
- stores application data under `/var/lib/pi-car-companion`;
|
||||||
|
- stores root-managed configuration under `/etc/pi-car-companion`;
|
||||||
|
- enables the companion and Avahi services at boot;
|
||||||
|
- starts the service and verifies `/healthz`.
|
||||||
|
|
||||||
|
The installer prints the final dashboard address, normally `http://<pi-hostname>.local:8787`. Open it and create the first administrator account.
|
||||||
|
|
||||||
|
`install.sh` is idempotent. After pulling changes in the original clone, run it again to deploy that checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull --ff-only
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard updates
|
||||||
|
|
||||||
|
Open **Settings** and select **Update now**. The Pi will:
|
||||||
|
|
||||||
|
1. fetch the configured branch from `origin`;
|
||||||
|
2. refuse non-fast-forward history changes;
|
||||||
|
3. create an isolated release worktree;
|
||||||
|
4. install dependencies and run script checks, lint, type checks, tests, and builds;
|
||||||
|
5. atomically activate the release only after every check passes;
|
||||||
|
6. restart the background service and verify its health endpoint;
|
||||||
|
7. automatically restore the previous release if startup verification fails.
|
||||||
|
|
||||||
|
If any step fails, the current release stays active and Settings shows the failure. Updates use the Git credentials of the local user who ran `install.sh`; credentials are not copied into the web service or repository configuration. Non-interactive credentials such as an SSH deploy key or cached HTTPS credential work best.
|
||||||
|
|
||||||
|
Useful service commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status pi-car-companion
|
||||||
|
sudo journalctl -u pi-car-companion -n 100 --no-pager
|
||||||
|
sudo journalctl -u pi-car-companion-update -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the API in one terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the Vite dashboard in another:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:web
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://127.0.0.1:5173`. The development server proxies API requests to `http://127.0.0.1:8787`.
|
||||||
|
|
||||||
|
Local development data is stored in `server/data/companion.db` and ignored by Git.
|
||||||
|
|
||||||
|
## Quality gate
|
||||||
|
|
||||||
|
Run every host-runnable check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs ESLint, strict TypeScript checks, tests, and both production builds.
|
||||||
|
|
||||||
|
Audit production dependencies separately from development tooling:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm audit --omit=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production-mode preview
|
||||||
|
|
||||||
|
Build and start the combined server/dashboard:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
NODE_ENV=production \
|
||||||
|
DATABASE_PATH=./server/data/companion.db \
|
||||||
|
ALLOWED_ORIGINS=http://127.0.0.1:8787 \
|
||||||
|
npm start --workspace @pi-car/server
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://127.0.0.1:8787`.
|
||||||
|
|
||||||
|
The development default remains loopback-only. `install.sh` explicitly enables LAN access so the car can reach the dashboard.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
See [.env.example](./.env.example). Configuration is validated at startup.
|
||||||
|
|
||||||
|
Important values:
|
||||||
|
|
||||||
|
- `HOST`: listen address, defaults to `127.0.0.1`
|
||||||
|
- `PORT`: HTTP port, defaults to `8787`
|
||||||
|
- `DATABASE_PATH`: SQLite database location
|
||||||
|
- `SESSION_TTL_HOURS`: session lifetime, defaults to 24 hours
|
||||||
|
- `COOKIE_SECURE`: enable for HTTPS deployments
|
||||||
|
- `ALLOWED_ORIGINS`: comma-separated exact browser origins accepted for state changes
|
||||||
|
- `UPDATE_STATUS_PATH`: update state shared with the systemd update unit
|
||||||
|
- `VERSION_FILE`: revision metadata for the active atomic release
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
|
||||||
|
<service-group>
|
||||||
|
<name replace-wildcards="yes">Pi Car Companion on %h</name>
|
||||||
|
<service>
|
||||||
|
<type>_http._tcp</type>
|
||||||
|
<port>8787</port>
|
||||||
|
</service>
|
||||||
|
</service-group>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import eslint from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["**/dist/**", "**/coverage/**", "**/node_modules/**"] },
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/consistent-type-imports": "error",
|
||||||
|
"@typescript-eslint/no-explicit-any": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
Executable
+167
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
APP_NAME="pi-car-companion"
|
||||||
|
SERVICE_USER="pi-companion"
|
||||||
|
INSTALL_ROOT="/opt/${APP_NAME}"
|
||||||
|
REPOSITORY="${INSTALL_ROOT}/repository"
|
||||||
|
RELEASES="${INSTALL_ROOT}/releases"
|
||||||
|
CURRENT="${INSTALL_ROOT}/current"
|
||||||
|
CONFIG_DIR="/etc/${APP_NAME}"
|
||||||
|
DATA_DIR="/var/lib/${APP_NAME}"
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
|
||||||
|
if [[ ${EUID} -ne 0 ]]; then
|
||||||
|
if ! command -v sudo >/dev/null 2>&1; then
|
||||||
|
echo "This installer needs root access and sudo is not installed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec sudo -- bash "${SCRIPT_DIR}/install.sh" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "${SCRIPT_DIR}/.git" ]]; then
|
||||||
|
echo "Run install.sh from a Git clone of Pi Car Companion." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f /etc/os-release ]]; then
|
||||||
|
echo "This installer supports Raspberry Pi OS and Debian." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
source /etc/os-release
|
||||||
|
if [[ "${ID:-}" != "debian" && "${ID:-}" != "raspbian" && "${ID_LIKE:-}" != *debian* ]]; then
|
||||||
|
echo "Unsupported operating system: ${PRETTY_NAME:-unknown}." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[1/7] Installing operating-system packages"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y ca-certificates curl gnupg git rsync sudo util-linux avahi-daemon sqlite3 build-essential
|
||||||
|
|
||||||
|
node_major=""
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
node_major="$(node --version | sed -E 's/^v([0-9]+).*/\1/')"
|
||||||
|
fi
|
||||||
|
if [[ "${node_major}" != "22" ]]; then
|
||||||
|
echo "[2/7] Installing Node.js 22"
|
||||||
|
install -d -m 0755 /etc/apt/keyrings
|
||||||
|
key_tmp="$(mktemp)"
|
||||||
|
curl --fail --silent --show-error --location https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key --output "${key_tmp}"
|
||||||
|
gpg --dearmor --yes --output /etc/apt/keyrings/nodesource.gpg "${key_tmp}"
|
||||||
|
rm -f -- "${key_tmp}"
|
||||||
|
chmod 0644 /etc/apt/keyrings/nodesource.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" > /etc/apt/sources.list.d/nodesource.list
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y nodejs
|
||||||
|
else
|
||||||
|
echo "[2/7] Node.js 22 is already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[3/7] Creating the service account and directories"
|
||||||
|
if ! id "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||||
|
useradd --system --home-dir "${DATA_DIR}" --shell /usr/sbin/nologin --user-group "${SERVICE_USER}"
|
||||||
|
fi
|
||||||
|
install -d -m 0755 -o root -g root "${INSTALL_ROOT}" "${REPOSITORY}" "${RELEASES}"
|
||||||
|
install -d -m 0750 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${DATA_DIR}"
|
||||||
|
install -d -m 0750 -o root -g "${SERVICE_USER}" "${CONFIG_DIR}"
|
||||||
|
|
||||||
|
echo "[4/7] Copying and building the application"
|
||||||
|
if [[ "${SCRIPT_DIR}" != "${REPOSITORY}" ]]; then
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude node_modules \
|
||||||
|
--exclude dist \
|
||||||
|
--exclude .env \
|
||||||
|
--exclude server/data \
|
||||||
|
"${SCRIPT_DIR}/" "${REPOSITORY}/"
|
||||||
|
fi
|
||||||
|
chown -R root:root "${REPOSITORY}"
|
||||||
|
git config --system --add safe.directory "${REPOSITORY}" 2>/dev/null || true
|
||||||
|
branch="$(git -C "${REPOSITORY}" branch --show-current)"
|
||||||
|
if [[ -z "${branch}" ]]; then
|
||||||
|
echo "The installation clone must be on a named Git branch." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "${branch}" > "${CONFIG_DIR}/update-branch"
|
||||||
|
chmod 0644 "${CONFIG_DIR}/update-branch"
|
||||||
|
update_user="${SUDO_USER:-$(stat -c '%U' "${SCRIPT_DIR}")}"
|
||||||
|
if ! id "${update_user}" >/dev/null 2>&1; then
|
||||||
|
echo "Could not determine the local user that owns Git update credentials." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "${update_user}" > "${CONFIG_DIR}/update-user"
|
||||||
|
chmod 0600 "${CONFIG_DIR}/update-user"
|
||||||
|
chown -R "${update_user}":"$(id -gn "${update_user}")" "${REPOSITORY}/.git"
|
||||||
|
|
||||||
|
cd "${REPOSITORY}"
|
||||||
|
npm ci
|
||||||
|
npm run check
|
||||||
|
revision="$(git rev-parse HEAD)"
|
||||||
|
release="${RELEASES}/${revision}"
|
||||||
|
install -d -m 0755 -o root -g root "${release}"
|
||||||
|
rsync -a --delete --exclude .git "${REPOSITORY}/" "${release}/"
|
||||||
|
printf '%s\n' "${revision}" > "${release}/REVISION"
|
||||||
|
chown -R root:root "${release}"
|
||||||
|
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 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 0440 "${SCRIPT_DIR}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion
|
||||||
|
visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null
|
||||||
|
install -d -m 0755 /etc/avahi/services
|
||||||
|
install -m 0644 "${SCRIPT_DIR}/config/pi-car-companion.service.xml" /etc/avahi/services/pi-car-companion.service
|
||||||
|
|
||||||
|
if [[ ! -f "${CONFIG_DIR}/companion.env" ]]; then
|
||||||
|
cat > "${CONFIG_DIR}/companion.env" <<EOF
|
||||||
|
NODE_ENV=production
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8787
|
||||||
|
DATABASE_PATH=${DATA_DIR}/companion.db
|
||||||
|
SESSION_TTL_HOURS=24
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
ALLOWED_ORIGINS=http://mg4pi.local:8787
|
||||||
|
UPDATE_STATUS_PATH=${DATA_DIR}/update-status.json
|
||||||
|
VERSION_FILE=${CURRENT}/REVISION
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
chown root:"${SERVICE_USER}" "${CONFIG_DIR}/companion.env"
|
||||||
|
chmod 0640 "${CONFIG_DIR}/companion.env"
|
||||||
|
|
||||||
|
if [[ ! -f "${DATA_DIR}/update-status.json" ]]; then
|
||||||
|
printf '{"state":"idle","message":"Ready to check for updates.","fromRevision":null,"toRevision":null,"updatedAt":"%s"}\n' "$(date --iso-8601=seconds)" > "${DATA_DIR}/update-status.json"
|
||||||
|
fi
|
||||||
|
chown root:"${SERVICE_USER}" "${DATA_DIR}/update-status.json"
|
||||||
|
chmod 0644 "${DATA_DIR}/update-status.json"
|
||||||
|
|
||||||
|
echo "[6/7] Enabling start-on-boot services"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now avahi-daemon
|
||||||
|
systemctl enable pi-car-companion.service
|
||||||
|
systemctl restart pi-car-companion.service
|
||||||
|
|
||||||
|
echo "[7/7] Verifying the service"
|
||||||
|
for _attempt in {1..20}; do
|
||||||
|
if curl --fail --silent --show-error http://127.0.0.1:8787/healthz >/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if ! curl --fail --silent --show-error http://127.0.0.1:8787/healthz >/dev/null; then
|
||||||
|
systemctl status --no-pager pi-car-companion.service || true
|
||||||
|
echo "Installation finished, but the health check failed. Review the service status above." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
host_name="$(hostname)"
|
||||||
|
ip_addresses="$(hostname -I 2>/dev/null | xargs || true)"
|
||||||
|
echo
|
||||||
|
echo "Pi Car Companion is installed and running."
|
||||||
|
echo "Dashboard: http://${host_name}.local:8787"
|
||||||
|
if [[ -n "${ip_addresses}" ]]; then
|
||||||
|
echo "Pi addresses: ${ip_addresses} (port 8787)"
|
||||||
|
fi
|
||||||
|
echo "Future updates can be installed from Settings, or by running git pull and install.sh again."
|
||||||
Generated
+5045
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-car-companion",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22 <23"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"web"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run dev --workspace @pi-car/server",
|
||||||
|
"dev:web": "npm run dev --workspace @pi-car/web",
|
||||||
|
"lint": "npm run lint --workspaces --if-present",
|
||||||
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
|
"test": "npm run test --workspaces --if-present",
|
||||||
|
"build": "npm run build --workspaces --if-present",
|
||||||
|
"check:scripts": "bash -n install.sh scripts/pi-car-companion-update",
|
||||||
|
"check": "npm run check:scripts && npm run lint && npm run typecheck && npm test && npm run build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.32.0",
|
||||||
|
"eslint": "^9.32.0",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"typescript-eslint": "^8.38.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+177
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
APP_NAME="pi-car-companion"
|
||||||
|
INSTALL_ROOT="/opt/${APP_NAME}"
|
||||||
|
REPOSITORY="${INSTALL_ROOT}/repository"
|
||||||
|
RELEASES="${INSTALL_ROOT}/releases"
|
||||||
|
CURRENT="${INSTALL_ROOT}/current"
|
||||||
|
STAGING_ROOT="${INSTALL_ROOT}/staging"
|
||||||
|
CONFIG_DIR="/etc/${APP_NAME}"
|
||||||
|
DATA_DIR="/var/lib/${APP_NAME}"
|
||||||
|
STATUS_FILE="${DATA_DIR}/update-status.json"
|
||||||
|
BRANCH_FILE="${CONFIG_DIR}/update-branch"
|
||||||
|
UPDATE_USER_FILE="${CONFIG_DIR}/update-user"
|
||||||
|
LOCK_FILE="/run/lock/${APP_NAME}-update.lock"
|
||||||
|
WORKTREE=""
|
||||||
|
RELEASE_TMP=""
|
||||||
|
FROM_REVISION=""
|
||||||
|
TO_REVISION=""
|
||||||
|
PHASE="checking for updates"
|
||||||
|
|
||||||
|
if [[ ${EUID} -ne 0 ]]; then
|
||||||
|
echo "The update helper must run as root." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_status() {
|
||||||
|
local state="$1"
|
||||||
|
local message="$2"
|
||||||
|
local update_group_id
|
||||||
|
update_group_id="$(getent group pi-companion | cut -d: -f3)"
|
||||||
|
UPDATE_STATE="${state}" \
|
||||||
|
UPDATE_MESSAGE="${message}" \
|
||||||
|
UPDATE_FROM="${FROM_REVISION}" \
|
||||||
|
UPDATE_TO="${TO_REVISION}" \
|
||||||
|
UPDATE_STATUS_FILE="${STATUS_FILE}" \
|
||||||
|
UPDATE_GROUP_ID="${update_group_id}" \
|
||||||
|
/usr/bin/node --input-type=module -e '
|
||||||
|
import { chmodSync, chownSync, renameSync, writeFileSync } from "node:fs";
|
||||||
|
const target = process.env.UPDATE_STATUS_FILE;
|
||||||
|
const temporary = `${target}.tmp`;
|
||||||
|
const value = {
|
||||||
|
state: process.env.UPDATE_STATE,
|
||||||
|
message: process.env.UPDATE_MESSAGE,
|
||||||
|
fromRevision: process.env.UPDATE_FROM || null,
|
||||||
|
toRevision: process.env.UPDATE_TO || null,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o644 });
|
||||||
|
chownSync(temporary, 0, Number(process.env.UPDATE_GROUP_ID));
|
||||||
|
chmodSync(temporary, 0o644);
|
||||||
|
renameSync(temporary, target);
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "${WORKTREE}" && "${WORKTREE}" == "${STAGING_ROOT}/"* && -e "${WORKTREE}" ]]; then
|
||||||
|
git -C "${REPOSITORY}" worktree remove --force "${WORKTREE}" >/dev/null 2>&1 || rm -rf -- "${WORKTREE}"
|
||||||
|
fi
|
||||||
|
if [[ -n "${RELEASE_TMP}" && "${RELEASE_TMP}" == "${RELEASES}/.new-"* && -e "${RELEASE_TMP}" ]]; then
|
||||||
|
rm -rf -- "${RELEASE_TMP}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
on_error() {
|
||||||
|
local exit_code=$?
|
||||||
|
trap - ERR
|
||||||
|
cleanup
|
||||||
|
write_status "failed" "Update failed while ${PHASE}. The previous version is still active."
|
||||||
|
exit "${exit_code}"
|
||||||
|
}
|
||||||
|
trap on_error ERR
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
exec 9>"${LOCK_FILE}"
|
||||||
|
if ! flock -n 9; then
|
||||||
|
write_status "failed" "Another update is already running."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "${REPOSITORY}/.git" || ! -f "${BRANCH_FILE}" || ! -f "${UPDATE_USER_FILE}" ]]; then
|
||||||
|
write_status "failed" "The deployment repository is not configured. Run install.sh again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
FROM_REVISION="$(cat "${CURRENT}/REVISION" 2>/dev/null || true)"
|
||||||
|
branch="$(tr -d '\r\n' < "${BRANCH_FILE}")"
|
||||||
|
update_user="$(tr -d '\r\n' < "${UPDATE_USER_FILE}")"
|
||||||
|
if ! id "${update_user}" >/dev/null 2>&1; then
|
||||||
|
write_status "failed" "The Git update user no longer exists. Run install.sh again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
write_status "checking" "Checking the configured Git remote for updates."
|
||||||
|
|
||||||
|
chown -R "${update_user}":"$(id -gn "${update_user}")" "${REPOSITORY}/.git"
|
||||||
|
if [[ "${update_user}" == "root" ]]; then
|
||||||
|
git -C "${REPOSITORY}" fetch --prune origin
|
||||||
|
else
|
||||||
|
sudo -H -u "${update_user}" -- git -C "${REPOSITORY}" fetch --prune origin
|
||||||
|
fi
|
||||||
|
TO_REVISION="$(git -C "${REPOSITORY}" rev-parse "origin/${branch}")"
|
||||||
|
|
||||||
|
if [[ -n "${FROM_REVISION}" && "${FROM_REVISION}" == "${TO_REVISION}" ]]; then
|
||||||
|
write_status "current" "Pi Car Companion is already up to date."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${FROM_REVISION}" ]] && ! git -C "${REPOSITORY}" merge-base --is-ancestor "${FROM_REVISION}" "${TO_REVISION}"; then
|
||||||
|
write_status "failed" "The remote update is not a fast-forward change. Automatic update was refused."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PHASE="building the new release"
|
||||||
|
write_status "building" "Downloading dependencies and verifying the new release."
|
||||||
|
install -d -m 0755 "${STAGING_ROOT}" "${RELEASES}"
|
||||||
|
WORKTREE="${STAGING_ROOT}/worktree-${TO_REVISION}-$$"
|
||||||
|
git -C "${REPOSITORY}" worktree add --detach "${WORKTREE}" "${TO_REVISION}"
|
||||||
|
|
||||||
|
cd "${WORKTREE}"
|
||||||
|
npm ci
|
||||||
|
npm run check
|
||||||
|
printf '%s\n' "${TO_REVISION}" > REVISION
|
||||||
|
|
||||||
|
PHASE="activating the new release"
|
||||||
|
RELEASE_TMP="${RELEASES}/.new-${TO_REVISION}-$$"
|
||||||
|
install -d -m 0755 "${RELEASE_TMP}"
|
||||||
|
rsync -a --delete --exclude .git "${WORKTREE}/" "${RELEASE_TMP}/"
|
||||||
|
release_final="${RELEASES}/${TO_REVISION}"
|
||||||
|
if [[ -e "${release_final}" ]]; then
|
||||||
|
rm -rf -- "${release_final}"
|
||||||
|
fi
|
||||||
|
mv "${RELEASE_TMP}" "${release_final}"
|
||||||
|
RELEASE_TMP=""
|
||||||
|
chown -R root:root "${release_final}"
|
||||||
|
|
||||||
|
install_integration_files() {
|
||||||
|
local source_release="$1"
|
||||||
|
install -m 0755 "${source_release}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update
|
||||||
|
install -m 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 0440 "${source_release}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion
|
||||||
|
}
|
||||||
|
|
||||||
|
install_integration_files "${release_final}"
|
||||||
|
visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
ln -sfn "releases/${TO_REVISION}" "${INSTALL_ROOT}/current.next"
|
||||||
|
mv -Tf "${INSTALL_ROOT}/current.next" "${CURRENT}"
|
||||||
|
PHASE="verifying the updated service"
|
||||||
|
write_status "building" "The update is installed. Verifying the restarted service."
|
||||||
|
healthy=false
|
||||||
|
if systemctl restart pi-car-companion.service; then
|
||||||
|
for _attempt in {1..30}; do
|
||||||
|
if curl --fail --silent --show-error http://127.0.0.1:8787/healthz >/dev/null; then
|
||||||
|
healthy=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${healthy}" != "true" ]]; then
|
||||||
|
if [[ -n "${FROM_REVISION}" && -d "${RELEASES}/${FROM_REVISION}" ]]; then
|
||||||
|
install_integration_files "${RELEASES}/${FROM_REVISION}"
|
||||||
|
systemctl daemon-reload
|
||||||
|
ln -sfn "releases/${FROM_REVISION}" "${INSTALL_ROOT}/current.next"
|
||||||
|
mv -Tf "${INSTALL_ROOT}/current.next" "${CURRENT}"
|
||||||
|
systemctl restart pi-car-companion.service || true
|
||||||
|
write_status "failed" "The new release failed its health check. The previous release was restored."
|
||||||
|
else
|
||||||
|
write_status "failed" "The new release failed its health check and no previous release was available."
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_status "success" "Update installed and the companion service is healthy."
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@pi-car/server",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"lint": "eslint src tests",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
|
"argon2": "^0.44.0",
|
||||||
|
"better-sqlite3": "^12.2.0",
|
||||||
|
"fastify": "^5.4.0",
|
||||||
|
"zod": "^4.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^22.17.0",
|
||||||
|
"tsx": "^4.20.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||||
|
import { extname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import argon2 from "argon2";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { AppConfig } from "./config.js";
|
||||||
|
import { openDatabase, type CompanionDatabase } from "./database.js";
|
||||||
|
import {
|
||||||
|
clientAddress,
|
||||||
|
CSRF_COOKIE,
|
||||||
|
hashToken,
|
||||||
|
randomToken,
|
||||||
|
safeEqual,
|
||||||
|
SESSION_COOKIE
|
||||||
|
} from "./security.js";
|
||||||
|
import { collectSystemStatus } from "./status.js";
|
||||||
|
import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
|
||||||
|
import "./types.js";
|
||||||
|
|
||||||
|
const credentialsSchema = z.object({
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(3, "Username must contain at least 3 characters")
|
||||||
|
.max(48, "Username must contain at most 48 characters")
|
||||||
|
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, "Use letters, numbers, dots, hyphens, or underscores"),
|
||||||
|
password: z.string().min(12, "Password must contain at least 12 characters").max(256)
|
||||||
|
});
|
||||||
|
|
||||||
|
type UserRow = { id: number; username: string; role: "admin"; password_hash: string };
|
||||||
|
type SessionUserRow = { id: number; username: string; role: "admin" };
|
||||||
|
|
||||||
|
function audit(
|
||||||
|
database: CompanionDatabase,
|
||||||
|
event: {
|
||||||
|
actorUserId?: number;
|
||||||
|
action: string;
|
||||||
|
result: "success" | "failure";
|
||||||
|
ipAddress?: string;
|
||||||
|
details?: Record<string, string | number | boolean>;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO audit_events
|
||||||
|
(actor_user_id, action, result, ip_address, details_json, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
event.actorUserId ?? null,
|
||||||
|
event.action,
|
||||||
|
event.result,
|
||||||
|
event.ipAddress ?? null,
|
||||||
|
JSON.stringify(event.details ?? {}),
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieOptions(config: AppConfig, httpOnly: boolean) {
|
||||||
|
return {
|
||||||
|
path: "/",
|
||||||
|
httpOnly,
|
||||||
|
sameSite: "strict" as const,
|
||||||
|
secure: config.cookieSecure
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireUser(request: FastifyRequest, reply: FastifyReply): boolean {
|
||||||
|
if (request.authUser) return true;
|
||||||
|
void reply.code(401).send({ error: { code: "AUTH_REQUIRED", message: "Sign in to continue" } });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
|
const app = Fastify({
|
||||||
|
logger:
|
||||||
|
config.nodeEnv === "test"
|
||||||
|
? false
|
||||||
|
: {
|
||||||
|
level: config.nodeEnv === "production" ? "info" : "debug",
|
||||||
|
redact: ["req.headers.cookie", "req.headers.authorization", "req.headers.x-csrf-token"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const database = openDatabase(config.databasePath);
|
||||||
|
|
||||||
|
app.decorate("database", database);
|
||||||
|
app.decorateRequest("authUser", null);
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(rateLimit, { global: false });
|
||||||
|
|
||||||
|
app.addHook("onClose", async () => database.close());
|
||||||
|
app.addHook("onSend", async (_request, reply) => {
|
||||||
|
reply.header("X-Content-Type-Options", "nosniff");
|
||||||
|
reply.header("Referrer-Policy", "no-referrer");
|
||||||
|
reply.header("X-Frame-Options", "DENY");
|
||||||
|
reply.header(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.addHook("onRequest", async (request) => {
|
||||||
|
request.authUser = null;
|
||||||
|
const token = request.cookies[SESSION_COOKIE];
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const user = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT users.id, users.username, users.role
|
||||||
|
FROM sessions JOIN users ON users.id = sessions.user_id
|
||||||
|
WHERE sessions.id_hash = ? AND sessions.expires_at > ?`
|
||||||
|
)
|
||||||
|
.get(hashToken(token), now) as SessionUserRow | undefined;
|
||||||
|
if (user) request.authUser = user;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.addHook("preValidation", async (request, reply) => {
|
||||||
|
if (!["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) return;
|
||||||
|
|
||||||
|
const origin = request.headers.origin;
|
||||||
|
let sameHostOrigin = false;
|
||||||
|
if (origin && request.headers.host) {
|
||||||
|
try {
|
||||||
|
const parsedOrigin = new URL(origin);
|
||||||
|
sameHostOrigin = ["http:", "https:"].includes(parsedOrigin.protocol) && parsedOrigin.host === request.headers.host;
|
||||||
|
} catch {
|
||||||
|
sameHostOrigin = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (origin && !sameHostOrigin && !config.allowedOrigins.has(origin)) {
|
||||||
|
return reply.code(403).send({ error: { code: "ORIGIN_REJECTED", message: "Request origin is not trusted" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieToken = request.cookies[CSRF_COOKIE];
|
||||||
|
const headerToken = request.headers["x-csrf-token"];
|
||||||
|
if (!cookieToken || typeof headerToken !== "string" || !safeEqual(cookieToken, headerToken)) {
|
||||||
|
return reply.code(403).send({ error: { code: "CSRF_REJECTED", message: "Refresh the page and try again" } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/healthz", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
||||||
|
|
||||||
|
app.get("/api/auth/state", async (request, reply) => {
|
||||||
|
let csrfToken = request.cookies[CSRF_COOKIE];
|
||||||
|
if (!csrfToken) {
|
||||||
|
csrfToken = randomToken();
|
||||||
|
reply.setCookie(CSRF_COOKIE, csrfToken, cookieOptions(config, false));
|
||||||
|
}
|
||||||
|
const row = database.prepare("SELECT COUNT(*) AS count FROM users").get() as { count: number };
|
||||||
|
return {
|
||||||
|
setupRequired: row.count === 0,
|
||||||
|
user: request.authUser,
|
||||||
|
csrfToken
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/auth/setup",
|
||||||
|
{ config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const parsed = credentialsSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: { code: "INVALID_CREDENTIALS", message: parsed.error.issues[0]?.message ?? "Invalid account details" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await argon2.hash(parsed.data.password, { type: argon2.argon2id });
|
||||||
|
const createAdmin = database.transaction(() => {
|
||||||
|
const row = database.prepare("SELECT COUNT(*) AS count FROM users").get() as { count: number };
|
||||||
|
if (row.count !== 0) return null;
|
||||||
|
const result = database
|
||||||
|
.prepare("INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, 'admin', ?)")
|
||||||
|
.run(parsed.data.username, passwordHash, new Date().toISOString());
|
||||||
|
return Number(result.lastInsertRowid);
|
||||||
|
});
|
||||||
|
const userId = createAdmin();
|
||||||
|
if (userId === null) {
|
||||||
|
audit(database, {
|
||||||
|
action: "auth.setup",
|
||||||
|
result: "failure",
|
||||||
|
ipAddress: clientAddress(request),
|
||||||
|
details: { reason: "setup_already_complete" }
|
||||||
|
});
|
||||||
|
return reply.code(409).send({ error: { code: "SETUP_COMPLETE", message: "Initial setup is already complete" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
audit(database, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: "auth.setup",
|
||||||
|
result: "success",
|
||||||
|
ipAddress: clientAddress(request)
|
||||||
|
});
|
||||||
|
return reply.code(201).send({ created: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
{ config: { rateLimit: { max: 8, timeWindow: "1 minute" } } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const parsed = credentialsSchema.safeParse(request.body);
|
||||||
|
const user = parsed.success
|
||||||
|
? (database
|
||||||
|
.prepare("SELECT id, username, role, password_hash FROM users WHERE username = ? COLLATE NOCASE")
|
||||||
|
.get(parsed.data.username) as UserRow | undefined)
|
||||||
|
: undefined;
|
||||||
|
const valid = user && parsed.success ? await argon2.verify(user.password_hash, parsed.data.password) : false;
|
||||||
|
|
||||||
|
if (!user || !valid) {
|
||||||
|
audit(database, {
|
||||||
|
action: "auth.login",
|
||||||
|
result: "failure",
|
||||||
|
ipAddress: clientAddress(request),
|
||||||
|
details: { reason: "invalid_credentials" }
|
||||||
|
});
|
||||||
|
return reply.code(401).send({ error: { code: "LOGIN_FAILED", message: "Username or password is incorrect" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
database.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(new Date().toISOString());
|
||||||
|
const token = randomToken();
|
||||||
|
const expiresAt = new Date(Date.now() + config.sessionTtlMs);
|
||||||
|
database
|
||||||
|
.prepare("INSERT INTO sessions (id_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
|
||||||
|
.run(hashToken(token), user.id, expiresAt.toISOString(), new Date().toISOString());
|
||||||
|
reply.setCookie(SESSION_COOKIE, token, {
|
||||||
|
...cookieOptions(config, true),
|
||||||
|
expires: expiresAt
|
||||||
|
});
|
||||||
|
audit(database, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: "auth.login",
|
||||||
|
result: "success",
|
||||||
|
ipAddress: clientAddress(request)
|
||||||
|
});
|
||||||
|
return { user: { id: user.id, username: user.username, role: user.role } };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post("/api/auth/logout", async (request, reply) => {
|
||||||
|
const token = request.cookies[SESSION_COOKIE];
|
||||||
|
if (token) database.prepare("DELETE FROM sessions WHERE id_hash = ?").run(hashToken(token));
|
||||||
|
if (request.authUser) {
|
||||||
|
audit(database, {
|
||||||
|
actorUserId: request.authUser.id,
|
||||||
|
action: "auth.logout",
|
||||||
|
result: "success",
|
||||||
|
ipAddress: clientAddress(request)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
reply.clearCookie(SESSION_COOKIE, cookieOptions(config, true));
|
||||||
|
return { loggedOut: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/status", async (request, reply) => {
|
||||||
|
if (!requireUser(request, reply)) return;
|
||||||
|
return collectSystemStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/system/update", async (request, reply) => {
|
||||||
|
if (!requireUser(request, reply)) return;
|
||||||
|
return readUpdateStatus(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/system/update",
|
||||||
|
{ config: { rateLimit: { max: 2, 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 using automatic updates" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (["checking", "building"].includes(current.state)) {
|
||||||
|
return reply.code(409).send({ error: { code: "UPDATE_IN_PROGRESS", message: "An update is already running" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await triggerSystemUpdate();
|
||||||
|
audit(database, {
|
||||||
|
actorUserId: request.authUser!.id,
|
||||||
|
action: "system.update",
|
||||||
|
result: "success",
|
||||||
|
ipAddress: clientAddress(request),
|
||||||
|
details: { state: "queued" }
|
||||||
|
});
|
||||||
|
return reply.code(202).send({ queued: true });
|
||||||
|
} catch {
|
||||||
|
audit(database, {
|
||||||
|
actorUserId: request.authUser!.id,
|
||||||
|
action: "system.update",
|
||||||
|
result: "failure",
|
||||||
|
ipAddress: clientAddress(request),
|
||||||
|
details: { reason: "update_service_unavailable" }
|
||||||
|
});
|
||||||
|
return reply.code(503).send({
|
||||||
|
error: { code: "UPDATE_START_FAILED", message: "The update service could not be started" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get("/api/events", async (request, reply) => {
|
||||||
|
if (!requireUser(request, reply)) return;
|
||||||
|
reply.hijack();
|
||||||
|
reply.raw.writeHead(200, {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no"
|
||||||
|
});
|
||||||
|
let closed = false;
|
||||||
|
const sendStatus = async () => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
const status = await collectSystemStatus();
|
||||||
|
reply.raw.write(`event: status\ndata: ${JSON.stringify(status)}\n\n`);
|
||||||
|
} catch {
|
||||||
|
reply.raw.write(`event: collection-error\ndata: {"code":"STATUS_COLLECTION_FAILED"}\n\n`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await sendStatus();
|
||||||
|
const interval = setInterval(() => void sendStatus(), 10_000);
|
||||||
|
request.raw.on("close", () => {
|
||||||
|
closed = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const webRoot = resolve(fileURLToPath(new URL("../../web/dist", import.meta.url)));
|
||||||
|
if (config.nodeEnv === "production" && existsSync(webRoot)) {
|
||||||
|
app.get("/", async (_request, reply) => {
|
||||||
|
reply.type("text/html; charset=utf-8");
|
||||||
|
return reply.send(createReadStream(resolve(webRoot, "index.html")));
|
||||||
|
});
|
||||||
|
app.get<{ Params: { file: string } }>("/assets/:file", async (request, reply) => {
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(request.params.file)) {
|
||||||
|
return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Asset not found" } });
|
||||||
|
}
|
||||||
|
const assetPath = resolve(webRoot, "assets", request.params.file);
|
||||||
|
if (!existsSync(assetPath) || !statSync(assetPath).isFile()) {
|
||||||
|
return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Asset not found" } });
|
||||||
|
}
|
||||||
|
const contentTypes: Record<string, string> = {
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".map": "application/json; charset=utf-8",
|
||||||
|
".png": "image/png",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".woff2": "font/woff2"
|
||||||
|
};
|
||||||
|
reply.type(contentTypes[extname(assetPath)] ?? "application/octet-stream");
|
||||||
|
reply.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
return reply.send(createReadStream(assetPath));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const booleanFromString = z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.default("false")
|
||||||
|
.transform((value) => value === "true");
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
|
HOST: z.string().default("127.0.0.1"),
|
||||||
|
PORT: z.coerce.number().int().min(1).max(65_535).default(8787),
|
||||||
|
DATABASE_PATH: z.string().default(resolve("data/companion.db")),
|
||||||
|
SESSION_TTL_HOURS: z.coerce.number().positive().max(24 * 30).default(24),
|
||||||
|
COOKIE_SECURE: booleanFromString,
|
||||||
|
ALLOWED_ORIGINS: z.string().default("http://mg4pi.local:8787"),
|
||||||
|
UPDATE_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/update-status.json"),
|
||||||
|
VERSION_FILE: z.string().default(resolve("REVISION"))
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppConfig = {
|
||||||
|
nodeEnv: "development" | "test" | "production";
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
databasePath: string;
|
||||||
|
sessionTtlMs: number;
|
||||||
|
cookieSecure: boolean;
|
||||||
|
allowedOrigins: ReadonlySet<string>;
|
||||||
|
updateStatusPath: string;
|
||||||
|
versionFile: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
|
const parsed = schema.parse(environment);
|
||||||
|
const allowedOrigins = new Set(
|
||||||
|
parsed.ALLOWED_ORIGINS.split(",")
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parsed.NODE_ENV === "development") {
|
||||||
|
allowedOrigins.add("http://localhost:5173");
|
||||||
|
allowedOrigins.add("http://127.0.0.1:5173");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeEnv: parsed.NODE_ENV,
|
||||||
|
host: parsed.HOST,
|
||||||
|
port: parsed.PORT,
|
||||||
|
databasePath: parsed.DATABASE_PATH,
|
||||||
|
sessionTtlMs: parsed.SESSION_TTL_HOURS * 60 * 60 * 1000,
|
||||||
|
cookieSecure: parsed.COOKIE_SECURE,
|
||||||
|
allowedOrigins,
|
||||||
|
updateStatusPath: parsed.UPDATE_STATUS_PATH,
|
||||||
|
versionFile: parsed.VERSION_FILE
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
export type CompanionDatabase = Database.Database;
|
||||||
|
|
||||||
|
export function openDatabase(path: string): CompanionDatabase {
|
||||||
|
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true, mode: 0o750 });
|
||||||
|
|
||||||
|
const database = new Database(path);
|
||||||
|
database.pragma("journal_mode = WAL");
|
||||||
|
database.pragma("foreign_keys = ON");
|
||||||
|
database.pragma("busy_timeout = 5000");
|
||||||
|
migrate(database);
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate(database: CompanionDatabase): void {
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role = 'admin'),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id_hash TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
result TEXT NOT NULL CHECK (result IN ('success', 'failure')),
|
||||||
|
ip_address TEXT,
|
||||||
|
details_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { buildApp } from "./app.js";
|
||||||
|
import { loadConfig } from "./config.js";
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const app = await buildApp(config);
|
||||||
|
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
app.log.info({ signal }, "Shutting down");
|
||||||
|
await app.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ host: config.host, port: config.port });
|
||||||
|
} catch (error) {
|
||||||
|
app.log.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import type { FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = "companion_session";
|
||||||
|
export const CSRF_COOKIE = "companion_csrf";
|
||||||
|
|
||||||
|
export function randomToken(bytes = 32): string {
|
||||||
|
return randomBytes(bytes).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeEqual(left: string, right: string): boolean {
|
||||||
|
const leftBuffer = Buffer.from(left);
|
||||||
|
const rightBuffer = Buffer.from(right);
|
||||||
|
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientAddress(request: FastifyRequest): string {
|
||||||
|
return request.ip.slice(0, 64);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import os from "node:os";
|
||||||
|
import type { Availability } from "./types.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
type DiskStatus = { totalBytes: number; usedBytes: number; availableBytes: number; mount: string };
|
||||||
|
|
||||||
|
function available<T>(value: T): Availability<T> {
|
||||||
|
return { available: true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailable<T>(reason: string): Availability<T> {
|
||||||
|
return { available: false, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cpuTemperature(): Promise<Availability<number>> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile("/sys/class/thermal/thermal_zone0/temp", "utf8");
|
||||||
|
const value = Number.parseInt(raw.trim(), 10) / 1000;
|
||||||
|
return Number.isFinite(value) ? available(value) : unavailable("Kernel returned an invalid value");
|
||||||
|
} catch {
|
||||||
|
return unavailable("Thermal sensor is not exposed by this host");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function diskUsage(): Promise<Availability<DiskStatus>> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync("df", ["-Pk", "/"], { timeout: 2_000 });
|
||||||
|
const line = stdout.trim().split("\n").at(-1)?.trim().split(/\s+/);
|
||||||
|
if (!line || line.length < 6) return unavailable("Could not parse disk usage");
|
||||||
|
const total = Number(line[1]) * 1024;
|
||||||
|
const used = Number(line[2]) * 1024;
|
||||||
|
const free = Number(line[3]) * 1024;
|
||||||
|
if (![total, used, free].every(Number.isFinite)) return unavailable("Disk usage was invalid");
|
||||||
|
return available({ totalBytes: total, usedBytes: used, availableBytes: free, mount: line[5] ?? "/" });
|
||||||
|
} catch {
|
||||||
|
return unavailable("The df utility was unavailable or timed out");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectSystemStatus() {
|
||||||
|
let interfaces: Array<{ name: string; address: string; family: string }> = [];
|
||||||
|
let interfaceReason: string | null = null;
|
||||||
|
try {
|
||||||
|
interfaces = Object.entries(os.networkInterfaces()).flatMap(([name, addresses]) =>
|
||||||
|
(addresses ?? [])
|
||||||
|
.filter((address) => !address.internal)
|
||||||
|
.map((address) => ({ name, address: address.address, family: address.family }))
|
||||||
|
);
|
||||||
|
if (interfaces.length === 0) interfaceReason = "No active non-loopback interface was found";
|
||||||
|
} catch {
|
||||||
|
interfaceReason = "Network interfaces are not exposed by this host";
|
||||||
|
}
|
||||||
|
const totalMemory = os.totalmem();
|
||||||
|
const freeMemory = os.freemem();
|
||||||
|
|
||||||
|
const [temperature, disk] = await Promise.all([cpuTemperature(), diskUsage()]);
|
||||||
|
return {
|
||||||
|
collectedAt: new Date().toISOString(),
|
||||||
|
hostname: os.hostname(),
|
||||||
|
uptimeSeconds: os.uptime(),
|
||||||
|
operatingSystem: `${os.type()} ${os.release()}`,
|
||||||
|
cpu: {
|
||||||
|
model: os.cpus()[0]?.model ?? "Unavailable",
|
||||||
|
loadAverage: os.loadavg(),
|
||||||
|
temperatureCelsius: temperature
|
||||||
|
},
|
||||||
|
memory: { totalBytes: totalMemory, usedBytes: totalMemory - freeMemory, availableBytes: freeMemory },
|
||||||
|
disk,
|
||||||
|
network: {
|
||||||
|
interfaces,
|
||||||
|
interfaceReason,
|
||||||
|
wifi: unavailable<string>("Wi-Fi capability detection is scheduled for the next milestone")
|
||||||
|
},
|
||||||
|
service: { state: "healthy" as const, processUptimeSeconds: process.uptime() }
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { CompanionDatabase } from "./database.js";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
authUser: { id: number; username: string; role: "admin" } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FastifyInstance {
|
||||||
|
database: CompanionDatabase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Availability<T> =
|
||||||
|
| { available: true; value: T }
|
||||||
|
| { available: false; reason: string };
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { AppConfig } from "./config.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const updateStatusSchema = z.object({
|
||||||
|
state: z.enum(["idle", "checking", "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),
|
||||||
|
updatedAt: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateStatus = z.infer<typeof updateStatusSchema> & {
|
||||||
|
installedRevision: string | null;
|
||||||
|
supported: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readTrimmed(path: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const value = (await readFile(path, "utf8")).trim();
|
||||||
|
return value || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readUpdateStatus(config: AppConfig): Promise<UpdateStatus> {
|
||||||
|
const installedRevision = await readTrimmed(config.versionFile);
|
||||||
|
try {
|
||||||
|
const parsed = updateStatusSchema.parse(JSON.parse(await readFile(config.updateStatusPath, "utf8")));
|
||||||
|
const updateAgeMs = Date.now() - Date.parse(parsed.updatedAt);
|
||||||
|
if (["checking", "building"].includes(parsed.state) && Number.isFinite(updateAgeMs) && updateAgeMs > 35 * 60 * 1000) {
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
state: "failed",
|
||||||
|
message: "The previous update was interrupted. It is safe to try again.",
|
||||||
|
installedRevision,
|
||||||
|
supported: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...parsed, installedRevision, supported: true };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
state: "idle",
|
||||||
|
message: "Automatic updates are available after installing the system service.",
|
||||||
|
fromRevision: null,
|
||||||
|
toRevision: null,
|
||||||
|
updatedAt: new Date(0).toISOString(),
|
||||||
|
installedRevision,
|
||||||
|
supported: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerSystemUpdate(): Promise<void> {
|
||||||
|
await execFileAsync(
|
||||||
|
"/usr/bin/sudo",
|
||||||
|
["-n", "/usr/bin/systemctl", "start", "--no-block", "pi-car-companion-update.service"],
|
||||||
|
{ timeout: 10_000, maxBuffer: 16 * 1024 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import type { AppConfig } from "../src/config.js";
|
||||||
|
import { CSRF_COOKIE, SESSION_COOKIE } from "../src/security.js";
|
||||||
|
|
||||||
|
const apps: FastifyInstance[] = [];
|
||||||
|
|
||||||
|
function testConfig(): AppConfig {
|
||||||
|
return {
|
||||||
|
nodeEnv: "test",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 8787,
|
||||||
|
databasePath: ":memory:",
|
||||||
|
sessionTtlMs: 60_000,
|
||||||
|
cookieSecure: false,
|
||||||
|
allowedOrigins: new Set(["http://mg4pi.local:8787"]),
|
||||||
|
updateStatusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-update.json`,
|
||||||
|
versionFile: `/tmp/pi-car-companion-test-${process.pid}-missing-revision`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApp(): Promise<FastifyInstance> {
|
||||||
|
const app = await buildApp(testConfig());
|
||||||
|
apps.push(app);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function csrf(app: FastifyInstance): Promise<string> {
|
||||||
|
const response = await app.inject({ method: "GET", url: "/api/auth/state" });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
return response.json<{ csrfToken: string }>().csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutationHeaders(token: string) {
|
||||||
|
return {
|
||||||
|
cookie: `${CSRF_COOKIE}=${token}`,
|
||||||
|
"x-csrf-token": token,
|
||||||
|
origin: "http://mg4pi.local:8787"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(app: FastifyInstance, token: string) {
|
||||||
|
return app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/setup",
|
||||||
|
headers: mutationHeaders(token),
|
||||||
|
payload: { username: "owner", password: "correct-horse-battery-staple" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authentication boundary", () => {
|
||||||
|
it("creates exactly one initial administrator", async () => {
|
||||||
|
const app = await createApp();
|
||||||
|
const token = await csrf(app);
|
||||||
|
|
||||||
|
expect((await setup(app, token)).statusCode).toBe(201);
|
||||||
|
const second = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/setup",
|
||||||
|
headers: mutationHeaders(token),
|
||||||
|
payload: { username: "second", password: "another-secure-password" }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second.statusCode).toBe(409);
|
||||||
|
expect(second.json()).toMatchObject({ error: { code: "SETUP_COMPLETE" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects state changes without a matching CSRF token", async () => {
|
||||||
|
const app = await createApp();
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/setup",
|
||||||
|
payload: { username: "owner", password: "correct-horse-battery-staple" }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(response.json()).toMatchObject({ error: { code: "CSRF_REJECTED" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an untrusted browser origin", async () => {
|
||||||
|
const app = await createApp();
|
||||||
|
const token = await csrf(app);
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/setup",
|
||||||
|
headers: { ...mutationHeaders(token), origin: "https://attacker.example" },
|
||||||
|
payload: { username: "owner", password: "correct-horse-battery-staple" }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(response.json()).toMatchObject({ error: { code: "ORIGIN_REJECTED" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a same-host origin for dynamic LAN addresses", async () => {
|
||||||
|
const app = await createApp();
|
||||||
|
const token = await csrf(app);
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/setup",
|
||||||
|
headers: {
|
||||||
|
...mutationHeaders(token),
|
||||||
|
host: "192.168.4.20:8787",
|
||||||
|
origin: "http://192.168.4.20:8787"
|
||||||
|
},
|
||||||
|
payload: { username: "owner", password: "correct-horse-battery-staple" }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a valid session for system status", async () => {
|
||||||
|
const app = await createApp();
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/status" })).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-horse-battery-staple" }
|
||||||
|
});
|
||||||
|
expect(login.statusCode).toBe(200);
|
||||||
|
const sessionCookie = login.cookies.find((cookie) => cookie.name === SESSION_COOKIE);
|
||||||
|
expect(sessionCookie).toBeDefined();
|
||||||
|
|
||||||
|
const status = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/status",
|
||||||
|
headers: { cookie: `${SESSION_COOKIE}=${sessionCookie?.value ?? ""}` }
|
||||||
|
});
|
||||||
|
expect(status.statusCode, status.body).toBe(200);
|
||||||
|
expect(status.json()).toMatchObject({ service: { state: "healthy" } });
|
||||||
|
|
||||||
|
const update = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/system/update",
|
||||||
|
headers: {
|
||||||
|
...mutationHeaders(token),
|
||||||
|
cookie: `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${sessionCookie?.value ?? ""}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(update.statusCode).toBe(503);
|
||||||
|
expect(update.json()).toMatchObject({ error: { code: "UPDATES_UNAVAILABLE" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose update state without authentication", async () => {
|
||||||
|
const app = await createApp();
|
||||||
|
const response = await app.inject({ method: "GET", url: "/api/system/update" });
|
||||||
|
expect(response.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { mkdtemp, 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 { readUpdateStatus } from "../src/update.js";
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
function config(updateStatusPath: string, versionFile: string): AppConfig {
|
||||||
|
return {
|
||||||
|
nodeEnv: "test",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 8787,
|
||||||
|
databasePath: ":memory:",
|
||||||
|
sessionTtlMs: 60_000,
|
||||||
|
cookieSecure: false,
|
||||||
|
allowedOrigins: new Set(),
|
||||||
|
updateStatusPath,
|
||||||
|
versionFile
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("system update status", () => {
|
||||||
|
it("reads only validated updater state and active revision metadata", 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: "success",
|
||||||
|
message: "Update installed.",
|
||||||
|
fromRevision: "a".repeat(40),
|
||||||
|
toRevision: "b".repeat(40),
|
||||||
|
updatedAt: "2026-07-31T18:00:00.000Z"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await writeFile(versionPath, `${"b".repeat(40)}\n`);
|
||||||
|
|
||||||
|
await expect(readUpdateStatus(config(statusPath, versionPath))).resolves.toMatchObject({
|
||||||
|
state: "success",
|
||||||
|
installedRevision: "b".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);
|
||||||
|
const statusPath = join(directory, "status.json");
|
||||||
|
await writeFile(statusPath, "not-json");
|
||||||
|
|
||||||
|
await expect(readUpdateStatus(config(statusPath, join(directory, "missing")))).resolves.toMatchObject({
|
||||||
|
state: "idle",
|
||||||
|
installedRevision: null,
|
||||||
|
supported: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers an update state left running after interruption", async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "pi-car-update-test-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
const statusPath = join(directory, "status.json");
|
||||||
|
await writeFile(
|
||||||
|
statusPath,
|
||||||
|
JSON.stringify({
|
||||||
|
state: "building",
|
||||||
|
message: "Building.",
|
||||||
|
fromRevision: null,
|
||||||
|
toRevision: null,
|
||||||
|
updatedAt: "2020-01-01T00:00:00.000Z"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(readUpdateStatus(config(statusPath, join(directory, "missing")))).resolves.toMatchObject({
|
||||||
|
state: "failed",
|
||||||
|
supported: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["tests"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"types": ["node", "vitest/globals"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "tests"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Update Pi Car Companion
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
ExecStart=/usr/local/sbin/pi-car-companion-update
|
||||||
|
TimeoutStartSec=30min
|
||||||
|
Nice=10
|
||||||
|
IOSchedulingClass=idle
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Pi Car Companion
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pi-companion
|
||||||
|
Group=pi-companion
|
||||||
|
WorkingDirectory=/opt/pi-car-companion/current
|
||||||
|
EnvironmentFile=/etc/pi-car-companion/companion.env
|
||||||
|
ExecStart=/usr/bin/node /opt/pi-car-companion/current/server/dist/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
TimeoutStopSec=20s
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
UMask=0027
|
||||||
|
StateDirectory=pi-car-companion
|
||||||
|
NoNewPrivileges=false
|
||||||
|
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
|
||||||
|
ReadWritePaths=/var/lib/pi-car-companion
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pi-companion ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pi-car-companion-update.service
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"useUnknownInCatchVariables": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#10130f" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>Pi Car Companion</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@pi-car/web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"lint": "eslint src vite.config.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"build": "tsc --noEmit && vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.10",
|
||||||
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"vite": "^7.0.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
+378
@@ -0,0 +1,378 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowClockwise,
|
||||||
|
CheckCircle,
|
||||||
|
Cpu,
|
||||||
|
Gauge,
|
||||||
|
HardDrives,
|
||||||
|
House,
|
||||||
|
Gear,
|
||||||
|
GitBranch,
|
||||||
|
DownloadSimple,
|
||||||
|
SignOut,
|
||||||
|
TerminalWindow,
|
||||||
|
WarningCircle,
|
||||||
|
WifiHigh
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
getAuthState,
|
||||||
|
getStatus,
|
||||||
|
getUpdateStatus,
|
||||||
|
post,
|
||||||
|
type AuthState,
|
||||||
|
type SystemStatus,
|
||||||
|
type UpdateStatus
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
|
type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [screen, setScreen] = useState<Screen>("loading");
|
||||||
|
const [auth, setAuth] = useState<AuthState | null>(null);
|
||||||
|
const [fatalError, setFatalError] = useState("");
|
||||||
|
|
||||||
|
const refreshAuth = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const state = await getAuthState();
|
||||||
|
setAuth(state);
|
||||||
|
setScreen(state.setupRequired ? "setup" : state.user ? "dashboard" : "login");
|
||||||
|
} catch (error) {
|
||||||
|
setFatalError(error instanceof Error ? error.message : "The companion service is unreachable");
|
||||||
|
setScreen("fatal");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => void refreshAuth(), [refreshAuth]);
|
||||||
|
|
||||||
|
if (screen === "loading") return <BootScreen />;
|
||||||
|
if (screen === "fatal") return <ConnectionError message={fatalError} retry={() => void refreshAuth()} />;
|
||||||
|
if (!auth) return null;
|
||||||
|
if (screen === "setup") return <AccountScreen mode="setup" csrfToken={auth.csrfToken} onComplete={refreshAuth} />;
|
||||||
|
if (screen === "login") return <AccountScreen mode="login" csrfToken={auth.csrfToken} onComplete={refreshAuth} />;
|
||||||
|
return <Dashboard auth={auth} onLoggedOut={refreshAuth} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BootScreen() {
|
||||||
|
return (
|
||||||
|
<main className="center-screen" aria-live="polite">
|
||||||
|
<div className="boot-mark"><Gauge size={44} weight="duotone" /></div>
|
||||||
|
<p className="eyebrow">Pi Car Companion</p>
|
||||||
|
<h1>Connecting to your Pi</h1>
|
||||||
|
<div className="loading-line" aria-hidden="true"><span /></div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionError({ message, retry }: { message: string; retry: () => void }) {
|
||||||
|
return (
|
||||||
|
<main className="center-screen error-screen">
|
||||||
|
<WarningCircle size={52} weight="duotone" aria-hidden="true" />
|
||||||
|
<p className="eyebrow">Connection unavailable</p>
|
||||||
|
<h1>The Pi did not respond</h1>
|
||||||
|
<p className="screen-copy">{message}. Check power and network access, then reconnect.</p>
|
||||||
|
<button className="primary-button" onClick={retry}><ArrowClockwise size={22} /> Reconnect</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountScreen({
|
||||||
|
mode,
|
||||||
|
csrfToken,
|
||||||
|
onComplete
|
||||||
|
}: {
|
||||||
|
mode: "setup" | "login";
|
||||||
|
csrfToken: string;
|
||||||
|
onComplete: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const setup = mode === "setup";
|
||||||
|
|
||||||
|
async function submit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (setup) await post("/api/auth/setup", { username, password }, csrfToken);
|
||||||
|
await post("/api/auth/login", { username, password }, csrfToken);
|
||||||
|
await onComplete();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof ApiError ? caught.message : "The request could not be completed");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="account-layout">
|
||||||
|
<section className="account-context">
|
||||||
|
<div className="brand"><Gauge size={32} weight="duotone" /><span>Pi Car Companion</span></div>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{setup ? "First run" : "Local access"}</p>
|
||||||
|
<h1>{setup ? "Secure your companion." : "Welcome back."}</h1>
|
||||||
|
<p>{setup ? "Create the only initial administrator. There are no default credentials." : "Sign in to view live Pi status and controlled actions."}</p>
|
||||||
|
</div>
|
||||||
|
<p className="local-note"><WifiHigh size={22} /> Credentials stay on this Pi.</p>
|
||||||
|
</section>
|
||||||
|
<section className="account-form-wrap">
|
||||||
|
<form className="account-form" onSubmit={(event) => void submit(event)}>
|
||||||
|
<h2>{setup ? "Create administrator" : "Sign in"}</h2>
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} minLength={3} maxLength={48} required autoFocus />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input type="password" autoComplete={setup ? "new-password" : "current-password"} value={password} onChange={(event) => setPassword(event.target.value)} minLength={12} maxLength={256} required />
|
||||||
|
{setup && <small>Use at least 12 characters.</small>}
|
||||||
|
</label>
|
||||||
|
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
|
||||||
|
<button className="primary-button" type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Working..." : setup ? "Create account" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () => Promise<void> }) {
|
||||||
|
const [page, setPage] = useState<"home" | "settings">("home");
|
||||||
|
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [connection, setConnection] = useState<"connecting" | "live" | "offline">("connecting");
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
setStatus(await getStatus());
|
||||||
|
setError("");
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : "Status could not be loaded");
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
const events = new EventSource("/api/events");
|
||||||
|
events.addEventListener("open", () => setConnection("live"));
|
||||||
|
events.addEventListener("status", (event) => {
|
||||||
|
setStatus(JSON.parse((event as MessageEvent<string>).data) as SystemStatus);
|
||||||
|
setConnection("live");
|
||||||
|
setError("");
|
||||||
|
});
|
||||||
|
events.addEventListener("error", () => setConnection("offline"));
|
||||||
|
return () => events.close();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await post("/api/auth/logout", {}, auth.csrfToken);
|
||||||
|
await onLoggedOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="brand brand-compact"><Gauge size={30} weight="duotone" /><span>Pi Companion</span></div>
|
||||||
|
<nav aria-label="Primary navigation">
|
||||||
|
<button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => setPage("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button>
|
||||||
|
<button className="nav-item" disabled><TerminalWindow size={24} /><span>Jobs</span><small>Soon</small></button>
|
||||||
|
<button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => setPage("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button>
|
||||||
|
</nav>
|
||||||
|
<button className="nav-item logout" onClick={() => void logout()}><SignOut size={24} /><span>Sign out</span></button>
|
||||||
|
</aside>
|
||||||
|
<main className="dashboard">
|
||||||
|
<header className="dashboard-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{page === "home" ? "System overview" : "Companion settings"}</p>
|
||||||
|
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : "Settings"}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="header-actions">
|
||||||
|
<div className={`connection-state ${connection}`}>
|
||||||
|
<span aria-hidden="true" /> {connection === "live" ? "Live" : connection === "connecting" ? "Connecting" : "Reconnecting"}
|
||||||
|
</div>
|
||||||
|
{page === "home" && <button className="secondary-button" onClick={() => void refresh()} disabled={refreshing}>
|
||||||
|
<ArrowClockwise size={22} className={refreshing ? "spin" : ""} /> Refresh
|
||||||
|
</button>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{page === "home" ? <>
|
||||||
|
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
|
||||||
|
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
|
||||||
|
</> : <SettingsPage csrfToken={auth.csrfToken} />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||||
|
const [update, setUpdate] = useState<UpdateStatus | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [requesting, setRequesting] = useState(false);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const running = update?.state === "checking" || update?.state === "building";
|
||||||
|
|
||||||
|
const refreshUpdate = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setUpdate(await getUpdateStatus());
|
||||||
|
setError("");
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : "Update status is unavailable");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshUpdate();
|
||||||
|
const interval = window.setInterval(() => void refreshUpdate(), 3_000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [refreshUpdate]);
|
||||||
|
|
||||||
|
async function startUpdate() {
|
||||||
|
setConfirming(false);
|
||||||
|
setRequesting(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await post("/api/system/update", {}, csrfToken);
|
||||||
|
await refreshUpdate();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof ApiError ? caught.message : "The update could not be started");
|
||||||
|
} finally {
|
||||||
|
setRequesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-layout">
|
||||||
|
<section className="settings-panel">
|
||||||
|
<div className="settings-title">
|
||||||
|
<div className="settings-icon"><DownloadSimple size={30} weight="duotone" /></div>
|
||||||
|
<div><h2>Software update</h2><p>Fetch, verify, build, and activate the newest release from the configured Git branch.</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="update-facts">
|
||||||
|
<div><dt>Status</dt><dd className={`update-state ${update?.state ?? "idle"}`}>{statusLabel}</dd></div>
|
||||||
|
<div><dt>Installed revision</dt><dd><GitBranch size={18} /> <code>{revision}</code></dd></div>
|
||||||
|
<div><dt>Last activity</dt><dd>{update && update.updatedAt !== new Date(0).toISOString() ? new Date(update.updatedAt).toLocaleString() : "Never"}</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="update-message" aria-live="polite">
|
||||||
|
{running && <ArrowClockwise size={22} className="spin" />}
|
||||||
|
{!running && update?.state === "failed" && <WarningCircle size={22} />}
|
||||||
|
{!running && update?.state !== "failed" && <CheckCircle size={22} />}
|
||||||
|
<span>{update?.message ?? "Reading update status..."}</span>
|
||||||
|
</div>
|
||||||
|
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
|
||||||
|
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="primary-button" onClick={() => setConfirming(true)} disabled={!update?.supported || running || requesting}>
|
||||||
|
<DownloadSimple size={22} /> {running ? "Updating..." : "Update now"}
|
||||||
|
</button>
|
||||||
|
<button className="secondary-button" onClick={() => void refreshUpdate()} disabled={requesting}>Refresh status</button>
|
||||||
|
</div>
|
||||||
|
<p className="settings-note">The current release stays active unless the new version passes installation, lint, type checks, tests, and production builds.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-panel compact-panel">
|
||||||
|
<h2>Service behavior</h2>
|
||||||
|
<dl className="behavior-list">
|
||||||
|
<div><dt>Start on boot</dt><dd>{update?.supported ? "Enabled by systemd" : "Available after install"}</dd></div>
|
||||||
|
<div><dt>Failure recovery</dt><dd>{update?.supported ? "Automatic restart" : "Available after install"}</dd></div>
|
||||||
|
<div><dt>Update channel</dt><dd>{update?.supported ? "Configured Git branch" : "Not configured"}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{confirming && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirming(false)}>
|
||||||
|
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="update-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
||||||
|
<h2 id="update-dialog-title">Install the latest version?</h2>
|
||||||
|
<p>The dashboard may disconnect briefly when the service restarts. The previous version remains active if verification fails.</p>
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="secondary-button" onClick={() => setConfirming(false)}>Cancel</button>
|
||||||
|
<button className="primary-button" onClick={() => void startUpdate()}>Install update</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusContent({ status }: { status: SystemStatus }) {
|
||||||
|
const memoryPercent = percent(status.memory.usedBytes, status.memory.totalBytes);
|
||||||
|
const diskPercent = status.disk.available ? percent(status.disk.value.usedBytes, status.disk.value.totalBytes) : null;
|
||||||
|
const primaryAddress = status.network.interfaces[0]?.address;
|
||||||
|
const temperature = status.cpu.temperatureCelsius.available
|
||||||
|
? `${status.cpu.temperatureCelsius.value.toFixed(1)}°C`
|
||||||
|
: "Unavailable";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="status-layout">
|
||||||
|
<section className="health-summary">
|
||||||
|
<div className="summary-heading">
|
||||||
|
<div className="health-icon"><CheckCircle size={38} weight="fill" /></div>
|
||||||
|
<div><p>Companion service</p><h2>Running normally</h2></div>
|
||||||
|
</div>
|
||||||
|
<dl className="summary-details">
|
||||||
|
<div><dt>Host uptime</dt><dd>{formatUptime(status.uptimeSeconds)}</dd></div>
|
||||||
|
<div><dt>Service uptime</dt><dd>{formatUptime(status.service.processUptimeSeconds)}</dd></div>
|
||||||
|
<div><dt>Last update</dt><dd>{new Date(status.collectedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="metrics-grid" aria-label="System metrics">
|
||||||
|
<Metric icon={<Cpu size={28} />} label="CPU temperature" value={temperature} note={`Load ${status.cpu.loadAverage[0]?.toFixed(2) ?? "Unavailable"}`} />
|
||||||
|
<Metric icon={<Gauge size={28} />} label="Memory used" value={`${memoryPercent}%`} note={`${formatBytes(status.memory.availableBytes)} available`} meter={memoryPercent} />
|
||||||
|
<Metric icon={<HardDrives size={28} />} label="Disk used" value={diskPercent === null ? "Unavailable" : `${diskPercent}%`} note={status.disk.available ? `${formatBytes(status.disk.value.availableBytes)} available` : status.disk.reason} meter={diskPercent ?? undefined} />
|
||||||
|
<Metric icon={<WifiHigh size={28} />} label="Local network" value={primaryAddress ?? "Unavailable"} note={status.network.interfaces[0]?.name ?? status.network.interfaceReason ?? "No active interface"} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="system-strip">
|
||||||
|
<div><span>Operating system</span><strong>{status.operatingSystem}</strong></div>
|
||||||
|
<div><span>Processor</span><strong>{status.cpu.model}</strong></div>
|
||||||
|
<div><span>Wi-Fi detail</span><strong>{status.network.wifi.available ? status.network.wifi.value : status.network.wifi.reason}</strong></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ icon, label, value, note, meter }: { icon: React.ReactNode; label: string; value: string; note: string; meter?: number | undefined }) {
|
||||||
|
return (
|
||||||
|
<article className="metric">
|
||||||
|
<div className="metric-label">{icon}<span>{label}</span></div>
|
||||||
|
<strong className="metric-value">{value}</strong>
|
||||||
|
<p>{note}</p>
|
||||||
|
{meter !== undefined && <div className="meter" aria-label={`${label}: ${meter}%`}><span style={{ width: `${meter}%` }} /></div>}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardSkeleton() {
|
||||||
|
return <div className="skeleton-grid" aria-label="Loading system status"><div /><div /><div /><div /><div /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function percent(used: number, total: number): number {
|
||||||
|
return total > 0 ? Math.round((used / total) * 100) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let value = bytes;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
|
||||||
|
return `${value.toFixed(unit < 2 ? 0 : 1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86_400);
|
||||||
|
const hours = Math.floor((seconds % 86_400) / 3_600);
|
||||||
|
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||||
|
return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export type User = { id: number; username: string; role: "admin" };
|
||||||
|
|
||||||
|
export type AuthState = {
|
||||||
|
setupRequired: boolean;
|
||||||
|
user: User | null;
|
||||||
|
csrfToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Availability<T> =
|
||||||
|
| { available: true; value: T }
|
||||||
|
| { available: false; reason: string };
|
||||||
|
|
||||||
|
export type SystemStatus = {
|
||||||
|
collectedAt: string;
|
||||||
|
hostname: string;
|
||||||
|
uptimeSeconds: number;
|
||||||
|
operatingSystem: string;
|
||||||
|
cpu: {
|
||||||
|
model: string;
|
||||||
|
loadAverage: number[];
|
||||||
|
temperatureCelsius: Availability<number>;
|
||||||
|
};
|
||||||
|
memory: { totalBytes: number; usedBytes: number; availableBytes: number };
|
||||||
|
disk: Availability<{ totalBytes: number; usedBytes: number; availableBytes: number; mount: string }>;
|
||||||
|
network: {
|
||||||
|
interfaces: Array<{ name: string; address: string; family: string }>;
|
||||||
|
interfaceReason: string | null;
|
||||||
|
wifi: Availability<string>;
|
||||||
|
};
|
||||||
|
service: { state: "healthy"; processUptimeSeconds: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateStatus = {
|
||||||
|
state: "idle" | "checking" | "building" | "current" | "success" | "failed";
|
||||||
|
message: string;
|
||||||
|
fromRevision: string | null;
|
||||||
|
toRevision: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
installedRevision: string | null;
|
||||||
|
supported: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorResponse = { error?: { code?: string; message?: string } };
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly code: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseResponse<T>(response: Response): Promise<T> {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as T & ErrorResponse;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(data.error?.message ?? "The request failed", response.status, data.error?.code ?? "REQUEST_FAILED");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthState(): Promise<AuthState> {
|
||||||
|
return parseResponse<AuthState>(await fetch("/api/auth/state", { credentials: "same-origin" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function post<T>(path: string, body: unknown, csrfToken: string): Promise<T> {
|
||||||
|
return parseResponse<T>(
|
||||||
|
await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStatus(): Promise<SystemStatus> {
|
||||||
|
return parseResponse<SystemStatus>(await fetch("/api/status", { credentials: "same-origin" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUpdateStatus(): Promise<UpdateStatus> {
|
||||||
|
return parseResponse<UpdateStatus>(await fetch("/api/system/update", { credentials: "same-origin" }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
:root {
|
||||||
|
font-family: "Aptos", "Segoe UI Variable", "Segoe UI", system-ui, sans-serif;
|
||||||
|
color: #f0f2eb;
|
||||||
|
background: #10130f;
|
||||||
|
font-synthesis: none;
|
||||||
|
--bg: #10130f;
|
||||||
|
--surface: #171b16;
|
||||||
|
--surface-raised: #1d221c;
|
||||||
|
--line: #30372d;
|
||||||
|
--text: #f0f2eb;
|
||||||
|
--muted: #aeb7a7;
|
||||||
|
--accent: #b9e769;
|
||||||
|
--accent-ink: #172008;
|
||||||
|
--danger: #ff9b8d;
|
||||||
|
--radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
|
||||||
|
body { min-height: 100dvh; background: var(--bg); }
|
||||||
|
button, input { font: inherit; }
|
||||||
|
button { -webkit-tap-highlight-color: transparent; }
|
||||||
|
button:focus-visible, input:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
|
||||||
|
|
||||||
|
h1, h2, p { margin-top: 0; }
|
||||||
|
h1 { margin-bottom: 0; font-size: clamp(2rem, 3vw, 3.15rem); line-height: 1; letter-spacing: -0.045em; }
|
||||||
|
h2 { letter-spacing: -0.025em; }
|
||||||
|
|
||||||
|
.eyebrow { margin-bottom: 12px; color: var(--accent); font: 700 0.76rem/1.2 ui-monospace, "Cascadia Code", monospace; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; font-weight: 750; letter-spacing: -0.02em; }
|
||||||
|
.brand svg, .boot-mark svg { color: var(--accent); }
|
||||||
|
|
||||||
|
.center-screen { min-height: 100dvh; display: grid; place-content: center; justify-items: center; padding: 32px; text-align: center; }
|
||||||
|
.center-screen h1 { margin-bottom: 28px; }
|
||||||
|
.boot-mark { display: grid; place-items: center; width: 74px; height: 74px; margin-bottom: 28px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||||
|
.loading-line { width: min(320px, 70vw); height: 3px; overflow: hidden; border-radius: 2px; background: var(--line); }
|
||||||
|
.loading-line span { display: block; width: 45%; height: 100%; background: var(--accent); animation: load 1.2s ease-in-out infinite alternate; }
|
||||||
|
@keyframes load { from { transform: translateX(-20%); } to { transform: translateX(145%); } }
|
||||||
|
.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 { color: var(--accent-ink); background: var(--accent); }
|
||||||
|
.secondary-button { color: var(--text); background: var(--surface-raised); border: 1px solid var(--line); }
|
||||||
|
.primary-button:hover { background: #c8f17f; }
|
||||||
|
.secondary-button:hover { background: #262c24; }
|
||||||
|
.primary-button:active, .secondary-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); }
|
||||||
|
.account-context { display: flex; flex-direction: column; justify-content: space-between; min-height: 100dvh; padding: clamp(32px, 5vw, 76px); border-right: 1px solid var(--line); background: radial-gradient(circle at 20% 55%, rgba(185, 231, 105, 0.08), transparent 33%), var(--bg); }
|
||||||
|
.account-context h1 { max-width: 760px; margin-bottom: 22px; font-size: clamp(3rem, 6vw, 6.4rem); }
|
||||||
|
.account-context > div > p:not(.eyebrow) { max-width: 560px; color: var(--muted); font-size: 1.15rem; line-height: 1.6; }
|
||||||
|
.local-note { display: flex; align-items: center; gap: 10px; margin: 0; color: var(--muted); }
|
||||||
|
.account-form-wrap { display: grid; place-items: center; padding: 42px; background: var(--surface); }
|
||||||
|
.account-form { width: min(100%, 460px); }
|
||||||
|
.account-form h2 { margin-bottom: 36px; font-size: 1.8rem; }
|
||||||
|
.account-form label { display: grid; gap: 9px; margin-bottom: 22px; color: #dce1d7; font-weight: 650; }
|
||||||
|
.account-form input { min-height: 60px; width: 100%; padding: 0 16px; color: var(--text); background: #10140f; border: 1px solid #465043; border-radius: 10px; }
|
||||||
|
.account-form small { color: var(--muted); font-weight: 450; }
|
||||||
|
.account-form .primary-button { width: 100%; margin-top: 10px; }
|
||||||
|
.form-error, .status-error { display: flex; align-items: center; gap: 10px; padding: 14px 16px; margin-bottom: 16px; color: #ffd5cf; background: #321d19; border: 1px solid #6b332a; border-radius: 10px; }
|
||||||
|
|
||||||
|
.app-shell { min-height: 100dvh; display: grid; grid-template-columns: 220px minmax(0, 1fr); }
|
||||||
|
.sidebar { position: sticky; top: 0; height: 100dvh; display: flex; flex-direction: column; padding: 28px 18px 18px; border-right: 1px solid var(--line); background: #121610; }
|
||||||
|
.brand-compact { padding: 0 12px 30px; }
|
||||||
|
.sidebar nav { display: grid; gap: 8px; }
|
||||||
|
.nav-item { min-height: 58px; width: 100%; display: flex; align-items: center; gap: 13px; padding: 0 14px; color: var(--muted); background: transparent; border: 0; border-radius: 10px; font-weight: 700; text-align: left; cursor: pointer; }
|
||||||
|
.nav-item.active { color: var(--accent-ink); background: var(--accent); }
|
||||||
|
.nav-item small { margin-left: auto; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||||
|
.nav-item.logout { margin-top: auto; }
|
||||||
|
|
||||||
|
.dashboard { min-width: 0; padding: clamp(26px, 3.4vw, 52px); }
|
||||||
|
.dashboard-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; margin-bottom: 30px; }
|
||||||
|
.header-actions { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.connection-state { min-height: 48px; display: flex; align-items: center; gap: 9px; padding: 0 15px; color: var(--muted); font-weight: 700; }
|
||||||
|
.connection-state span { width: 9px; height: 9px; border-radius: 50%; background: #86907f; }
|
||||||
|
.connection-state.live span { background: var(--accent); box-shadow: 0 0 0 5px rgba(185, 231, 105, 0.1); }
|
||||||
|
.connection-state.offline span { background: var(--danger); }
|
||||||
|
.status-error { margin: 0 0 20px; }
|
||||||
|
.status-error button { margin-left: auto; color: #ffe6e2; background: transparent; border: 0; text-decoration: underline; cursor: pointer; }
|
||||||
|
|
||||||
|
.status-layout { display: grid; gap: 18px; }
|
||||||
|
.health-summary { display: grid; grid-template-columns: minmax(340px, 1.2fr) minmax(460px, 1fr); align-items: center; min-height: 150px; padding: 26px 30px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||||
|
.summary-heading { display: flex; align-items: center; gap: 18px; }
|
||||||
|
.health-icon { width: 64px; height: 64px; display: grid; place-items: center; color: var(--accent); background: rgba(185, 231, 105, 0.08); border-radius: 12px; }
|
||||||
|
.summary-heading p { margin: 0 0 5px; color: var(--muted); }
|
||||||
|
.summary-heading h2 { margin: 0; font-size: 1.65rem; }
|
||||||
|
.summary-details { display: grid; grid-template-columns: repeat(3, 1fr); margin: 0; }
|
||||||
|
.summary-details div { padding-left: 22px; border-left: 1px solid var(--line); }
|
||||||
|
.summary-details dt, .system-strip span { margin-bottom: 7px; color: var(--muted); font-size: 0.8rem; }
|
||||||
|
.summary-details dd { margin: 0; font: 700 1.05rem/1.2 ui-monospace, "Cascadia Code", monospace; }
|
||||||
|
|
||||||
|
.metrics-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; }
|
||||||
|
.metric { min-width: 0; padding: 22px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||||
|
.metric-label { display: flex; align-items: center; gap: 10px; color: var(--muted); }
|
||||||
|
.metric-label svg { color: var(--accent); flex: none; }
|
||||||
|
.metric-value { display: block; overflow: hidden; margin: 25px 0 7px; font: 750 clamp(1.55rem, 2vw, 2.35rem)/1 ui-monospace, "Cascadia Code", monospace; letter-spacing: -0.06em; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.metric p { min-height: 2.6em; margin: 0; color: var(--muted); font-size: 0.83rem; line-height: 1.35; }
|
||||||
|
.meter { height: 3px; margin-top: 17px; background: var(--line); }
|
||||||
|
.meter span { display: block; height: 100%; background: var(--accent); }
|
||||||
|
.system-strip { display: grid; grid-template-columns: 1fr 1.4fr 1.2fr; gap: 32px; padding: 22px 28px; border: 1px solid var(--line); border-radius: var(--radius); }
|
||||||
|
.system-strip div { min-width: 0; display: grid; }
|
||||||
|
.system-strip strong { overflow: hidden; color: #d9dfd3; font-size: 0.88rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.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-title { display: flex; align-items: flex-start; gap: 18px; }
|
||||||
|
.settings-title h2, .compact-panel h2 { margin-bottom: 8px; font-size: 1.45rem; }
|
||||||
|
.settings-title p { max-width: 620px; margin: 0; color: var(--muted); line-height: 1.5; }
|
||||||
|
.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 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-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; }
|
||||||
|
.behavior-list { display: grid; gap: 24px; margin: 26px 0 0; }
|
||||||
|
.behavior-list div { display: grid; gap: 7px; }
|
||||||
|
.behavior-list dd { 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; }
|
||||||
|
.confirm-dialog p { color: var(--muted); line-height: 1.6; }
|
||||||
|
.dialog-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 28px; }
|
||||||
|
|
||||||
|
.skeleton-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
|
||||||
|
.skeleton-grid div { min-height: 210px; border-radius: var(--radius); background: linear-gradient(100deg, var(--surface) 30%, var(--surface-raised) 50%, var(--surface) 70%); background-size: 220% 100%; animation: shimmer 1.4s ease infinite; }
|
||||||
|
.skeleton-grid div:first-child { grid-column: 1 / -1; min-height: 150px; }
|
||||||
|
@keyframes shimmer { to { background-position-x: -220%; } }
|
||||||
|
.spin { animation: spin 800ms linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.app-shell { grid-template-columns: 86px minmax(0, 1fr); }
|
||||||
|
.brand-compact span, .nav-item span, .nav-item small { display: none; }
|
||||||
|
.brand-compact, .nav-item { justify-content: center; padding-left: 0; padding-right: 0; }
|
||||||
|
.health-summary { grid-template-columns: 1fr; gap: 24px; }
|
||||||
|
.metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.settings-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.account-layout { grid-template-columns: 1fr; }
|
||||||
|
.account-context { min-height: auto; gap: 64px; padding: 28px 22px 38px; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.account-context h1 { font-size: 3rem; }
|
||||||
|
.account-form-wrap { padding: 38px 22px; }
|
||||||
|
.app-shell { display: block; padding-bottom: 76px; }
|
||||||
|
.sidebar { position: fixed; top: auto; bottom: 0; z-index: 10; width: 100%; height: 72px; flex-direction: row; padding: 8px 12px; border-top: 1px solid var(--line); border-right: 0; }
|
||||||
|
.brand-compact { display: none; }
|
||||||
|
.sidebar nav { display: flex; flex: 1; }
|
||||||
|
.nav-item { width: 72px; min-height: 56px; }
|
||||||
|
.nav-item.logout { margin: 0 0 0 auto; }
|
||||||
|
.dashboard { padding: 26px 18px; }
|
||||||
|
.dashboard-header { align-items: flex-start; }
|
||||||
|
.connection-state { padding: 0; }
|
||||||
|
.connection-state:not(.offline) { font-size: 0; }
|
||||||
|
.secondary-button { width: 56px; padding: 0; font-size: 0; }
|
||||||
|
.health-summary { min-width: 0; padding: 22px; }
|
||||||
|
.summary-details { grid-template-columns: 1fr; gap: 16px; }
|
||||||
|
.summary-details div { padding: 0; border: 0; }
|
||||||
|
.metrics-grid, .system-strip { grid-template-columns: 1fr; }
|
||||||
|
.update-facts { grid-template-columns: 1fr; }
|
||||||
|
.settings-actions, .dialog-actions { flex-direction: column; }
|
||||||
|
.settings-actions button, .dialog-actions button { width: 100%; }
|
||||||
|
.skeleton-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:8787",
|
||||||
|
"/healthz": "http://127.0.0.1:8787"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user