diff --git a/.github/allowed_signers b/.github/allowed_signers new file mode 100644 index 0000000..9ffa7eb --- /dev/null +++ b/.github/allowed_signers @@ -0,0 +1,6 @@ +# VesselStack release signers for `git verify-tag` (ssh-keygen allowed_signers format). +# Release tags (vX.Y.Z) must be SSH-signed by the key below; release.yml refuses anything else. +# To rotate: add the new key line (and remove the old one only when retiring it) in a pull +# request to main, get it reviewed and merged, then create the next tag. This file must change +# only in a reviewed PR to main; the release workflow reads the copy at the tagged commit. +jc@linuxzone.org namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFHdnlLvuVxMDHUxoZg1Y1KHgevAzl7d2mU6tIGZx8mB VesselStack release signing diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5fe030..e068ae6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,8 @@ jobs: run: tests/test-socketcan.sh - name: Release package content checks run: tests/test-build-package.sh + - name: Release tag signature verification checks + run: tests/test-verify-release-tag.sh - name: Validate Compose configuration run: docker compose --env-file vesselstack.example.env -f templates/compose.yml config --quiet - name: Validate optional Compose profiles diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6eb1955..821ba69 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,11 @@ name: Release # Runs only when a version tag is pushed (never on pull requests or branch pushes). # 1. ci: the full CI workflow, reused unchanged, against the tagged commit. -# 2. build: read-only; checks the tag, builds the release files, uploads them as -# workflow artifacts. +# 2. build: read-only; checks the tag (VERSION match, commit on main, and an SSH +# signature from the release key in .github/allowed_signers, verified by +# scripts/verify-release-tag.sh), then builds the release files and uploads +# them as workflow artifacts. Unsigned, lightweight or wrongly signed tags +# fail before anything is built. # 3. publish: the only job with write access; creates a DRAFT release from those # artifacts. The draft is the authoritative release: test its assets, then # publish it by hand (see docs/DEVELOPMENT.md). @@ -44,6 +47,18 @@ jobs: echo "Tagged commit $GITHUB_SHA is not an ancestor of main; merge it first, then tag main." >&2 exit 1 } + - name: Tag must be an annotated tag SSH-signed by the release key + env: + TAG: ${{ github.ref_name }} + run: | + # actions/checkout can leave a lightweight ref pointing at the commit, which has + # no signature. Replace it with the real annotated tag object from origin. + git fetch --force --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}" + # The allowed signers file is the copy at the tagged commit, which the previous + # step requires to be on main (changes to it need a reviewed pull request). + # GITHUB_SHA is the commit checked out and built below; the tag must point at + # exactly it, and the tag object's own name must equal $TAG. + scripts/verify-release-tag.sh "$TAG" .github/allowed_signers "$GITHUB_SHA" - name: Build and verify release files run: | VESSELSTACK_BUILD_REQUIRE_CLEAN=true ./build-package.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index d703200..6ad6024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,51 +3,166 @@ All notable changes to VesselStack are documented here. The project follows Semantic Versioning. -## Unreleased - -- Docs: `README.md` is now a landing page with a table of contents and a task-to-document - table. Deep reference moved unchanged into `docs/` (`DISPLAYS.md`, `BOAT_CHAT.md`, - `CONTROL_PANEL.md`, `MQTT.md`, `NETWORK.md`, `UPDATING.md`; developer validation and - historical notes in `DEVELOPMENT.md`). Install, backup, removal and troubleshooting - stay in the README and its anchors are preserved. `tests/test-distribution.sh` now - checks the pinned ESPHome digest and version in README or `docs/DISPLAYS.md`. -- Displays: Home Assistant numeric items now round like instruments (half away - from zero, no `-0.0`, integer payload at precision 0, out-of-range values are - "missing"); a heading that rounds up to 360 shows 0; the board registry - validator rejects a board that lists a layout the firmware generator cannot - render (`list-6` stays defined but unoffered); `displays/README.md` lists - `preflight` and documents adding a board versus adding a layout. -- Installer env files (`vesselstack.env`, `boat-chat.env`, `control-panel.env`) - are written with portable quoting that bash, systemd, Python and Docker - Compose read identically; values with spaces, `$` or `!` no longer split or - expand. Values with an apostrophe plus a backslash, double quote, `$` or - backtick, and multi-line values, are refused. -- The installed `vesselstackctl` defaults to the configured `VESSELSTACK_ROOT`, - and `uninstall` also removes `vesselstack-update` and `deploy-chat.py`. -- Boat Chat and Control Panel token comparison handles non-ASCII header text; - `GET`/`HEAD /api/settings` require the access token; unauthenticated loopback - settings writes require a local `Host` name (DNS-rebinding defense). -- Power tracking splits sign-crossing intervals at zero and floors peak - inferred charge at 0 W. -- The VesselTwin preload now pins `signalk-vesseltwin` 0.2.3 (0.2.2 was never - published). An existing preloaded copy is never overwritten; existing boats - update through the Signal K Appstore. In Docker mode, a re-run that newly - preloads the plugin restarts the running `signalk` container (the helper exits - with status 10 when it installed something). `docs/VESSELTWIN.md` now lists - plugin enablement as its own step for preloaded and Appstore installs, explains - how to reach the admin page, and matches the plugin's status messages. -- The VesselTwin preload also lists `signalk-vesseltwin` in - `signalk/package.json` dependencies so Signal K App Store installs do not - prune it. -- Documentation corrections: README structure, version references, VesselTwin - wording, and historical 1.0 scope and validation labels. -- Documentation: the hardware guide now recommends the Copperhill/SK Pang - PiCAN-M as the NMEA 2000 HAT, with power guidance per Pi model and USB and - ESP32 alternatives. Galvanically isolated interfaces are listed first as - preferred (safest); the non-isolated PiCAN-M is documented as field-proven - with explicit grounding caveats. Wizard text (terminal and browser) now - states the same preferred/caveats framing. Installation docs add a PiCAN-M - `config.txt` example. +## 0.6.0 - 2026-11-02 + +### Highlights + +- Setup wizard: the "Nautical" units option is gone. It produced a configuration the installer + refused; the choices are now US customary (the default, matching the installer) and metric, + and knots and nautical miles are always kept. The web wizard asks for the Signal K address + when you choose "use existing" and has an untrusted-interface field when the firewall is on. + Back keeps your selections, Continue is blocked until the page has its address token, and + after saving it shows the next commands to run. It also gains dark mode and screen-reader + support. +- Control Panel: redesigned around four tabs (Home, Settings, Updates, Maintenance). Home shows + whether everything is running, the last backup and the backup drive, and whether an update is + known, with one **Back up now** button and the components with their real names. The result of + the latest operation stays in a dismissible banner with its log under Details, and every + stopping or interrupting action asks in an in-page dialog that lists what is affected. Settings + has a jump list, plain-language help for every field, Advanced sections that start collapsed, + inline field errors (timezone and numeric ranges are now validated), and one Save and Apply bar. + The Apply dialog now states what Apply does: it writes the saved settings into the service files, + running services keep running until their next restart, and only the displays bridge restarts. + The Maintenance restore hint now shows `sudo vesselstackctl restore --yes`. + The login accepts a pasted `CONTROL_PANEL_TOKEN=...` line and explains how to reach the panel. + Updates are checked only when you press **Check for updates**. Dark mode, "Signal K" naming and + accessibility improvements remain. Stored keys and values are unchanged, and no action's + behaviour or command changed (the old Install & start and Run preflight buttons are now + **Reinstall & start** and **Check setup** under Maintenance). +- Boat Chat: the credentials are now called "Access code" and "Admin code" in the interface + (the environment variable names are unchanged). Phones get a "More" menu, and the header and + overview status agree. Chat requests time out after 3 minutes, Clear is a two-step action, + notes and feedback use inline forms, and the trip report downloads as + `vesselstack-trip-.txt`. A unified brand palette, dark mode and screen-reader + announcements complete the update. +- Installer: every value written to the env files is checked first, before anything changes and + also under `--dry-run`, and the env files are written atomically. +- The README is a short landing page with a table of contents, a "What you need" hardware + list and a table that points each task to the right guide. Deep reference lives in `docs/` + (`DISPLAYS.md`, `BOAT_CHAT.md`, `CONTROL_PANEL.md`, `MQTT.md`, `NETWORK.md`, `UPDATING.md`; + developer validation, wizard internals and historical notes in `DEVELOPMENT.md`). Install, + backup, removal and troubleshooting stay in the README. The README sections that moved to + `docs/` are listed under "Moved sections" at its end, with links. +- Every quick start now uses the pinned release URL. GitHub's "Latest" release is still + `v0.4.4` (every 0.5.x release was a prerelease), so `releases/latest/download/` installs 0.4.4; + it is documented only as a secondary form. The documented default for reaching the Control + Panel is an SSH tunnel, with direct network access as an opt-in choice. +- The README gains an "Upgrading to 0.6.0" section: what changes for an existing boat, how the + installer's env-value check behaves, verification, and rollback. +- Hardware guide: the NMEA 2000 section recommends the Copperhill/SK Pang PiCAN-M as a + field-proven HAT, with power guidance per Pi model and USB and ESP32 alternatives. + Galvanically isolated interfaces are listed first as preferred (safest); the non-isolated + PiCAN-M is documented with explicit grounding caveats. The terminal and browser wizards say + the same, and the installation guide adds a PiCAN-M `config.txt` example. The optional + Waveshare ESP32-S3-Touch-LCD-2.8C display is listed in the bill of materials as a preview item. + +### Fixes + +- Control Panel no longer blanks a non-numeric saved boat length or beam on save. +- Boat Chat: a Settings save could blank the server configuration when settings had failed to + load. Settings no longer opens without loading, and it saves only the fields you changed. + Saves that blank `BOAT_CHAT_PROVIDER`, `BOAT_CHAT_MODEL`, `BOAT_CHAT_MAX_TOKENS`, + `BOAT_CHAT_CONTEXT_CHARS`, `BOAT_CHAT_HOST` or `BOAT_CHAT_PORT`, or give invalid numbers, are + rejected with a 400 (an explicit `null` resets the setting to its default). +- Boat Chat: the access code can no longer be cleared (blank or `null` for + `BOAT_CHAT_ACCESS_TOKEN` is rejected with a 400) unless `BOAT_CHAT_ALLOW_OPEN=true`, so an + API-only save cannot lock every client out. Set a new code instead. +- Control Panel: saving settings accepted any text for the timezone. The server now rejects a + timezone that is not in the system's timezone list (`Europe/Lisbon` is accepted, `Lisbon` is + not) with a 400 that names the field, and writes nothing. +- Control Panel: numeric settings accepted any number. The server now rejects a value that is + not a plain number (no units, signs or exponents), a decimal where a whole number is needed, + or a value outside the allowed range for that setting, with a 400 that names each offending + field. The page shows these messages beside the fields. +- Installer: an unsafe env value used to be refused only during `--start`, after services were + stopped and files replaced, and the installed `vesselstack.env` had already been truncated. + The check now runs first (see Security), and each env file is written to a temporary file in + the same directory (mode 0600) and renamed into place only when complete, so a failure leaves + the previous file intact. Ownership and permissions are unchanged. The installer also removes + stale `.vesselstack.env.*`, `.boat-chat.env.*` and `.control-panel.env.*` temp files (exact + `mktemp` names only) from `config/` left by a killed run, and the preflight now also checks + the preserved or generated secrets. +- Displays: Home Assistant numeric items round like instruments (half away from zero, no + `-0.0`, an integer payload at precision 0, out-of-range values count as missing). A heading + that rounds up to 360 shows 0. +- Installer env files (`vesselstack.env`, `boat-chat.env`, `control-panel.env`) are written with + quoting that bash, systemd, Python and Docker Compose read identically, so values with spaces, + `$` or `!` no longer split or expand. +- The installed `vesselstackctl` defaults to the configured `VESSELSTACK_ROOT`, and `uninstall` + also removes `vesselstack-update` and `deploy-chat.py`. +- Power tracking splits intervals that cross zero and floors peak inferred charge at 0 W. +- VesselTwin preload: the installer pins `signalk-vesseltwin` 0.2.3 (0.2.2 was never + published) and also lists it in `signalk/package.json` dependencies so Signal K App Store + installs do not prune it. An existing preloaded copy is never overwritten; existing boats update + through the Signal K App Store. `docs/VESSELTWIN.md` lists plugin enablement as its own step + for preloaded and App Store installs, explains how to reach the admin page, and matches the + plugin's status messages. +- Documentation corrections: README structure, version references, VesselTwin wording, the + historical 1.0 scope and validation labels, the screenshot captions (they show the optional + browser wizard), and the "signed release" wording, which is now "pinned release". + +### Security + +- `GET`, `HEAD` and `POST /api/settings` require the Boat Chat access code, and `POST` also + requires the admin code where one is configured. The new optional `X-Boat-Chat-Access-Token` + header carries the access code when it differs from the admin code, on `GET`, `HEAD` and + `POST`. +- Unauthenticated loopback settings writes require a local `Host` name (DNS-rebinding defense). +- Boat Chat and Control Panel token comparison handles non-ASCII header text. +- Env-file values that contain an apostrophe together with a backslash, double quote, `$` or + backtick, and multi-line values, are refused by the installer instead of being written in a + form some readers would misparse. A preflight checks every value for `vesselstack.env`, + `boat-chat.env` and `control-panel.env` before anything changes, including under + `--dry-run`, and names only the offending keys, never the values. +- Release tags must now be annotated tags SSH-signed by the release key in + `.github/allowed_signers`. The release workflow fetches the real tag object, runs + `scripts/verify-release-tag.sh` and fails on an unsigned, lightweight or wrongly signed tag + before anything is built. Changing the allowed key needs a reviewed pull request to `main`. +- Release tag verification also binds the tag's own name and the exact commit CI builds. + +### Upgrade notes + +- `install.sh --dry-run`, and so `vesselstackctl update` and the Control Panel updater, refuses + the unsafe env values above before the backup is taken or anything is changed, naming the key. + The fix is in the README under "Upgrading to 0.6.0". +- Docker Signal K may restart once: a re-run that newly preloads the VesselTwin plugin restarts + the running `signalk` container (the helper exits with status 10 when it installed something). +- Anything that read `/api/settings` without the access code now needs it, and scripts that call + `POST /api/settings` need the access code (plus the admin code where configured). Send the + access code in `X-Boat-Chat-Access-Token` when it differs from the admin code. +- Owners on 0.4.x: update once from the CLI with the pinned installer. The 0.5.0 Mosquitto access + list cuts off MQTT users that were added by hand to the old password file. +- Owners on 0.5.5 to 0.5.7 can use the Control Panel updater; while 0.6.0 is still a + pre-release, turn on the switch that lists prereleases (called "Include lab prereleases" in + the 0.5.x panel and "Include early test releases" in 0.6.0). + +### Rollback + +Downgrades are refused. Restore the verified pre-update backup that the update takes: +`sudo vesselstackctl restore /path/to/vesselstack-TIMESTAMP.tar.gz --yes`. See the README sections +"Updating" and "Backups". + +### Release status + +`v0.6.0` is an SSH-signed tag (Ed25519 release key, verified by the release workflow against `.github/allowed_signers`). It is published first as a pre-release and marked Latest after the maintainer's physical Raspberry Pi install passes. + +### For contributors + +- ShellCheck-clean at every severity on the CI runner's ShellCheck 0.9: `install.sh` stale-temp + cleanup and `scripts/verify-release-tag.sh` argument and signer checks use explicit `if` blocks + instead of `A && B || C` (no behavior change). +- `tests/test-distribution.sh` checks the pinned ESPHome digest and version in `README.md` or + `docs/DISPLAYS.md`. +- The board registry validator rejects a board that lists a layout the firmware generator cannot + render (`list-6` stays defined but unoffered); `displays/README.md` lists `preflight` and + documents adding a board versus adding a layout. +- In Docker mode the VesselTwin preload helper exits with status 10 when it installed something; + the installer uses that to restart the `signalk` container. +- Wizard internals moved from the README to `docs/DEVELOPMENT.md` ("Wizard internals"). +- Version bump: `VERSION` and `DEFAULT_VERSION` in `scripts/get-vesselstack.sh` are 0.6.0. +- `scripts/verify-release-tag.sh TAG [ALLOWED_SIGNERS_FILE [EXPECTED_COMMIT]]` verifies a release tag (annotated, + SSH-signed by an allowed principal) and prints the signer and key fingerprint; + `tests/test-verify-release-tag.sh` covers it with throwaway keys and runs in CI. ## 0.5.7 - 2026-11-01 diff --git a/README.md b/README.md index c3da586..bf7d293 100644 --- a/README.md +++ b/README.md @@ -9,34 +9,74 @@ the boat's local network when the internet is unavailable. ## Contents -- [Where to go next](#where-to-go-next) -- [What you get](#what-you-get) -- [Optional integrations](#optional-integrations): VesselTwin and instrument displays -- [Quick start for an internet-connected Pi](#quick-start-for-an-internet-connected-pi) -- [Quick start: one command on the Raspberry Pi](#quick-start-one-command-on-the-raspberry-pi) and [manual guided installation](#advanced-manual-guided-installation) +- [What it looks like](#what-it-looks-like), [what you need](#what-you-need) and [where to go next](#where-to-go-next) +- [What you get](#what-you-get) and [optional integrations](#optional-integrations): VesselTwin and instrument displays +- [Quick start: one command on the Raspberry Pi](#quick-start-one-command-on-the-raspberry-pi), [before and after installation](#before-and-after-installation) and [manual guided installation](#advanced-manual-guided-installation) +- [Upgrading to 0.6.0](#upgrading-to-060) - [First-run setup](#9-complete-first-run-setup): Home Assistant, Signal K, InfluxDB, Grafana, Boat Chat, Control Panel - [MQTT access control](#mqtt-access-control), [network access](#reaching-vesselstack-from-other-devices) and [network security](#network-security) - [Backups](#backups), [Updating](#updating), [Stopping or removing services](#stopping-or-removing-services) - [Troubleshooting](#troubleshooting) -- [Developer validation](#developer-validation), [Documentation policy](#documentation-policy), [License and security](#license-and-security) +- [Developer validation](#developer-validation), [Documentation policy](#documentation-policy), [License and security](#license-and-security), [Moved sections](#moved-sections) + +## What it looks like + +![Control Panel Home: services, backup and software status, one Back up now button and the component cards](docs/images/control-panel-home.png) + +*The Control Panel Home tab shows whether everything is running, the last backup and +whether an update is known. [Control Panel guide](docs/CONTROL_PANEL.md).* + +![Boat Chat Overview: boat condition, observations, what needs attention and suggested actions](docs/images/boat-chat-overview.png) + +*Boat Chat answers questions from your boat's own recorded data. Its Overview shows +condition and what needs attention. [Boat Chat guide](docs/BOAT_CHAT.md).* + +![Boat Chat on a phone in dark mode](docs/images/boat-chat-phone-dark.png) + +*Both web apps work on a phone and follow the device's light or dark setting.* + +## What you need + +The reference build, from [docs/BOM.md](docs/BOM.md): + +- Raspberry Pi 5 (8 GB; 4 GB is the minimum) with the Raspberry Pi Active Cooler. +- A 256 GB or larger high-endurance USB 3 SSD for the data disk. +- A dedicated, fused 12 V DC branch feeding an isolated or regulated marine + supply that delivers a stable 5 V / 5 A to the Pi (the BOM allows a 10-32 V + DC input). +- An NMEA 2000 interface, if you want boat data from the bus. A galvanically + isolated interface is preferred; the PiCAN-M is field-proven on a boat but is + not isolated and comes with grounding caveats. +- A separate backup drive, mounted outside the data disk. +- Optional: one Waveshare ESP32-S3-Touch-LCD-2.8C instrument display (a preview + feature, see [Instrument displays](docs/DISPLAYS.md)). + +A Raspberry Pi 4 also works, and a Debian or Ubuntu host with systemd is +supported. Setup needs the internet once; afterwards the stack runs on the boat +network without it. ## Where to go next | I want to… | Read | |---|---| | Understand the hardware and buy parts | [Hardware bill of materials](docs/BOM.md) | -| Install on a new or existing Pi | [Quick start](#quick-start-one-command-on-the-raspberry-pi), then [Installation](docs/INSTALLATION.md) | +| Build a new Pi | [Hardware bill of materials](docs/BOM.md), then [Installation](docs/INSTALLATION.md) | +| Add VesselStack to an existing Pi | [Quick start](#quick-start-one-command-on-the-raspberry-pi), then [Getting started](docs/GETTING_STARTED.md) | | Do first boot, SSH and token steps | [Getting started](docs/GETTING_STARTED.md) | | Use or secure the Control Panel | [Control Panel](docs/CONTROL_PANEL.md) | | Set up Boat Chat, units and timezone | [Boat Chat setup](docs/BOAT_CHAT.md), providers in [Boat Chat providers](boat-chat/README.md) | | Reach dashboards from other devices | [Network access and security](docs/NETWORK.md) | | Manage MQTT accounts and access lists | [MQTT access control](docs/MQTT.md) | -| Update, roll back, or install offline | [Updating and offline install](docs/UPDATING.md), [Deployment runbook](docs/DEPLOYMENT.md) | +| Update, roll back, or install offline | [Updating](#updating), [Updating and offline install](docs/UPDATING.md) | +| Release to a lab Pi and an offline boat | [Deployment runbook](docs/DEPLOYMENT.md) | | Connect VesselTwin | [VesselTwin integration](docs/VESSELTWIN.md) | | Add ESP32 instrument displays | [Instrument displays (preview)](docs/DISPLAYS.md) | | Develop, test, or cut a release | [Development guide](docs/DEVELOPMENT.md) | | See every document | [Documentation index](docs/README.md) | +Power sources and instrument-reading coverage for Boat Chat are in +[Power monitoring](boat-chat/POWER_MONITORING.md). + ## What you get - A local view of boat data from Signal K-compatible sources and supported NMEA @@ -87,7 +127,7 @@ These features are off or inert until you deliberately enable them. Neither is n ### Optional VesselTwin connection An optional VesselTwin connection uses the `signalk-vesseltwin` plugin, pairing, -and explicit reading opt-ins. Managed Docker/native SignalK modes preload the pinned +and explicit reading opt-ins. Managed Docker/native Signal K modes preload the pinned plugin by default (0.5.x lab prerelease); VesselStack does not enable, pair, or authorize readings for you. See the [VesselTwin integration guide](docs/VESSELTWIN.md) for setup and [VesselTwin](https://www.vesseltwin.io/) for maintenance documents, @@ -108,254 +148,77 @@ sudo vesselstackctl display status Lifecycle commands are `vesselstackctl display list|add|remove|rotate|wifi|status|render|export|build|flash`. See [Instrument displays (preview)](docs/DISPLAYS.md) for setup, firmware, layouts, the pinned ESPHome version and limitations, and [displays/README.md](displays/README.md) for the code layout. -## Choose your path - -| If you are… | Start here | -|---|---| -| Building a new Pi | [Hardware bill of materials](docs/BOM.md), then the [installation guide](docs/INSTALLATION.md) | -| Adding VesselStack to an existing Pi | [Getting started](docs/GETTING_STARTED.md), then [installation](docs/INSTALLATION.md) | -| Releasing to a lab and an offline boat | [Deployment runbook](docs/DEPLOYMENT.md) | -| Developing or validating changes | [Development guide](docs/DEVELOPMENT.md) | - -## Quick start for an internet-connected Pi +## Quick start: one command on the Raspberry Pi -The normal first install is headless: the Pi needs no screen, keyboard, or -browser. From your laptop, SSH to the Pi with a terminal allocation, then run -the release installer and answer its short questions: +**Headless is the normal case: the Pi needs no screen, keyboard or browser.** On a +Raspberry Pi running 64-bit Raspberry Pi OS (or Debian) with internet access, SSH into +it from your laptop (use `ssh -t` so the installer can ask its questions on that +session), run the pinned release installer and answer the questions in the same terminal: ```bash ssh -t YOUR_USER@raspberrypi.local -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh | sudo bash +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash ``` -The installer checks the host before changing it. It may install Debian -prerequisites and, after asking, Docker Engine and Compose v2 from Docker's -official apt repository. It then downloads and checksum-verifies the release, -asks for boat and service settings, and offers a final review before starting -VesselStack. `sudo` is required because installation writes system packages, -services, and `/opt/vesselstack`; the questions explain each change. Secrets -are generated or entered without echo and are not printed in the review. - -The command above installs the latest stable release. For the `0.5.7` lab -prerelease, follow the pinned-release instructions in the -[installation guide](docs/INSTALLATION.md). -For a boat with no internet, prepare the release and offline bundle in the lab -and follow the [offline deployment path](docs/DEPLOYMENT.md). - -## Before and after installation - -Before installation, decide where the Pi will live, how it will be powered, -and whether its data disk and backups are separate from the boot media. If you -connect to NMEA 2000, confirm the bus is correctly powered and terminated and -that you have chosen the interface and its power source deliberately -(an isolated interface is preferred (safest); a non-isolated one such as the -PiCAN-M works with caveats, see docs/BOM.md). Commission on the bench first. - -After installation, use the printed URLs from a trusted local device, run -`sudo vesselstackctl doctor`, and confirm the dashboards show the signals you expect. -Keep the generated credentials per host, schedule backups to a separate device, -and test restoring one before relying on the system. Read the deployment guide -before carrying an update to a boat without internet access. - -### Update from the Control Panel (0.5.7 lab release) - -An authenticated Control Panel session can check releases from the public -`jclima/VesselStack` repository. Stable releases are shown by default; enable -**Include lab prereleases** to include lab releases in the list. The panel shows -the installed/running version, release channel, -release notes, and a link to the release; it does not display a checksum value. -It accepts no arbitrary URL or filesystem path and does -not let the browser override the backup destination. - -Before an update, mount the configured external backup device under `/media` or -`/mnt` and confirm the displayed path is the one you intend. The panel's **Back -up & install** flow -asks for confirmation, verifies the downloaded SHA-256 manifest, safely extracts -the release, runs preflight, then makes and verifies the backup. A failed or -unmounted backup stops the update; the backup path and bounded operation log -remain available for recovery. There is no automatic rollback promise: use the -explicit CLI `vesselstackctl verify-backup` and `vesselstackctl restore` path in -the deployment guide when recovery is needed. `--require-mounted` defeats -`ALLOW_UNMOUNTED` for this flow. - -The detached updater restarts the Control Panel and verifies its new version and -boot before the browser reconnects. Settings also provides a standalone -**Restart Control Panel** action. The panel uses ordinary HTTP; release -checksums provide corruption detection, not signed provenance, and the 0.5.7 -lab tag is explicitly unsigned and not latest. See the full reference below for -backup, recovery, and update details. - -The browser updater (0.5.x lab prerelease) was introduced in `0.5.5`. Installations on `0.5.4` or -earlier must use the CLI installer/update once; installations on `0.5.5` or -`0.5.6` can upgrade to `0.5.7` through the Control Panel with lab prereleases -enabled. -The panel serializes its own update/configuration/component operations, but -unrelated CLI commands are separate maintenance actions and are not globally -serialized by the panel; do not run them concurrently with an update. - -The strict backup flag requires a mounted external destination under `/media` -or `/mnt`; browser input cannot override that destination. The transient -updater job, status, and staging files under `/var/lib/vesselstack` are -intentionally excluded from backups; user configuration and data are backed up. -If an update fails, the verified pre-update backup is kept for explicit -`vesselstackctl verify-backup`/`restore` recovery. The browser does not promise -automatic restoration. - -## Find your next answer - -The [documentation index](docs/README.md) maps common tasks to the right guide, -including first boot, access, offline operation, troubleshooting, privacy, and -contribution. This README keeps the install steps, backups, removal and -troubleshooting; deeper reference lives in `docs/`. For Boat Chat provider -configuration see [Boat Chat providers](boat-chat/README.md); for first-run access, settings, -timezone and units see [Boat Chat setup](docs/BOAT_CHAT.md); for power sources and -instrument-reading coverage see [Power monitoring](boat-chat/POWER_MONITORING.md). - -## Quick start: one command on the Raspberry Pi - -**Docker is installed for you.** If Docker Engine with the Compose v2 plugin is missing, -the script asks once and then follows Docker's official apt-repository instructions -(; the Ubuntu page for Ubuntu): - -- it checks the system first: 64-bit Raspberry Pi OS, Debian 12 (bookworm) or 13 - (trixie), or Ubuntu; **32-bit Raspberry Pi OS (armhf) is refused** (see below); -- it downloads Docker's signing key over HTTPS, checks that the file holds exactly - one key and that its fingerprint is `9DC8 5822 9FC7 DD38 854A E2D8 8D81 803C 0EBF CD88`, - and stops without changing anything if it does not match; -- it installs the key as `/etc/apt/keyrings/docker.asc`, writes - `/etc/apt/sources.list.d/docker.sources` (Docker's deb822 entry for your release and - CPU), runs `apt-get update` and installs `docker-ce docker-ce-cli containerd.io - docker-buildx-plugin docker-compose-plugin`; it enables and starts the `docker` - service and verifies `docker --version`, `docker compose version` (v2 or newer) and - `docker info` (nothing is pulled); -- if Docker is already installed but the Compose plugin is missing, only - `docker-compose-plugin` is added from the same repository; -- if Docker and Compose v2 are already present, it does nothing to Docker; -- unofficial packages that conflict with Docker's own (`docker.io`, `docker-cli`, - `docker-compose`, `docker-compose-v2`, `docker-doc`, `docker-buildx`, - `podman-docker`, `containerd`, `runc`) are **never removed silently**: the script - lists the ones that are installed (plus any dependent packages apt would remove with - them, taken from `apt-get -s remove`) and asks; `--yes` accepts. They are removed with - `apt-get remove` (not purge), so `/var/lib/docker` is kept. If a Docker daemon - already holds containers or volumes, `--yes` is not enough: the script stops, and you - must confirm interactively (back up first) or remove the packages yourself; -- an older `docker.list` (the manual steps of v0.4.0) or any other apt source that - already points at `download.docker.com` is renamed to `*.vesselstack-saved` after - asking, because two entries with different keys make `apt-get update` fail; -- no `docker` group change is made: the installer runs as root and VesselStack's - service account does not need Docker access; -- `--offline` cannot install Docker; install it first while online. - -If a step fails, the script prints the failing command and what it left behind; -re-running is safe. **32-bit Raspberry Pi OS:** Docker Engine v28 was the last release -for it, so VesselStack needs 64-bit Raspberry Pi OS. Re-flash the card or SSD with -"Raspberry Pi OS (64-bit)" in Raspberry Pi Imager (Pi 3, 4, 5 and Zero 2 W support it); -`dpkg --print-architecture` must say `arm64` (`uname -m` can say `aarch64` on a 32-bit -system running a 64-bit kernel). - -To undo the Docker part (your containers and data stay unless you delete them): +The same release is also selected by `VESSELSTACK_VERSION=0.6.0` or `--version 0.6.0`: ```bash -sudo apt-get remove docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -sudo rm -f /etc/apt/sources.list.d/docker.sources /etc/apt/keyrings/docker.asc -sudo apt-get update -# optional, deletes ALL images, containers and volumes: sudo rm -rf /var/lib/docker /var/lib/containerd +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo VESSELSTACK_VERSION=0.6.0 bash +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash -s -- --version 0.6.0 ``` -**Headless is the normal case: the Pi needs no screen, keyboard or browser.** On a -Raspberry Pi running 64-bit Raspberry Pi OS (or Debian) with internet access, SSH into -it from your laptop (use `ssh -t` so the installer can ask its questions on that -session), paste this one line and answer the questions in the same terminal: +> [!NOTE] +> The pinned URL names the release explicitly, so it installs exactly 0.6.0 whatever +> GitHub marks as Latest. The form `https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh` +> points to whichever release GitHub marks Latest, which is not necessarily the newest +> one (every 0.5.x release was published as a prerelease, so that URL kept serving +> 0.4.4). Use it only when you want exactly that. -```bash -ssh -t YOUR_USER@raspberrypi.local -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh | sudo bash -``` - -(Or paste the second line into a terminal on the Pi itself. No address is opened and no +The installer checks the host before changing it. It may install Debian +prerequisites and, after asking, Docker Engine and Compose v2 from Docker's +official apt repository (signing-key fingerprint checked; details and undo steps are in +[the installation guide](docs/INSTALLATION.md#quick-path-one-command)). It then downloads and +checksum-verifies the release, asks for boat and service settings, and offers a final review +before starting VesselStack. `sudo` is required because installation writes system packages, +services, and `/opt/vesselstack`; the questions explain each change. Secrets are generated or +entered without echo and are not printed in the review. **32-bit Raspberry Pi OS (armhf) is +refused**: Docker Engine v28 was the last release for it. Re-flash with "Raspberry Pi OS +(64-bit)" in Raspberry Pi Imager (VesselStack supports Raspberry Pi 4 and 5); +`dpkg --print-architecture` must say `arm64`. + +(Or paste the command into a terminal on the Pi itself. No address is opened and no tunnel is needed.) The questions are grouped in short sections, each with a plain-language explanation and a sensible default in `[brackets]`: **Enter** accepts the default, **?** explains the question, **b** goes back one question, and a final review screen lists every -choice (never any secret) with *save*, *edit a section* and *quit*. Example, abridged: - -```text -== Step 5 of 6: Configure == - Starting the setup questions. Press Enter to accept the answer in [brackets]; ? explains a question. - -VesselStack setup. Press Enter to accept the answer in [brackets]. ? explains a question, b goes back one question. -Everything stays on this computer; no secrets are shown or printed. -This computer: aarch64, 7.6 GiB RAM, Docker ready; CAN: no can0 yet; network: eth0, wlan0. - -[1/9] Your boat - Names shown in dashboards and Boat Chat. Everything stays on this Raspberry Pi. - Boat name [My Boat]: Sea Wolf - Boat type [Motor vessel]: Sailing yacht - 1) metric metres, km/h, degrees C - 2) us_customary feet, mph, degrees F - 3) nautical knots, nautical miles, metres/fathoms - Units - choose 1-3 [metric]: 3 - Detected from this computer: Europe/Oslo - Timezone [Europe/Oslo]: - Add optional vessel details (MMSI, call sign, length, beam)? [y/N]: - -[2/9] Signal K (the boat data hub) - How should Signal K run? - choose 1-3 [docker]: - -[3/9] NMEA 2000 (CAN bus) - No can0 interface found yet (normal before the CAN HAT overlay is installed). You can enable it later. - Enable SocketCAN / NMEA 2000? [y/N]: ? - An isolated CAN interface is preferred (safest); a non-isolated one such as the PiCAN-M works with caveats (see docs/BOM.md). Needs a powered and terminated backbone and the can0 device present. ... - Enable SocketCAN / NMEA 2000? [y/N]: -... -Advanced settings (service account, folders, ports, container and bucket names, passwords)? [y/N]: - -Review (secrets are never shown): - 1. Your boat - Boat name Sea Wolf - Units nautical - ... - [s] save and continue [e] edit a section [q] quit without saving - choose [s]: - - Install and start VesselStack now? [Y/n]: -... - Addresses (for information; use them from any computer on the boat network when you like): - Grafana (history dashboards) http://192.0.2.10:43000 - Home Assistant http://192.0.2.10:8123 - Boat Chat http://192.0.2.10:8765 - Show the Boat Chat access token (it is printed only when you run this): - sudo grep '^BOAT_CHAT_ACCESS_TOKEN=' /opt/vesselstack/config/boat-chat.env - Control panel (administration; it listens on this Pi only, port 8780). From your own computer: - ssh -L 8780:127.0.0.1:8780 YOUR_USER@192.0.2.10 -``` +choice (never any secret) with *save*, *edit a section* and *quit*. A short example is in +[the installation guide](docs/INSTALLATION.md#quick-path-one-command). At the end the +installer prints the Grafana, Home Assistant and Boat Chat addresses, the command that shows +the Boat Chat access token, and the SSH tunnel command for the Control Panel. Everything +printed at the end is information only: nothing in the install needs a browser, and nothing is +opened for you. -Everything printed at the end is information only: nothing in the install needs a -browser, and nothing is opened for you. - -The `releases/latest/download/` form works only once a release carrying -`get-vesselstack.sh` has been published **and marked latest**. Until then (and while a -release is still being install-tested) use the pinned form shown next, which works for -any published release, including a pre-release. +For a boat with no internet, prepare the release and offline bundle in the lab and follow the +[offline deployment path](docs/DEPLOYMENT.md#simple-path-one-command). Releases are built by the tag-triggered GitHub workflow (read-only build job, separate write-only publish job, draft first, reproducible tarball on Linux); maintainers follow the -release checklist in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). - -To install an exact release instead (recommended once you have tested one): - -```bash -curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.5.7/get-vesselstack.sh | sudo bash -# or: curl -fsSL .../get-vesselstack.sh | sudo VESSELSTACK_VERSION=0.5.7 bash -# or: curl -fsSL .../get-vesselstack.sh | sudo bash -s -- --version 0.5.7 -``` +release checklist in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#release-checklist). The recommended +way to sign release tags (key held in 1Password) is in +[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#1-release-signing-with-an-ssh-signing-key). +Release tags must be SSH-signed annotated tags: the release workflow runs +`scripts/verify-release-tag.sh` against `.github/allowed_signers` and refuses an unsigned, +lightweight or wrongly signed tag, a tag object whose own name differs from its ref, and a tag +that does not point at the exact commit being built, before building anything. To rotate the release key, change +`.github/allowed_signers` in a reviewed pull request to `main` first, then tag. **No prompts at all** (scripts, a Pi without a terminal, repeatable installs): ```bash # defaults, plus the settings you name (repeat --set; names as in vesselstack.example.env) -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh \ +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh \ | sudo bash -s -- --yes --set BOAT_NAME=Foo --set SOCKETCAN_ENABLE=true # every question's own default answer, nothing asked -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh | sudo bash -s -- --defaults +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash -s -- --defaults # a ready-made vesselstack.env (optionally with --set overrides) sudo bash get-vesselstack.sh --yes --config ./vesselstack.env --set BOAT_NAME=Foo ``` @@ -376,14 +239,20 @@ turn on SSH. Boot the Pi, then connect once from your laptop with `ssh -t YOUR_USER@YOUR_PI.local` (use the Pi's router-assigned IP if the name does not resolve). You do not need a screen, keyboard or SSH tunnel on the Pi. -Run the guided installer above in that terminal. In **Who can reach what**, choose -**Trusted network — token required** for the Control Panel. This choice is separate -from the dashboard preset: choose **local** for the dashboards if you want their -managed listeners kept on the Pi. The installer prints the panel's real address, -such as `http://YOUR_PI.local:8780`, and the command to retrieve its unlock token. -Open that address in your laptop's browser and paste the token into **Unlock**. +Run the guided installer above in that terminal. In **Who can reach what**, the +question **Control panel listens on** defaults to **this Pi only (SSH tunnel)**. Keep +that answer: it is the recommended setting. The installer ends by printing the tunnel +command; run it on your own computer, open `http://127.0.0.1:8780`, and paste the +panel token into **Unlock**: + +```bash +ssh -L 8780:127.0.0.1:8780 YOUR_USER@YOUR_PI.local +sudo grep '^CONTROL_PANEL_TOKEN=' /opt/vesselstack/config/control-panel.env +``` -For an existing installation with this command available: +Reaching the panel directly over the boat network is an opt-in choice for a trusted +lab or boat LAN: answer **trusted network (token required)** instead, or apply it to an +installed system with this command and use the address it prints: ```bash sudo vesselstackctl panel-access lan @@ -411,7 +280,7 @@ Network access binds the panel to **all IPv4 interfaces**. Use it only on a trus lab or boat network: its token is still required, but ordinary HTTP does not encrypt administration traffic. Do not forward this port from the internet. An enabled strict firewall may need an explicit rule for the panel port on the relevant -interface; setup never silently opens firewall ports. Local-only remains the default. +interface; setup never silently opens firewall ports. Local-only (SSH tunnel) is the default and the recommendation. The guided installer explains each step in plain language and asks before it does anything beyond installing ordinary Debian packages: @@ -434,46 +303,22 @@ does anything beyond installing ordinary Debian packages: 4. asks the configuration questions **in the terminal** (the terminal wizard, `vesselstack-wizard.py --cli`, the default). It reads your answers from the terminal (`/dev/tty`, so the piped `curl | sudo bash` form works), validates every answer - immediately with the same rules as the browser wizard (`DEFAULTS`, `validate()`, - `render()`; stricter checks only where `install.sh` enforces the same limits, such as - the boat name characters), and offers sections for the boat (name, type, units, timezone - detected from the system, optional and private MMSI, call sign, length and beam), - Signal K (docker, existing or native; docker recommended), NMEA 2000 / CAN - (`SOCKETCAN_ENABLE`, interface, bit rate and the home-lab virtual-CAN option - `VESSELSTACK_CAN_CREATE_VCAN`), AIS, Home Assistant (URL, optional hidden token), Boat - Chat provider (default `local`) and Telegram, the firewall (untrusted interface, enable, - strict mode, allowed ports), raw-data retention (`INFLUXDB_RAW_RETENTION`; empty means - 30 days on a new install and no change to an existing database) and who can reach the - dashboards (`local`, `dashboards` or `all`; see "Reaching VesselStack from other devices"). - Ports, container and bucket names, folders, the service account and the generated - secrets sit behind "Advanced settings? [y/N]". Passwords and tokens default to being - generated during installation, are read without echo when you do type one and are - never printed or logged. It detects the architecture, Docker, `can0` and the network - interface names to inform defaults and hints; it never turns hardware on by itself. - The browser wizard asks the same "Who should be able to reach the dashboards?" question - (`local`, `dashboards` default, `all`) on its network step. The page sends only the - preset name (`LAN_ACCESS`); the server validates it (an unknown name is refused with HTTP 400, - nothing is written) and expands it with the terminal wizard's `LAN_PRESETS` mapping - into `VESSELSTACK_WEB_BIND_ADDRESS`, `VESSELSTACK_DATA_BIND_ADDRESS`, `BOAT_CHAT_HOST` and `HOMEASSISTANT_BIND_ADDRESS`; - the preset wins over explicit bind values, `LAN_ACCESS` is never written to the env file - and the independent Control Panel access choice is preserved. Choosing `all` shows the warning that Prometheus - has no login and recommends the firewall. The loopback, Host/Origin, URL-fragment token - and content-type protections are unchanged. - The terminal wizard has no network surface (no web server, no token, no URL), so it - runs as root directly, in a private root-only `0700` directory; its output is then - treated exactly like the browser wizard's: copied once into the root-only staging - directory, linted, re-validated as root with `vesselstack-wizard.py --validate FILE` - (known settings only, allowed characters, enum and boolean values) and installed - root-only, mode 600. `--config FILE` gets the same treatment (opened without following - symlinks, a regular file of at most 64 KiB, copied once, only the staged copy linted, - validated and used); with `--set` the staged copy is the base the values are applied - to. Optional browser wizard: add `--web-wizard`; it is unchanged (loopback only on a - random free port, `--wizard-port N`, run as an unprivileged user or the temporary - `vesselstack-wizard` system account, a `#token=` address printed once, open on the Pi - or through the printed `ssh -L` tunnel) and cannot be combined with `--yes`-style - configuration options (`--config`, `--set`, `--defaults`). Limitation: the Docker line - in a wizard's preflight reflects what that wizard sees; `install.sh` performs the - authoritative checks; + immediately, and offers sections for the boat (name, type, units, timezone detected + from the system, optional and private MMSI, call sign, length and beam), Signal K + (docker, existing or native; docker recommended), NMEA 2000 / CAN (`SOCKETCAN_ENABLE`, + interface, bit rate and the home-lab virtual-CAN option `VESSELSTACK_CAN_CREATE_VCAN`), + AIS, Home Assistant (URL, optional hidden token), Boat Chat provider (default `local`) + and Telegram, the firewall (untrusted interface, enable, strict mode, allowed ports), + raw-data retention (`INFLUXDB_RAW_RETENTION`; empty means 30 days on a new install and no + change to an existing database) and who can reach the dashboards (`local`, `dashboards` + or `all`; see "Reaching VesselStack from other devices"). Ports, container and bucket + names, folders, the service account and the generated secrets sit behind "Advanced + settings? [y/N]". Passwords and tokens default to being generated during installation, are + read without echo when you do type one and are never printed or logged. It never turns + hardware on by itself. The terminal wizard has no network surface. The optional browser + wizard (`--web-wizard`) serves loopback only and cannot be combined with `--config`, + `--set` or `--defaults`. How the wizards validate, stage and install the file is described + in [Development: wizard internals](docs/DEVELOPMENT.md#wizard-internals); 5. shows a summary of the chosen configuration (no secrets), asks for confirmation, runs `install.sh --dry-run`, then `install.sh --start`, then `vesselstackctl doctor`, and prints `vesselstackctl urls` plus, as plain text, the service @@ -512,7 +357,9 @@ script is wrapped in functions and ends with a single `main "$@"` call, so a truncated download cannot half-run. If you prefer to read it first (recommended): ```bash -curl -fsSLO https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh +curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh +curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh.sha256 +sha256sum -c get-vesselstack.sh.sha256 less get-vesselstack.sh sudo bash get-vesselstack.sh ``` @@ -533,42 +380,138 @@ from the end-to-end guide: ```bash git clone https://github.com/jclima/VesselStack.git cd VesselStack -git checkout v0.5.7 +git checkout v0.6.0 python3 vesselstack-wizard.py --cli # terminal questions (no browser) -# python3 vesselstack-wizard.py # or the browser wizard +# python3 vesselstack-wizard.py # or the optional browser wizard ``` The terminal wizard (`--cli`) asks in the terminal and writes `vesselstack.env`; add -`--defaults` and `--set KEY=VALUE` to configure without questions, `--base FILE` to start from an -existing file. For the browser wizard open the address it prints (`http://127.0.0.1:8088/#token=...`), or use the -SSH tunnel documented in the guide and open the same address on your workstation. -The wizard accepts `--port N`, `--output FILE` (where to write the env file) and -`--exit-after-save` (stop after the first successful save; the one-line installer -uses it) and `--validate FILE` (check an existing env file with the wizard's rules, -exit 0 if valid, 1 otherwise, no server started; `VESSELSTACK_CAN_CREATE_VCAN` is now a -known wizard setting, default `false`). Rollback: revert this change; nothing is -persisted except the temporary account, which is removed on exit (`sudo userdel -vesselstack-wizard` if a run was killed with SIGKILL). It prints a per-run address of the form `http://127.0.0.1:PORT/#token=...`; open -that exact address. The token is carried in the URL fragment (never sent to the -server, never present in any served file); `wizard/app.js` reads it, removes it -from the address bar with `history.replaceState` and sends it as `X-Wizard-Token` -with every API call (`/api/system` and `/api/save` refuse requests without it). -The wizard needs no root privileges; run it as a normal user. Requests are refused unless the `Host` -header is `127.0.0.1`/`localhost`/`[::1]` on the bound port, any `Origin` is the -same loopback origin, the token matches, and saves use `Content-Type: -application/json` (this blocks cross-site form posts and DNS rebinding). The env -file is created mode 600 from the start and never through a symlink. Open the -printed URL itself (including the `#token=` part); a bookmarked older address stops working when the wizard restarts. The dependency-free wizard binds only to loopback, shows relevant host -prerequisites, and writes a complete private `vesselstack.env`. It does not -change boot firmware, firewall rules, marine wiring, or start services. Then run -`sudo ./install.sh --config vesselstack.env --dry-run` and `--start` as described -in the sections below. +`--defaults` and `--set KEY=VALUE` to configure without questions, `--base FILE` to start +from an existing file, and `--validate FILE` to check an existing env file with the wizard's +rules (exit 0 if valid, 1 otherwise, no server started). The browser wizard walks seven steps +(Preflight, Hardware, Vessel, Storage, Signal K, Network, Review); Back keeps your choices and +Continue stays blocked until the page has its address token. It prints a per-run +loopback address of the form `http://127.0.0.1:PORT/#token=...`; open that exact address, on +the Pi or through an `ssh -L` tunnel. Both wizards write a private `vesselstack.env` and +change nothing else: no boot firmware, firewall rules, marine wiring or services. The browser +wizard's options, token handling and request protections are described in +[Development: wizard internals](docs/DEVELOPMENT.md#wizard-internals). When the browser wizard +saves the file, its last page shows the commands to run next (a `--dry-run` check, then +`--start`, then `sudo vesselstackctl doctor`) and the wizard may then stop by itself, which is +expected. Run `sudo ./install.sh --config vesselstack.env --dry-run` and `--start` as described +in the sections below. The pages are shown with screenshots in [Getting started](docs/GETTING_STARTED.md#the-optional-browser-wizard). + +Rollback: delete the generated `vesselstack.env`; if a browser-wizard run was killed with +SIGKILL, remove its temporary account with `sudo userdel vesselstack-wizard`. > [!WARNING] > VesselStack is not certified navigation, collision-avoidance, engine-control, > alarm, or life-safety equipment. Maintain independent marine instruments and > alarms. Never depend on this software as the sole source of safety data. +## Before and after installation + +Before installation, decide where the Pi will live, how it will be powered, +and whether its data disk and backups are separate from the boot media. If you +connect to NMEA 2000, confirm the bus is correctly powered and terminated and +that you have chosen the interface and its power source deliberately +(an isolated interface is preferred (safest); a non-isolated one such as the +PiCAN-M works with caveats, see docs/BOM.md). Commission on the bench first. + +After installation, use the printed URLs from a trusted local device, run +`sudo vesselstackctl doctor`, and confirm the dashboards show the signals you expect. +Keep the generated credentials per host, schedule backups to a separate device, +and test restoring one before relying on the system. Read the deployment guide +before carrying an update to a boat without internet access. + +## Upgrading to 0.6.0 + +Read this before updating an installed system. Update from [Updating](#updating) with a backup +on a mounted external drive, not while underway. + +**What changes for an existing boat** + +- **Env-file values are checked more strictly.** The installer refuses a value that holds an + apostrophe together with a backslash, a double quote, a dollar sign or a backtick, and any + multi-line value, in the settings it writes to `vesselstack.env`, `boat-chat.env` and + `control-panel.env`. A boat name such as `Skipper's "Dream"` is affected; `Skipper's Dream` + is not. The check now runs first: before anything is changed, and also under `--dry-run`, and + it covers preserved and generated secrets too. The installer also deletes stale + `.vesselstack.env.*`, `.boat-chat.env.*` and `.control-panel.env.*` temp files from `config/` + (left by a killed run) before writing; no other file is touched. +- **`GET`, `HEAD` and `POST /api/settings` require the Boat Chat access code.** A bookmark, + script or dashboard that read or saved settings without it now receives an authentication + error. Where an admin code is configured, `POST` needs that too. Scripts that call `POST + /api/settings` must send the access code, and, when it differs from the admin code, the new + optional `X-Boat-Chat-Access-Token` header carries it on `GET`, `HEAD` and `POST`. The access + code cannot be cleared through the API (blank or `null` returns 400) unless + `BOAT_CHAT_ALLOW_OPEN=true`. The environment variable names + (`BOAT_CHAT_ACCESS_TOKEN`, `BOAT_CHAT_SETTINGS_TOKEN`) are unchanged. +- **Unauthenticated loopback settings writes require a local `Host` name** (a defense + against DNS rebinding). Reach Boat Chat through `127.0.0.1` or `localhost`, or send the + token. +- **The Control Panel looks different.** It now has four tabs (Home, Settings, Updates, + Maintenance). **Install & start** is **Reinstall & start** and **Run preflight** is **Check + setup**, both under Maintenance → Setup & repair, and they run the same commands as before. + Saving settings and applying them are two steps (**Save**, then **Apply**). The Updates tab + contacts GitHub only when you press **Check for updates**. Stored settings and keys are + unchanged. Boat Chat's credentials are labelled **Access code** and **Admin code**; the + variable names are unchanged. +- **The Control Panel checks a few settings as you save.** The timezone must be a valid IANA + name such as `Europe/Lisbon` (some legacy aliases such as `US/Eastern` may be rejected on + newer systems where they moved to the `tzdata-legacy` package). Boat length must be more + than 0 and at most 500, beam more than 0 and at most 100 (plain numbers in metres, no + units), and CAN speed a whole number from 10000 to 1000000. The checks apply only to fields + you edit, so an existing saved value you do not touch is never rejected or cleared; errors + appear next to the field. +- **Docker Signal K may restart once.** If the update newly preloads the pinned + `signalk-vesseltwin` 0.2.3 plugin into an already-running Signal K container, the + installer restarts that container so the plugin loads. An existing preloaded copy is never + overwritten. Plan for a short gap in Signal K data. +- **Coming from 0.4.x:** update once from the CLI with the pinned installer. Since 0.5.0 + Mosquitto always enforces an access list, so the first update recreates the broker once, and + MQTT users you added by hand to the old password file lose access unless you add them + through VesselStack (see [MQTT access control](docs/MQTT.md)). + +**Env-file values are checked before anything changes.** `sudo ./install.sh --config FILE +--dry-run`, and therefore `vesselstackctl update` and the Control Panel updater (both run a dry +run first), refuse an unsafe value before the backup is taken, services are stopped or any file +is touched. The installer lists each offending setting by key name only (never the value), says +why, and exits with an error: + +```text + BOAT_NAME: a single quote combined with a backslash, double quote, dollar sign or backtick +Refusing to continue: the settings above cannot be written safely to the environment files. +``` + +Edit that value (for example `BOAT_NAME` in the Control Panel or `vesselstack.env`) so it no +longer combines an apostrophe with one of those characters or spans lines, then run the dry +run or the update again. Env files are also written atomically (to a temporary file that +replaces the old one only when complete), so a failed run leaves the previous file intact. If an +older release left an update stopped on this problem, restore the pre-update backup with the +command under Rollback below, fix the value, and update again. + +**Verify after updating** + +```bash +cat /opt/vesselstack/config/installed-version # 0.6.0 +sudo vesselstackctl doctor # RESULT healthy +sudo vesselstackctl status +``` + +Open Boat Chat and the Control Panel, and confirm the dashboards show live values. + +**Rollback.** Downgrades are refused (`Downgrade from 0.6.0 to ... is not supported`). Restore the +verified pre-update backup instead: + +```bash +sudo vesselstackctl verify-backup /path/to/vesselstack-TIMESTAMP.tar.gz +sudo vesselstackctl restore /path/to/vesselstack-TIMESTAMP.tar.gz --yes +``` + +The restore keeps the previous trees as `*.pre-restore-STAMP` until you delete them (see +[Backups](#backups)). + ## What VesselStack installs | Component | Purpose | Default address | @@ -581,7 +524,7 @@ in the sections below. | Heimdall | Local service launcher | `http://HOST` | | Boat Chat | Telemetry-backed diagnostic assistant | `http://HOST:8765` | | Control Panel | Component installation, configuration, and lifecycle | `http://127.0.0.1:8780` | -| Displays bridge (opt-in, `DISPLAYS_ENABLE=true`) | Publishes configured SignalK instruments to MQTT for instrument displays | systemd service `vesselstack-displays-bridge`; no listener | +| Displays bridge (opt-in, `DISPLAYS_ENABLE=true`) | Publishes configured Signal K instruments to MQTT for instrument displays | systemd service `vesselstack-displays-bridge`; no listener | The installer generates credentials, renders a pinned Docker Compose stack, creates a confined systemd service for Boat Chat, creates configurable InfluxDB @@ -589,14 +532,14 @@ buckets, provisions the one-minute downsample task expected by Boat Chat, provisions Grafana datasources and a system-health dashboard, and installs the `vesselstackctl` lifecycle command. It also installs a local-first, token- authenticated Control Panel that can inspect and operate every managed -container and host service, edit redacted configuration, run preflight, apply -configuration, create backups, and apply a reviewed release update. +container and host service, edit redacted configuration, check the setup, save and apply +settings, create backups, and check for and install release updates. All container tags are release-pinned and checked for Linux ARM64 availability; VesselStack does not rely on floating `latest` tags. -The installer supports an existing SignalK server, an opt-in -pinned SignalK container, or an opt-in pinned native SignalK installation. It +The installer supports an existing Signal K server, an opt-in +pinned Signal K container, or an opt-in pinned native Signal K installation. It also provides hardware-probed SocketCAN and AIS-catcher modules. Hardware modules remain disabled unless explicitly configured. It also includes generic Home Assistant alert/dashboard templates and an optional untrusted-interface @@ -609,7 +552,7 @@ firewall policy. - SSD or NVMe storage strongly recommended - Docker Engine with Docker Compose v2 - Python 3 with `venv` -- Existing SignalK server or a selected VesselStack SignalK install mode +- Existing Signal K server or a selected VesselStack Signal K install mode - Dedicated non-root service account Installation downloads container images and a Python dependency, so temporary @@ -617,10 +560,10 @@ internet access is required. ## Before installation -1. Back up existing Home Assistant, InfluxDB, Grafana, MQTT, and SignalK data. +1. Back up existing Home Assistant, InfluxDB, Grafana, MQTT, and Signal K data. 2. Confirm ports `80`, `443`, `1883`, `8086`, `8123`, `8765`, `8780`, `9090`, and `43000` are free. Port `8780` binds to loopback by default. -3. If using `SIGNALK_MODE=existing`, confirm SignalK responds: +3. If using `SIGNALK_MODE=existing`, confirm Signal K responds: ```bash curl -fsS http://127.0.0.1:3000/signalk @@ -671,16 +614,32 @@ git clone https://github.com/jclima/VesselStack.git cd VesselStack ``` -For the pinned 0.5.7 lab prerelease, use its annotated unsigned tag (it is not -the latest release): +To work from the 0.6.0 release, check out its tag: ```bash git fetch --tags -git checkout v0.5.7 +git checkout v0.6.0 ``` -This lab prerelease is for authorized lab use only; it is unsigned, not latest, -and physical Raspberry Pi testing is pending. It is not a stable release. +`v0.6.0` is an SSH-signed tag (Ed25519 release key, verified by the release workflow against +`.github/allowed_signers`). It is published first as a pre-release and marked Latest after the +maintainer's physical Raspberry Pi install passes; see the [CHANGELOG](CHANGELOG.md). Verify the +tag yourself (needs `ssh-keygen`, which ships with OpenSSH): + +```bash +git fetch --tags +scripts/verify-release-tag.sh v0.6.0 +# maintainers and owners: also bind the tag to the commit you recorded when it was built +# (the full 40-character SHA from your release notes, not one read back from the tag itself) +scripts/verify-release-tag.sh v0.6.0 .github/allowed_signers "$RECORDED_COMMIT" +# equivalent plain git command: +git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=.github/allowed_signers verify-tag v0.6.0 +``` + +The script prints the signer and key fingerprint, and exits non-zero for an unsigned, +lightweight or wrongly signed tag. The release key fingerprint is +`SHA256:01UcXtAoMkJ7zfQ2n7gIc4fUjPa3+0BVvHJ2VHIuTIw`. Older tags (`v0.5.x`) are unsigned and +fail this check by design. ## 4. Configure the vessel @@ -698,7 +657,7 @@ nano vesselstack.env | `*_PORT` | Host ports for Grafana, Heimdall HTTP/HTTPS, InfluxDB, Prometheus, MQTT, and AIS | | `VESSELSTACK_DATA_BIND_ADDRESS` | IPv4 address InfluxDB, Prometheus and MQTT publish on; default `127.0.0.1` (this device only) | | `VESSELSTACK_WEB_BIND_ADDRESS` | IPv4 address Grafana and Heimdall publish on; default `0.0.0.0` (LAN dashboards), `127.0.0.1` to restrict | -| `HOMEASSISTANT_BIND_ADDRESS` | IPv4 address Home Assistant (host-networked, `8123`) listens on; default `0.0.0.0`. Any other value makes the installer manage one delimited `http:` block in `${VESSELSTACK_DATA}/homeassistant/configuration.yaml` (see "Reaching VesselStack from other devices"). SignalK has no equivalent setting | +| `HOMEASSISTANT_BIND_ADDRESS` | IPv4 address Home Assistant (host-networked, `8123`) listens on; default `0.0.0.0`. Any other value makes the installer manage one delimited `http:` block in `${VESSELSTACK_DATA}/homeassistant/configuration.yaml` (see "Reaching VesselStack from other devices"). Signal K has no equivalent setting | | `BOAT_CHAT_ACCESS_TOKEN` | `GENERATE` (default) creates a random token, preserved on re-install; required for chat, status and data APIs | | `INFLUXDB_CONTAINER_NAME` | Advanced override for existing/custom Compose deployments | | `BOAT_TIMEZONE` | IANA timezone, such as `America/Los_Angeles` | @@ -709,8 +668,8 @@ nano vesselstack.env | `VESSELSTACK_DATA` | Persistent container-data path | | `VESSELSTACK_BACKUP` | Backup destination; must be outside the application and data trees | | `SIGNALK_MODE` | `existing`, `docker`, or `native` | -| `SIGNALK_VERSION` | Pinned SignalK release used by managed modes | -| `SIGNALK_URL` | Existing SignalK endpoint | +| `SIGNALK_VERSION` | Pinned Signal K release used by managed modes | +| `SIGNALK_URL` | Existing Signal K endpoint | | `SIGNALK_VESSELTWIN_PRELOAD` | Managed Docker/native modes preload immutable `signalk-vesseltwin` 0.2.3 (default `true`); existing mode ignores it, and `false` skips without uninstalling | | `HOME_ASSISTANT_TOKEN` | Optional long-lived token used by Boat Chat | | `SOCKETCAN_ENABLE` | Opt in only after the CAN interface is present | @@ -720,7 +679,7 @@ nano vesselstack.env | `AIS_DEVICE` | USB bus or receiver device mapped into AIS-catcher | | `AIS_CATCHER_ARGS` | Reviewed AIS-catcher receiver/output arguments | | `INFLUXDB_ORG` | InfluxDB organization | -| `INFLUXDB_RAW_BUCKET` | Full-resolution SignalK data; default `signalk` | +| `INFLUXDB_RAW_BUCKET` | Full-resolution Signal K data; default `signalk` | | `INFLUXDB_HISTORY_BUCKET` | One-minute retained history; default `signalk_1m` | | `INFLUXDB_HOME_ASSISTANT_BUCKET` | Home Assistant history bucket | | `INFLUXDB_AIS_BUCKET` | Short-retention AIS history bucket | @@ -735,8 +694,8 @@ nano vesselstack.env | `MQTT_USERNAME`, `MQTT_PASSWORD` | Mosquitto administrator account; the username may contain letters, numbers, `.`, `_` and `-` (1-64), and a custom password must not contain `:` or leading/trailing whitespace | | `DISPLAYS_ENABLE` | `false` by default; `true` adds the displays bridge and per-display MQTT accounts (see [MQTT access control](#mqtt-access-control)) | | `DISPLAYS_BRIDGE_MQTT_USERNAME` | Bridge account name, 1-64 letters, numbers, dot, underscore or hyphen; default `vesselstack-bridge`; must differ from `MQTT_USERNAME` (the Control Panel rejects a collision when displays are enabled, and the installer preflight rejects it) | -| `DISPLAYS_SIGNALK_TOKEN` | Optional SignalK access token for the displays bridge, sent as `Authorization: Bearer`; leave empty unless SignalK security requires it; must not contain line breaks or any other control character (the Control Panel and the installer both refuse them) | -| `DISPLAYS_HA_TOKEN` | Optional long-lived access token of a dedicated Home Assistant user (a leaked token can operate every device in that Home Assistant; see [Home Assistant items](docs/DISPLAYS.md#home-assistant-items)), used only by the displays bridge for Home Assistant items (`ha` in `displays.json`, see [Home Assistant items](docs/DISPLAYS.md#home-assistant-items)); the bridge connects to `HOME_ASSISTANT_URL` (default `http://127.0.0.1:8123`). Stored only in the root-only 0600 `config/displays-bridge.env`, never logged; same rule as the SignalK token: no line breaks or other control characters. Different from `HOME_ASSISTANT_TOKEN` (Boat Chat) | +| `DISPLAYS_SIGNALK_TOKEN` | Optional Signal K access token for the displays bridge, sent as `Authorization: Bearer`; leave empty unless Signal K security requires it; must not contain line breaks or any other control character (the Control Panel and the installer both refuse them) | +| `DISPLAYS_HA_TOKEN` | Optional long-lived access token of a dedicated Home Assistant user (a leaked token can operate every device in that Home Assistant; see [Home Assistant items](docs/DISPLAYS.md#home-assistant-items)), used only by the displays bridge for Home Assistant items (`ha` in `displays.json`, see [Home Assistant items](docs/DISPLAYS.md#home-assistant-items)); the bridge connects to `HOME_ASSISTANT_URL` (default `http://127.0.0.1:8123`). Stored only in the root-only 0600 `config/displays-bridge.env`, never logged; same rule as the Signal K token: no line breaks or other control characters. Different from `HOME_ASSISTANT_TOKEN` (Boat Chat) | | Terminal wizard and `--set` | The `DISPLAYS_*` settings are asked under "Advanced settings" (the bridge user name and the two tokens only when displays are enabled; the tokens are hidden and optional) and can be set headless, for example `--set DISPLAYS_ENABLE=true`; they are kept when you re-run the wizard | Never commit `vesselstack.env`. Generated secrets are stored in mode-600 files @@ -760,7 +719,7 @@ locally loaded container images (see Offline install). Resolve every error before continuing. -For native SignalK, install Node.js 22 or newer and npm before preflight. For +For native Signal K, install Node.js 22 or newer and npm before preflight. For SocketCAN or AIS, connect the adapter first: preflight intentionally fails when an enabled device is not detected. @@ -860,7 +819,7 @@ sudo journalctl -u vesselstack-chat.service --since "15 minutes ago" --no-pager Open `http://HOST:8123` and finish onboarding. Configure a notification provider, MQTT at `127.0.0.1:1883`, and sensors appropriate to the boat's -SignalK paths. VesselStack installs reusable low-battery, bilge/high-water, and +Signal K paths. VesselStack installs reusable low-battery, bilge/high-water, and shore-power-loss blueprints under **Settings → Automations → Blueprints**. Create automations from them only after validating the selected entities, thresholds, durations, and notification actions. @@ -871,18 +830,18 @@ starter Lovelace view. Copy it into a dashboard only after replacing every `sensor.vesselstack_*` example with a real entity from the vessel. VesselStack does not silently enable safety alerts or assume thresholds. -### SignalK and InfluxDB +### Signal K and InfluxDB -With `SIGNALK_MODE=docker`, VesselStack starts the official pinned SignalK +With `SIGNALK_MODE=docker`, VesselStack starts the official pinned Signal K image and persists its settings in `/opt/vesselstack-data/signalk` (owned by uid/gid 1000, the image's `node` user, so the container can write it). With `SIGNALK_MODE=native`, it installs the pinned npm package under `/opt/vesselstack/signalk-server` and runs it as the locked VesselStack user. -`SIGNALK_MODE=existing` never modifies SignalK. The managed modes use the same +`SIGNALK_MODE=existing` never modifies Signal K. The managed modes use the same first-run admin onboarding at `http://HOST:3000`. Managed Docker and native installs preload the pinned, zero-runtime-dependency -`signalk-vesseltwin` 0.2.3 package before SignalK starts. The archive is +`signalk-vesseltwin` 0.2.3 package before Signal K starts. The archive is integrity-checked before publication; an existing plugin directory is preserved and never overwritten, so a boat that already has the plugin updates it from the Signal K App Store. When a re-run newly preloads the plugin into an @@ -895,12 +854,12 @@ explicit owner actions (enable the plugin in **Server > Plugin Config** after either a preload or an App Store install). When AIS is enabled, AIS-catcher exposes its viewer on port `8100` and an NMEA -TCP feed on port `5011`. Add a SignalK NMEA 0183 TCP client connection to +TCP feed on port `5011`. Add a Signal K NMEA 0183 TCP client connection to `127.0.0.1:5011`. When SocketCAN is enabled, VesselStack validates the named interface and installs a 250-kbit/s-by-default systemd unit; configure the -matching SignalK NMEA 2000 connection in SignalK's admin UI. +matching Signal K NMEA 2000 connection in Signal K's admin UI. -Configure a SignalK InfluxDB 2-compatible output plugin with values from +Configure a Signal K InfluxDB 2-compatible output plugin with values from `/opt/vesselstack/config/vesselstack.env`: - URL: `http://127.0.0.1:8086` @@ -948,10 +907,9 @@ volume again; new metrics stay in `/prometheus`). #### Raw data retention -The full-resolution bucket (`INFLUXDB_RAW_BUCKET`) used to keep data forever, -which eventually fills the boat's disk. New installs now create it with a 30-day -retention (`INFLUXDB_RAW_RETENTION`, default `30d`); the one-minute history -bucket keeps one year as before. **Upgrades never change an existing bucket +Keeping the full-resolution bucket (`INFLUXDB_RAW_BUCKET`) forever eventually fills +the boat's disk, so new installs create it with a 30-day retention +(`INFLUXDB_RAW_RETENTION`, default `30d`); the one-minute history bucket keeps one year. **Upgrades never change an existing bucket implicitly**, because shortening retention permanently deletes older raw data. To apply a window to an existing install, set `INFLUXDB_RAW_RETENTION` (for example `90d`, or `0` to keep forever) in `vesselstack.env` or the control panel @@ -978,13 +936,13 @@ the service first, then update `config/vesselstack.env`, then re-apply: `MQTT_PASSWORD` is editable: the Mosquitto password file is created only when missing and otherwise updated in place (other users are kept) when the -username/password changed since the last run, so re-running the installer no -longer resets MQTT credentials. Update Home Assistant's MQTT integration to match. +username/password changed since the last run, so re-running the installer +does not reset MQTT credentials. Update Home Assistant's MQTT integration to match. #### Virtual CAN for home-lab replay On a lab machine without a CAN adapter you can replay a recorded log into a -virtual CAN interface and let SignalK read it as if it were the boat bus. Set +virtual CAN interface and let Signal K read it as if it were the boat bus. Set ```bash SOCKETCAN_ENABLE="true" @@ -1004,7 +962,7 @@ sudo apt install can-utils canplayer -I /path/to/your-candump.log vcan0=can0 # add -l i to loop forever ``` -Point the SignalK NMEA 2000/SocketCAN connection at `vcan0`. Recorded logs +Point the Signal K NMEA 2000/SocketCAN connection at `vcan0`. Recorded logs contain vessel identifiers and positions: keep them outside the repository (the public-tree check rejects `.log` files) and never commit or share them. @@ -1019,27 +977,56 @@ sudo grep '^GRAFANA_ADMIN_PASSWORD=' \ The installer provisions `VesselStack InfluxDB`, `VesselStack Prometheus`, and the **VesselStack / VesselStack System Health** starter dashboard. Use that as -the operational baseline, then add vessel dashboards for the SignalK paths +the operational baseline, then add vessel dashboards for the Signal K paths available on the boat. Provisioned dashboards are editable in Grafana; keep reusable versions in the repository so reinstallations remain reproducible. ### Boat Chat -Open `http://HOST:8765` and enter the access token (`BOAT_CHAT_ACCESS_TOKEN` in -`config/boat-chat.env`); the default `local` provider sends nothing to an external model. -Settings, the settings token, telemetry refresh, optional host metrics, timezone and units -are in [Boat Chat setup](docs/BOAT_CHAT.md); providers are in [Boat Chat providers](boat-chat/README.md). +Open `http://HOST:8765`. The page opens on **Overview** (boat condition, observations, what +needs attention and suggested actions); **Chat** is the question box. **More** holds +**Boat Hub** (trips, position and maintenance), **Prompts** and **Settings**. When an access +code is required, choose **Connect** and enter it; it is kept in that browser tab only. The +access code is `BOAT_CHAT_ACCESS_TOKEN` in `config/boat-chat.env`, and the **Admin code** +(`BOAT_CHAT_SETTINGS_TOKEN`) authorizes changing Settings. The default `local` provider sends +nothing to an external model. + +![Boat Chat Overview in a browser](docs/images/boat-chat-overview.png) + +Settings, the admin code, API access rules, telemetry refresh, optional host metrics, timezone +and units are in [Boat Chat setup](docs/BOAT_CHAT.md); providers are in +[Boat Chat providers](boat-chat/README.md). ### Control Panel -The Control Panel is the browser administration surface. It listens on loopback by default -(`http://127.0.0.1:8780` through an SSH tunnel); `sudo vesselstackctl panel-access lan|local` -changes only that bind. See [Control Panel](docs/CONTROL_PANEL.md) for the full guide. +The Control Panel is the browser page for looking after VesselStack. It listens on loopback by +default (`http://127.0.0.1:8780` through an SSH tunnel); `sudo vesselstackctl panel-access +lan|local` changes only that bind. Sign in with the token from +`sudo grep '^CONTROL_PANEL_TOKEN=' /opt/vesselstack/config/control-panel.env`; pasting the whole +`CONTROL_PANEL_TOKEN=...` line works too. The full tour is in the +[Control Panel guide](docs/CONTROL_PANEL.md). + +![Control Panel Home tab](docs/images/control-panel-home.png) + +- **Home** is status only: services running or stopped, the last backup and backup drive (read-only; + "unknown" when they cannot be read) and whether an update is known, with one **Back up now** + button and the components below. Component cards offer Start, Stop, Restart and, in a **⋯** + menu, Disable. +- **Settings** has a jump list, help for every field, Advanced sections that start collapsed, + inline field errors, and one save bar: **Save** writes the settings, **Apply** renders and installs + them (it does not recreate containers and restarts only the displays bridge; restart a component to use the new values). +- **Updates** checks only when you press **Check for updates**, which contacts github.com. +- **Maintenance** holds Back up now, Start all, Restart all, Stop all, **Check setup**, + **Reinstall & start**, **Provision history** and **Restart Control Panel**. + +Stopping or interrupting actions ask first in an in-page dialog. If the connection drops the +header shows Disconnected with **Retry now**. Rollback: restore the previous release from a +backup as described under [Updating](#updating); stored settings keys are unchanged. ### Heimdall Open `http://HOST` and add trusted-network links to Home Assistant, Grafana, -SignalK, Boat Chat, InfluxDB, and Prometheus. +Signal K, Boat Chat, InfluxDB, and Prometheus. ## MQTT access control @@ -1076,20 +1063,19 @@ excluded; application source, configuration, memory, and data remain included. The destination is resolved before writing and is rejected if it falls inside the application or data tree, including through a symlink. Destinations under `/media` or `/mnt` must be a mounted filesystem (`mountpoint -q`), so a missing -USB drive can no longer silently fill the SD card; pass +USB drive cannot silently fill the SD card; pass `vesselstackctl backup --allow-unmounted DIR` (or set `VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=true`, which also applies to `vesselstackctl update`) to override. A warning is printed when the destination is on the same filesystem as the data. Besides the application and data trees the -archive now includes `/etc/systemd/system/vesselstack-*`, +archive includes `/etc/systemd/system/vesselstack-*`, `/usr/local/sbin/vesselstack{ctl,-firewall}` and `/etc/vesselstack/update.env` when they exist, so a restore across versions is complete. The whole stack, including Home Assistant, stays stopped for the copy. Home Assistant, Grafana, InfluxDB and Boat Chat all keep SQLite or write-ahead-log state in the data tree, and a file copy of a running database is not -crash-consistent, so partial stops were rejected in favour of a consistent -archive. +crash-consistent, so the archive is taken with the whole stack stopped. Restore only during a maintenance window. Verification rejects path traversal, absolute or escaping links, device nodes, FIFOs and any setuid, setgid or sticky @@ -1099,7 +1085,7 @@ member (a legitimate backup never contains one) before anything is extracted: sudo vesselstackctl restore /path/to/vesselstack-TIMESTAMP.tar.gz --yes ``` -Restore no longer extracts over the live tree. It (1) verifies the archive, +Restore never extracts over the live tree. It (1) verifies the archive, (2) checks free space (staging holds a second full copy next to the live trees; the previous trees are kept too, so you need room for old + new), (3) extracts as root with the recorded numeric owners into a private `.vesselstack-restore.*` @@ -1141,20 +1127,67 @@ live database files is insufficient. ## Updating -Back up first, then update through the guided path: +This is the one place in the README that describes updating and rolling back. + +1. **Back up first** to a mounted external drive (see [Backups](#backups)): + + ```bash + sudo vesselstackctl backup + ``` + +2. **Re-run the pinned release installer.** On the Pi, over `ssh -t`: + + ```bash + curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash + ``` + + The installer finds the installed system, never overwrites it silently, and offers + `vesselstackctl update ` for the release it unpacked under + `/opt/vesselstack-release/`. After you confirm, `vesselstackctl update` runs the + release's `install.sh --dry-run` preflight, makes and verifies a full backup, then runs + `install.sh --start`. Answer **n** to leave the system untouched; the installer prints + the exact command to run later. Services restart briefly, so do not update while underway. + Plain `sudo vesselstackctl update` with no argument fails: the command needs the release + directory, for example `sudo vesselstackctl update /opt/vesselstack-release/0.6.0`. + Without internet, use `--offline --bundle ...` as described under + [Offline install](#offline-install-no-internet-on-the-boat). + +3. **Or use the Control Panel.** An authenticated session can check the public + `jclima/VesselStack` releases. Stable releases are shown by default; enable + **Include early test releases** on the Updates tab to list prereleases as well, which you need for any release + GitHub publishes as a prerelease. The panel shows the installed version and, for a newer release, a link to its release notes. Nothing contacts GitHub until you press + **Check for updates**. **Back up & install** asks for confirmation in a dialog, verifies the downloaded SHA-256 manifest, safely extracts the + release, runs preflight, then makes and verifies a backup on a mounted external drive + (`/media` or `/mnt`; the browser cannot change the destination). A failed or unmounted + backup stops the update. A detached updater restarts the Control Panel and verifies its + new version before the browser reconnects. The panel uses ordinary HTTP and serializes + its own update, configuration and component operations; do not run CLI maintenance + commands at the same time. Transient updater files under `/var/lib/vesselstack` are not + backed up; configuration and data are. + +| Installed version | Update to 0.6.0 | +|---|---| +| 0.5.7 | Control Panel or the pinned installer. 0.6.0 is published first as a pre-release (in the 0.5.x panel, enable **Include lab prereleases** on the update card to see it; the 0.6.0 panel calls it **Include early test releases**) and is marked Latest after the maintainer's physical Raspberry Pi install passes | +| 0.5.5, 0.5.6 | Same as 0.5.7: the browser updater was introduced in 0.5.5 | +| 0.5.4 or earlier, including 0.4.x | Run the pinned installer from the CLI once (these versions cannot update from the browser); read [Upgrading to 0.6.0](#upgrading-to-060) first | + +A checksum shows a download is intact; it is not signed provenance. The signature on the `v0.6.0` tag (see [Download VesselStack](#3-download-vesselstack)) is the provenance check, and the installer does not perform it for you. + +**Rollback.** Downgrades are refused. If an update fails, the verified pre-update backup is +kept. Check it and restore it during a maintenance window: ```bash -sudo vesselstackctl backup -sudo vesselstackctl update +sudo vesselstackctl verify-backup /path/to/vesselstack-TIMESTAMP.tar.gz +sudo vesselstackctl restore /path/to/vesselstack-TIMESTAMP.tar.gz --yes ``` -To update from a reviewed release tag with `install.sh --dry-run` first, and to roll back from the -verified pre-update backup, see [Updating and offline install](docs/UPDATING.md). +There is no automatic rollback. To update from a reviewed source checkout with +`install.sh --dry-run` first, see [Updating and offline install](docs/UPDATING.md). ### Offline install (no internet on the boat) Prepare a bundle on a connected machine, carry it to the boat, and install with -`sudo bash get-vesselstack.sh --offline --bundle /path/to/offline-bundle --tarball /path/to/vesselstack-0.5.7.tar.gz`. +`sudo bash get-vesselstack.sh --offline --bundle /path/to/offline-bundle --tarball /path/to/vesselstack-0.6.0.tar.gz`. The bundle must be root-owned and not group/world-writable. Steps: [Updating and offline install](docs/UPDATING.md#offline-install-no-internet-on-the-boat). ## Stopping or removing services @@ -1215,7 +1248,7 @@ curl -fsS http://127.0.0.1:3000/signalk sudo journalctl -u vesselstack-chat.service -n 100 --no-pager ``` -Confirm SignalK and InfluxDB values in +Confirm Signal K and InfluxDB values in `/opt/vesselstack/config/boat-chat.env`. ### Mosquitto restarts repeatedly @@ -1253,24 +1286,24 @@ If `reindex` itself fails, its message names the cause (missing Python environment: rerun the installer; state directory missing or a file owned by root: the `chown` command it prints). -### SignalK not running / EACCES on package.json +### Signal K not running / EACCES on package.json Symptom (`SIGNALK_MODE=docker`): the `vesselstack-signalk` container restarts or exits and `sudo docker logs vesselstack-signalk` shows `EACCES: permission denied, open '/home/node/.signalk/package.json'`. The -`signalk-server` image runs SignalK as its `node` user (uid/gid 1000, config +`signalk-server` image runs Signal K as its `node` user (uid/gid 1000, config directory `/home/node/.signalk`), but `/signalk` was created by Docker as `root:root`. No release before 0.4.3 created it; 0.4.3 and later create it as `1000:1000` mode 0750 before `compose up` and repair a root-owned one on -every installer run, so rerunning the installer (`sudo vesselstackctl update` or -the installer from the release) fixes it. `sudo vesselstackctl doctor` also +every installer run, so rerunning the installer from the release (see +[Updating](#updating)) fixes it. `sudo vesselstackctl doctor` also detects the condition and reports it. Start with the read-only doctor output -and inspect the exact configured SignalK data directory and ownership. The +and inspect the exact configured Signal K data directory and ownership. The installer repairs only the known state conservatively: an empty or root-owned directory with safe contents may be repaired, while symlinks, hard links, special files, or another owner are left untouched with instructions. Resolve the indicated cause, then rerun the supported installer repair. Do not -blanket-chown the SignalK tree or restart the container while ownership and +blanket-chown the Signal K tree or restart the container while ownership and symlink state are still unknown. ### Container mount permissions fail @@ -1308,7 +1341,47 @@ value in a later commit does not remove it from earlier commits or releases. The distribution test rejects populated identity fields and private key material. This is a safety net, not a substitute for reviewing staged changes. +## Moved sections + +Earlier versions of this README held these sections. Old links to them land here and point to the current location. + +- [Alarm summary and takeover](docs/DISPLAYS.md#alarm-summary-and-takeover) +- [Automatic telemetry refresh](docs/BOAT_CHAT.md#automatic-telemetry-refresh) +- [Board registry schema 2](docs/DISPLAYS.md#board-registry-schema-2) +- [Building and flashing on the Pi](docs/DISPLAYS.md#building-and-flashing-on-the-pi) +- [Choose your path](#where-to-go-next) +- [Configuration and validation](docs/DISPLAYS.md#configuration-and-validation) +- [Current development improvements](docs/DEVELOPMENT.md#current-development-improvements) +- [Displays bridge service](docs/DISPLAYS.md#displays-bridge-service) +- [Find your next answer](#where-to-go-next) +- [Firmware: generate, build and flash](docs/DISPLAYS.md#firmware-generate-build-and-flash) +- [Hardware bring-up checklist (Waveshare ESP32-S3-Touch-LCD-2.8C)](docs/DISPLAYS.md#hardware-bring-up-checklist-waveshare-esp32-s3-touch-lcd-28c) +- [Home Assistant items](docs/DISPLAYS.md#home-assistant-items) +- [Icons](docs/DISPLAYS.md#icons) +- [Managing displays](docs/DISPLAYS.md#managing-displays) +- [MQTT topics and freshness](docs/DISPLAYS.md#mqtt-topics-and-freshness) +- [Notifications](docs/DISPLAYS.md#notifications) +- [One-command application updates](docs/DEVELOPMENT.md#one-command-application-updates) +- [Optional host metrics](docs/BOAT_CHAT.md#optional-host-metrics) +- [Planned limitations](docs/DISPLAYS.md#planned-limitations) +- [Quick start for an internet-connected Pi](#quick-start-one-command-on-the-raspberry-pi) +- [Selective core component lifecycle](docs/DEVELOPMENT.md#selective-core-component-lifecycle) +- [Settings and configuration precedence](docs/BOAT_CHAT.md#settings-and-configuration-precedence) +- [SignalK and InfluxDB](#signal-k-and-influxdb) +- [SignalK not running / EACCES on package.json](#signal-k-not-running--eacces-on-packagejson) +- [Supported boards](docs/DISPLAYS.md#supported-boards) +- [The `electrical` layout](docs/DISPLAYS.md#the-electrical-layout) +- [The `status-grid` layout](docs/DISPLAYS.md#the-status-grid-layout) +- [The `tank-bars` and `gauge-arc` layouts](docs/DISPLAYS.md#the-tank-bars-and-gauge-arc-layouts) +- [Thresholds and severity colours](docs/DISPLAYS.md#thresholds-and-severity-colours) +- [Touch, header, night mode and dimming](docs/DISPLAYS.md#touch-header-night-mode-and-dimming) +- [Update from the Control Panel (0.5.7 lab release)](#updating) +- [Version 1.0 release scope](docs/DEVELOPMENT.md#version-10-release-scope-historical) + ## Development and release history -Historical release scope and the unreleased review-fix notes are kept in +Historical release scope and review-fix notes are kept in [Development: Historical notes](docs/DEVELOPMENT.md#historical-notes). Release notes are in `CHANGELOG.md`. + +Contributors: CI runs ShellCheck 0.9.0 from the Ubuntu package at info severity; a clean run of a newer +local ShellCheck is not enough (see [Development](docs/DEVELOPMENT.md)). diff --git a/SECURITY.md b/SECURITY.md index 813bc83..14a6037 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,10 @@ ## Supported versions -Security fixes are provided for the latest published release and the default -branch. Pre-release builds may change without compatibility guarantees. +Security fixes are provided for the newest published release (the highest version +number, whether or not GitHub marks it Latest or as a prerelease) and for the default +branch. Older releases, including 0.4.x, receive no fixes: update to the newest release. +Pre-release builds may change without compatibility guarantees. ## Reporting a vulnerability diff --git a/VALIDATION.md b/VALIDATION.md index ca7abad..cbe970a 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -1,10 +1,41 @@ -# Historical 0.3.0 validation (schema 1.3) +# Validation + +Current automated gates come first. The 0.3.0 record that follows is kept for history. + +## 0.6.0 automated gates + +GitHub Actions (`.github/workflows/ci.yml`) runs these on every pull request; the release +workflow reruns the whole CI workflow on the tagged commit. None of them replaces +commissioning on the boat. + +- README update required: a pull request that does not change `README.md` fails. +- Privacy check (`scripts/check-public-tree.py`) and static distribution checks + (`tests/test-distribution.sh`, including ShellCheck and that `DEFAULT_VERSION` equals `VERSION`). +- Lifecycle backup, verify and restore; versioned migrations; clean installation; offline and + transactional installation; the update launcher's root-trust checks; the guided + `get-vesselstack.sh` bootstrap; SocketCAN unit rendering; release package contents. +- A real systemd browser-update acceptance run, and the Signal K VesselTwin preload security tests as root. +- Docker Compose validation with the default and optional profiles, and a check that every + pinned image publishes Linux ARM64. +- All Python regressions (`python3 -m unittest discover -s tests -p 'test_*.py'`) and the + Control Panel UI regression checks (`node tests/test_control_panel_ui.cjs`). +- A release archive build with a SHA-256 check of the archive and of `get-vesselstack.sh`. +- Disposable InfluxDB, Prometheus and Grafana startup, the Mosquitto access list and display + accounts, the Mosquitto upgrade path, the Signal K to MQTT displays bridge, and its + EnvironmentFile under real systemd. +- The generated display projects are validated with the pinned ESPHome image. + +Not covered by CI: SocketCAN and NMEA 2000 wiring, SDR/AIS reception, real Raspberry Pi +hardware and upgrades, and vessel-specific alarm thresholds. 0.6.0 release status: recorded +in the CHANGELOG at release time. + +## Historical 0.3.0 validation (schema 1.3) This record describes the evidence used for the historical 0.3.0 release. It is not a current-release gate and does not convert VesselStack into certified marine safety equipment. -## Automated release gates +### Automated release gates (0.3.0) - Bash syntax and ShellCheck for installer, lifecycle, migration, hardware, firewall, packaging, and integration scripts. @@ -13,16 +44,16 @@ marine safety equipment. - Clean installer render into an isolated filesystem, including permissions, generated credentials, branding, systemd units, Grafana assets, Home Assistant assets, and installed schema version. -- Explicit 0.1/1.0/1.1/1.2-to-1.3 migration and rejection of unknown future schemas. +- Explicit 0.1/1.0/1.1/1.2-to-1.3 migration (schema labels of that era) and rejection of unknown future schemas. - Checksummed backup verification and actual restoration of changed state. -- Docker Compose rendering with default, SignalK, and AIS profiles. +- Docker Compose rendering with default, Signal K, and AIS profiles. - Registry-manifest verification that every pinned image publishes Linux ARM64, with bounded retries for transient registry transport failures. - Disposable InfluxDB, Prometheus, and Grafana startup; health queries; bucket creation; idempotent Flux task creation; datasource lookup; and dashboard lookup. - Boat Chat, Control Panel, query-planner, session-store, API-integration, and - wizard tests, including installed SignalK, Home Assistant, InfluxDB + wizard tests, including installed Signal K, Home Assistant, InfluxDB endpoint/credential use, request limits, token handling, configuration rollback, and operation allowlists. - Lifecycle tests for complete first start, per-service diagnostics, nested @@ -35,7 +66,7 @@ marine safety equipment. content and overlay checks, browser error inspection, and two reviewed documentation screenshots. -## Raspberry Pi validation host +### Raspberry Pi validation host (0.3.0) - Linux ARM64 host - Linux `aarch64`, 64-bit diff --git a/VERSION b/VERSION index d3532a1..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.7 +0.6.0 diff --git a/boat-chat/README.md b/boat-chat/README.md index edc9882..d39c5fd 100644 --- a/boat-chat/README.md +++ b/boat-chat/README.md @@ -4,9 +4,8 @@ First-run access, settings, telemetry refresh, units and timezone are in [Boat C Local read-only diagnostic chatbot for VesselStack. -This guide follows current `main`. The `0.5.7` lab prerelease includes -selectable unit presentation and separate telemetry sampling and indexing -timers. +This guide follows current `main`. Release 0.6.0 includes selectable unit +presentation and separate telemetry sampling and indexing timers. ## Run @@ -41,7 +40,7 @@ Without a configured model provider, the app still gathers and returns local con Boat Chat keeps context in tiers so common questions stay fast and small local models do not have to infer stable facts from long runbooks: - Tier 0: stable boat facts from `boat-chat/boat_facts.json` such as vessel name, model, MMSI, call sign, host, telemetry stack, and engine layout. -- Tier 1: live SignalK and Home Assistant state, fetched only when relevant. +- Tier 1: live Signal K and Home Assistant state, fetched only when relevant. - Tier 2: InfluxDB historical summaries, fetched only for questions that need history such as engine runtime or fuel usage. - Tier 3: local docs and runbooks from the SQLite memory index, limited to the top matches. @@ -53,7 +52,7 @@ Runtime behavior is governed by `boat-chat/BOAT_CHAT_AGENT.md`. It is optimized Complex questions use semantic signal groups instead of requiring every query word to appear in one measurement name. Boat Chat can gather multiple requested signals, exact local windows (including the recorded last trip), min/max timestamps, aligned values at notable times, port/starboard comparisons, RPM-matched comparisons, threshold durations, and correlations. Engine diagnostics include a separate engine-running view so normal engine-off zero readings do not become false low-pressure or fuel-flow findings. -Current SignalK values are converted to the configured `BOAT_UNITS` display units before model reasoning, and timestamps use the effective `BOAT_TIMEZONE` configured at service startup. Source instants, offsets, and unit labels remain authoritative; unknown units are not guessed. Nearby-vessel/AIS questions use the SignalK `/vessels` feed and include target distance, speed, course, position timestamp, and staleness. Home Assistant contributes current states, unavailable/stale review lists, and relevant retained state transitions for complex alert, shore-power, trip, bilge, and service questions. +Current Signal K values are converted to the configured `BOAT_UNITS` display units before model reasoning, and timestamps use the effective `BOAT_TIMEZONE` configured at service startup. Source instants, offsets, and unit labels remain authoritative; unknown units are not guessed. Nearby-vessel/AIS questions use the Signal K `/vessels` feed and include target distance, speed, course, position timestamp, and staleness. Home Assistant contributes current states, unavailable/stale review lists, and relevant retained state transitions for complex alert, shore-power, trip, bilge, and service questions. The web and Telegram clients send the last few conversation turns with each request. A short answer to a clarification, such as `last 30 days`, is combined with the prior user question instead of being treated as a new unrelated question. Telegram `/clear` and the web Clear button reset that context. @@ -73,14 +72,14 @@ Purpose-built telemetry questions are also answered deterministically before any ## Settings UI -Use the `Settings` button in the web app to configure: +Open **More** then **Settings** in the web app (it opens only after the current values have loaded). The interface is described in [Boat Chat setup](../docs/BOAT_CHAT.md#a-tour-of-the-interface). The page shows Models, Budgets and the provider sections; **Advanced** holds Telegram and the Server section with the **Access code** and **Admin code**. Settings configures: - Primary and fallback providers and models, all via dropdowns. Model dropdowns are provider-aware: Ollama lists the models actually installed (live from `/api/tags`), other providers show curated suggestions, and a `Custom...` entry accepts any exact model ID. Suggestions come from `GET /api/models`. - The primary model is stored in the right env key automatically: `BOAT_CHAT_CODEX_MODEL` for `codex_cli`, `BOAT_CHAT_CLAUDE_MODEL` for `claude_cli`, `BOAT_CHAT_MODEL` for everything else (and the generic key is cleared when a CLI provider is chosen, so a stale value cannot shadow it). Fallback models always use `BOAT_CHAT_FALLBACK_MODEL`. - Context budget, output budget, and CLI timeout. - Codex CLI, Claude CLI, Ollama, OpenAI, Vercel AI Gateway, AWS Bedrock, Google, and OpenAI-compatible provider settings. - Telegram bot token and allowed chat IDs. -- Server host/port and an optional settings write token. +- Server host/port and an optional admin code (`BOAT_CHAT_SETTINGS_TOKEN`) that protects settings changes. Base settings are stored in `/opt/vesselstack/config/boat-chat.env`; Settings-page changes are written to the service-user override file at `$VESSELSTACK_DATA/boat-chat/boat-chat.env`. Provider, model, token, and credential changes are read dynamically by the next chat request. Host/port changes require: @@ -90,7 +89,7 @@ sudo systemctl restart vesselstack-chat.service Secrets are not shown back in the browser; blank password fields keep the existing saved value. -Settings writes (`POST /api/settings`) require loopback when no settings token is configured. If `BOAT_CHAT_SETTINGS_TOKEN` is configured, the matching `X-Boat-Chat-Token` header is required from every address, including loopback and private LAN addresses. The web UI remembers the token only in browser session storage after you enter it in the Server section. Closing the tab clears it. Keep the app off the public internet. +Settings requests (`GET` and `POST /api/settings`) require the access code (`BOAT_CHAT_ACCESS_TOKEN`). `POST` also needs the admin code where one is configured: without `BOAT_CHAT_SETTINGS_TOKEN` it is accepted from loopback only, and with it the matching `X-Boat-Chat-Token` header is required from every address, including loopback and private LAN addresses. The access code normally travels in `X-Boat-Chat-Token`; when it differs from the admin code, send the access code in the optional `X-Boat-Chat-Access-Token` header and the admin code in `X-Boat-Chat-Token`; `GET`, `HEAD` and `POST` all accept that header. The access code cannot be cleared (blank or `null` returns 400 with "The access code cannot be cleared; set a new one instead.") unless `BOAT_CHAT_ALLOW_OPEN=true`. A save that blanks provider, model, max tokens, context characters, host or port, or gives an invalid number, is rejected with a 400. The web app calls these the **Access code** and **Admin code** and keeps them only in browser session storage; closing the tab clears them. Settings does not open until the current values have loaded, and it saves only the fields you changed. Keep the app off the public internet. ## Telemetry cache @@ -129,7 +128,7 @@ journalctl -u vesselstack-telemetry-indexer.service --since "1 hour ago" - `BOAT_CHAT_MAX_TOKENS` — optional, defaults to `1200`. - `BOAT_CHAT_CONTEXT_CHARS` — optional local-context budget for the prompt. Lower values such as `4000` keep simple small-model requests compact; complex historical questions automatically receive up to at least 18,000 characters so aligned evidence is not discarded. - `BOAT_CHAT_OLLAMA_NUM_CTX` — optional Ollama context window in tokens; defaults to `8192` so a larger context budget can never be silently truncated. Ollama calls also pass `keep_alive: -1` to keep the model resident in RAM (~2 GB for a 3B Q4 model), and the prompt places stable context before the question so Ollama can reuse its KV prefix cache — warm repeat calls drop from ~85 s to a few seconds. -- `BOAT_CHAT_SETTINGS_TOKEN` — optional write token for `POST /api/settings` (sent as `X-Boat-Chat-Token`). +- `BOAT_CHAT_SETTINGS_TOKEN` — optional admin code for `POST /api/settings` (sent as `X-Boat-Chat-Token`); the access code `BOAT_CHAT_ACCESS_TOKEN` is also required and, when different, is sent as `X-Boat-Chat-Access-Token`. - `BOAT_CHAT_CODEX_MODEL` — optional model for `codex_cli`; defaults to `gpt-5.4-mini` for lighter subscription usage. - `BOAT_CHAT_CODEX_EFFORT` — optional Codex reasoning effort; defaults to `low`. - `BOAT_CHAT_CODEX_BIN` — optional Codex binary path; defaults to `codex`. @@ -330,8 +329,8 @@ If `TELEGRAM_BOT_TOKEN` is unset, the worker idles and logs that it is waiting f ## Read-only data sources -- SignalK self telemetry: `http://127.0.0.1:3000/signalk/v1/api/vessels/self` -- SignalK nearby AIS traffic: `http://127.0.0.1:3000/signalk/v1/api/vessels` +- Signal K self telemetry: `http://127.0.0.1:3000/signalk/v1/api/vessels/self` +- Signal K nearby AIS traffic: `http://127.0.0.1:3000/signalk/v1/api/vessels` - Home Assistant: local REST API using the installed environment's configured bearer token (with `.mcp.json` retained as a compatibility fallback where supported). - InfluxDB: Flux queries using the installed environment's configured credentials (with `.mcp.json` retained as a compatibility fallback where supported). - Curated local docs: `boat-chat/BOAT_CHAT_AGENT.md`, `boat-chat/boat_facts.json`, `boat-chat/README.md`, and `boat-chat/knowledge/*.md`. diff --git a/boat-chat/app.py b/boat-chat/app.py index 53187de..a519179 100755 --- a/boat-chat/app.py +++ b/boat-chat/app.py @@ -513,13 +513,42 @@ def public_settings() -> dict[str, Any]: return payload -def update_settings(updates: dict[str, Any]) -> dict[str, Any]: - # Reject the whole request before writing anything: a newline/CR/NUL in a value - # would inject extra KEY=VALUE lines into the env file read by the services. +# Settings that must never be saved as a blank string: the server, chat budget and +# provider routing cannot run without a value. An explicit None is still an intentional +# "reset to the built-in default" and keeps its existing semantics. +REQUIRED_SETTING_KEYS = ( + "BOAT_CHAT_PROVIDER", + "BOAT_CHAT_MODEL", + "BOAT_CHAT_MAX_TOKENS", + "BOAT_CHAT_CONTEXT_CHARS", + "BOAT_CHAT_HOST", + "BOAT_CHAT_PORT", +) + + +def validate_settings_update(updates: dict[str, Any]) -> None: + """Reject the whole request before anything is written.""" for key, value in updates.items(): - if key in SETTING_KEYS and value is not None: - if any(char in str(value) for char in ("\n", "\r", "\x00")): - raise ValueError(f"{key} contains unsupported characters") + if key not in SETTING_KEYS: + continue + if value is not None and any(char in str(value) for char in ("\n", "\r", "\x00")): + # A newline/CR/NUL would inject extra KEY=VALUE lines into the env file. + raise ValueError(f"{key} contains unsupported characters") + if key == "BOAT_CHAT_ACCESS_TOKEN" and access_token_required() and (value is None or not str(value).strip()): + # Clearing the code would lock out every API client (the Settings page never sends blanks). + raise ValueError("The access code cannot be cleared; set a new one instead.") + if key in REQUIRED_SETTING_KEYS: + if value is not None and not str(value).strip(): + raise ValueError(f"{key} is required and cannot be blank") + for key in ("BOAT_CHAT_PORT", "BOAT_CHAT_MAX_TOKENS", "BOAT_CHAT_CONTEXT_CHARS"): + if key in updates and updates[key] is not None: + text = str(updates[key]).strip() + if not (text.isascii() and text.isdigit()) or int(text) < 1 or (key == "BOAT_CHAT_PORT" and int(text) > 65535): + raise ValueError(f"{key} must be a positive whole number" + (" up to 65535" if key == "BOAT_CHAT_PORT" else "")) + + +def update_settings(updates: dict[str, Any]) -> dict[str, Any]: + validate_settings_update(updates) with env_config.locked(ENV_CONFIG): current = parse_env_config() for key, value in updates.items(): @@ -5265,6 +5294,17 @@ def access_allowed(self) -> bool: provided = (self.headers.get("X-Boat-Chat-Token") or "").strip() return token_matches(provided, token) + def settings_access_header_allowed(self) -> bool: + """Access code sent in X-Boat-Chat-Access-Token, so one request can carry both + the access code and the admin code (X-Boat-Chat-Token) when they differ.""" + token = (setting_value("BOAT_CHAT_ACCESS_TOKEN") or "").strip() + provided = (self.headers.get("X-Boat-Chat-Access-Token") or "").strip() + return bool(token) and bool(provided) and token_matches(provided, token) + + def settings_read_allowed(self) -> bool: + """GET/HEAD /api/settings: the access code in either header, like POST.""" + return self.access_allowed() or self.settings_access_header_allowed() + def sensitive_access_allowed(self) -> bool: if not access_token_required(): return True @@ -5369,7 +5409,7 @@ def do_GET(self) -> None: ) return elif path == "/api/settings": - if not self.access_allowed(): + if not self.settings_read_allowed(): self.send_json(401, {"error": "invalid or missing X-Boat-Chat-Token header"}); return self.send_json(200, public_settings()) return @@ -5392,7 +5432,7 @@ def do_GET(self) -> None: def do_HEAD(self) -> None: path = urllib.parse.urlparse(self.path).path if path in {"/health", "/api/settings"}: - self.send_response(401 if path == "/api/settings" and not self.access_allowed() else 200) + self.send_response(401 if path == "/api/settings" and not self.settings_read_allowed() else 200) self.end_headers() return self.send_static(self.static_target(path), include_body=False) @@ -5414,6 +5454,10 @@ def do_POST(self) -> None: return if path == "/api/settings": try: + # Same access check as GET /api/settings, then the admin (write) check. + if not self.settings_read_allowed(): + self.send_json(401, {"error": "invalid or missing X-Boat-Chat-Token header"}) + return allowed, reason = self.settings_write_allowed() if not allowed: self.send_json(403, {"error": reason}) diff --git a/boat-chat/static/app.js b/boat-chat/static/app.js index afb9308..07babc5 100644 --- a/boat-chat/static/app.js +++ b/boat-chat/static/app.js @@ -5,9 +5,7 @@ if (new URLSearchParams(window.location.search).get("embedded") === "ha") { const form = document.querySelector("#chat-form"); const input = document.querySelector("#message"); const messages = document.querySelector("#messages"); -const context = document.querySelector("#context-json"); -const contextSummary = document.querySelector("#context-summary"); -const contextDrawer = document.querySelector(".context-drawer"); +const announcer = document.querySelector("#sr-announcer"); const status = document.querySelector("#status"); const routeStatus = document.querySelector("#route-status"); const settingsDialog = document.querySelector("#settings-dialog"); @@ -96,6 +94,27 @@ answerMode.value = localStorage.getItem("boatChatAnswerMode") || "concise"; answerMode.addEventListener("change",()=>localStorage.setItem("boatChatAnswerMode",answerMode.value)); let activeController = null; let progressTimer = null; +const CHAT_TIMEOUT_MS = 180000; + +function announce(text) { + // Separate polite live region: the conversation itself is not live, so restored + // history and the per-second counter are never read out. + announcer.textContent = ""; + window.setTimeout(() => { announcer.textContent = text; }, 60); +} +function accessCode() { try { return sessionStorage.getItem("boatChatAccessToken") || ""; } catch { return ""; } } +function describeHttpError(status, data) { + if (status === 401) return "Access code needed or not accepted. Choose Connect."; + if (status === 429) return "Too many requests. Wait a minute, then try again."; + return (data && typeof data.error === "string" && data.error) || `The boat returned an error (HTTP ${status}).`; +} +async function fetchJson(url, options = {}) { + const response = await fetch(url, options); + const data = await response.json().catch(() => null); + if (!response.ok) { const error = new Error(describeHttpError(response.status, data)); error.status = response.status; throw error; } + if (data === null) throw new Error("The boat returned a response that could not be read."); + return data; +} function appendInline(parent, text) { // Deliberately small text-only markup parser. Never interpret model HTML. @@ -160,6 +179,18 @@ function setStatus(text, state = "ready") { status.classList.toggle("is-busy", state === "busy"); status.classList.toggle("is-error", state === "error"); } +// The header chip reports the same sources as the Overview (/health plus the boat +// status request), so the two can never contradict each other. +let healthState = "unknown"; +function refreshConnectionStatus() { + if (activeController) return; + if (accessTokenRequired && !accessCode()) setStatus("Not connected", "error"); + else if (connectionState === "offline" || healthState === "offline") setStatus("Offline", "error"); + else if (healthState === "limited") setStatus("Limited data", "error"); + else if (connectionState === "connected" && lastBoatStatus) { + if (isStatusOutdated()) setStatus("Status outdated", "busy"); else setStatus("Connected"); + } else setStatus("Checking…", "busy"); +} function providerName(value) { return providerLabels[value] || value || "none"; @@ -175,35 +206,6 @@ function setRoute(provider, fallbackProvider, models) { routeStatus.title = route; } -function summarizeContext(payload) { - if (!payload || typeof payload !== "object" || !Object.keys(payload).length) { - return "No request yet"; - } - const keys = Object.keys(payload).filter((key) => payload[key] !== undefined && payload[key] !== null); - const useful = keys.filter((key) => !["context_profile", "question"].includes(key)); - const count = useful.length || keys.length; - return payload.evidence?.length ? `${payload.evidence.length} evidence item${payload.evidence.length === 1 ? "" : "s"}` : `${count} detail${count === 1 ? "" : "s"}`; -} - -function setContext(payload) { - context.replaceChildren(); - if (!payload) { - context.textContent = "No request yet."; - contextSummary.textContent = "No request yet"; - contextDrawer.hidden = true; - return; - } - contextDrawer.hidden = false; - const list = document.createElement("ul"); list.className = "evidence-list"; - (payload.evidence || []).forEach((item) => { - const row = document.createElement("li"); const label = document.createElement("strong"); const detail = document.createElement("span"); - label.textContent = item.label; detail.textContent = item.detail; row.append(label, detail); list.appendChild(row); - }); - if (!(payload.evidence || []).length) list.textContent = "No additional evidence details for this answer."; - context.appendChild(list); - contextSummary.textContent = summarizeContext(payload); -} - function setBusy(isBusy) { sendButton.disabled = isBusy; clearButton.disabled = isBusy; @@ -224,22 +226,38 @@ let lastStatusReceived = 0; let connectionState = "disconnected"; let accessTokenRequired = false; const connectDialog = document.querySelector("#connect-dialog"); -function showView(view) { document.body.dataset.view = view; } +function showView(view) { + document.body.dataset.view = view; + [["overview", "#overview-open"], ["chat", "#chat-open"]].forEach(([name, selector]) => { + const button = document.querySelector(selector); + button.setAttribute("aria-pressed", String(view === name)); + if (view === name) button.setAttribute("aria-current", "page"); else button.removeAttribute("aria-current"); + }); +} function updateAccessControls() { const connectButton = document.querySelector("#connect-open"); connectButton.hidden = !accessTokenRequired; if (!accessTokenRequired && connectDialog.open) connectDialog.close(); } +function statusSeconds() { + if (!lastBoatStatus) return null; + const age = lastBoatStatus.age_seconds; + const elapsed = Math.floor((Date.now() - lastStatusReceived) / 1000); + return age == null ? null : age + elapsed; +} +function isStatusOutdated() { + const seconds = statusSeconds(); + return seconds == null || seconds > 600 || connectionState !== "connected"; +} function ageText() { const output = document.querySelector("#observation-age"); if (!lastBoatStatus) return; - const age = lastBoatStatus.age_seconds; - const elapsed = Math.floor((Date.now() - lastStatusReceived) / 1000); - const seconds = age == null ? null : age + elapsed; - const outdated = seconds == null || seconds > 600 || connectionState !== "connected"; + const seconds = statusSeconds(); + const outdated = isStatusOutdated(); output.textContent = `${connectionState === "offline" ? "Connection lost — retained observations. " : ""}${seconds == null ? "Observation time unavailable" : `Snapshot collected ${Math.floor(seconds / 60)} min ago`}. ${outdated ? "Outdated or incomplete — refresh before relying on it." : "Individual sensors may report less often."}`; document.querySelector("#owner-overview").dataset.outdated = String(outdated); if (outdated) document.querySelector("#boat-condition").textContent = "Status needs refreshing"; + refreshConnectionStatus(); } function renderBoatStatus(data) { lastBoatStatus = data; lastStatusReceived = Date.now(); @@ -272,33 +290,35 @@ function clearDeviceStatus(message) { document.querySelector("#owner-attention").textContent="Boat status has not been checked."; document.querySelector("#owner-system").textContent="Connect to check system status."; insightsContent.replaceChildren(); document.querySelector("#connect-open").textContent="Connect"; updateAccessControls(); + refreshConnectionStatus(); } async function loadBoatStatus() { if(statusBusy) return; - const token=sessionStorage.getItem("boatChatAccessToken") || ""; + const token=accessCode(); if(accessTokenRequired && !token) { connectionState="disconnected"; clearDeviceStatus("Connect this device to view boat observations."); return; } statusBusy=true; try { const response=await fetch("/api/status",{headers:{"X-Boat-Chat-Token":token},signal:AbortSignal.timeout(10000)}); - if(response.status===401) { accessTokenRequired=true; updateAccessControls(); connectionState="disconnected"; clearDeviceStatus("Access token rejected. Choose Connect to sign in again."); return; } + if(response.status===401) { accessTokenRequired=true; updateAccessControls(); connectionState="disconnected"; clearDeviceStatus("Access code not accepted. Choose Connect to sign in again."); return; } if(!response.ok) throw new Error(`HTTP ${response.status}`); connectionState="connected"; renderBoatStatus(await response.json()); } catch(error) { connectionState="offline"; if(lastBoatStatus) ageText(); - else { clearDeviceStatus("Cannot reach the boat. Check your boat Wi-Fi or ZeroTier connection, then refresh."); document.querySelector("#boat-condition").textContent="Boat connection unavailable"; } - } finally { statusBusy=false; } + else { clearDeviceStatus("Cannot reach the boat. Check that you are on the boat Wi-Fi or connected through your remote-access VPN, then refresh."); document.querySelector("#boat-condition").textContent="Boat connection unavailable"; } + } finally { statusBusy=false; refreshConnectionStatus(); } } document.querySelector("#overview-open").addEventListener("click",()=>{showView("overview");loadBoatStatus();}); document.querySelector("#chat-open").addEventListener("click",()=>{showView("chat");input.focus();}); document.querySelector("#refresh-status").addEventListener("click",loadBoatStatus); -document.querySelector("#connect-open").addEventListener("click",()=>connectDialog.showModal()); +function openConnect() { connectDialog.showModal(); document.querySelector("#connect-token").focus(); } +document.querySelector("#connect-open").addEventListener("click",openConnect); document.querySelector("#connect-close").addEventListener("click",()=>connectDialog.close()); document.querySelector("#disconnect-device").addEventListener("click",()=>{sessionStorage.removeItem("boatChatAccessToken");connectionState="disconnected";clearDeviceStatus("Device disconnected.");connectDialog.close();}); document.querySelector("#connect-form").addEventListener("submit",async(event)=>{ event.preventDefault(); const field=document.querySelector("#connect-token"); const token=field.value.trim(); const message=document.querySelector("#connect-message"); message.textContent="Checking access…"; try { const response=await fetch("/api/status",{headers:{"X-Boat-Chat-Token":token},signal:AbortSignal.timeout(10000)}); - if(!response.ok) throw new Error(response.status===401 ? "Token not accepted. Check the Boat Chat access token and try again." : "Boat status is unavailable. Try again."); + if(!response.ok) throw new Error(response.status===401 ? "Access code not accepted. Check the Boat Chat access code and try again." : "Boat status is unavailable. Try again."); const data=await response.json(); sessionStorage.setItem("boatChatAccessToken",token); field.value=""; connectionState="connected";renderBoatStatus(data);message.textContent="";connectDialog.close(); } catch(error) { message.textContent=error.message; } }); @@ -316,9 +336,9 @@ function hubCard(title, wide = false) { } async function saveMaintenance(task) { - const token=sessionStorage.getItem("boatChatAccessToken") || ""; - const response=await fetch("/api/maintenance",{method:"POST",headers:{"Content-Type":"application/json","X-Boat-Chat-Token":token},body:JSON.stringify(task)}); - const data=await response.json(); if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); await loadInsights(); + const token=accessCode(); + await fetchJson("/api/maintenance",{method:"POST",headers:{"Content-Type":"application/json","X-Boat-Chat-Token":token},body:JSON.stringify(task)}); + await loadInsights(); } function alertSignature(data) { @@ -330,7 +350,7 @@ function processProactiveAlerts(data) { localStorage.setItem("boatChatAlertSignature",signature); if (previous !== null && signature && signature !== previous && localStorage.getItem("boatChatBrowserAlerts")==="on" && Notification.permission==="granted") { const warnings=(data.alerts || []).filter((item)=>item.severity!=="ok"); - new Notification("VesselStack boat alert",{body:warnings.map((item)=>`${item.label}: ${item.value}`).join(" · "),tag:"boat-health",renotify:true}); + new Notification("Boat Chat alert",{body:warnings.map((item)=>`${item.label}: ${item.value}`).join(" · "),tag:"boat-health",renotify:true}); } } @@ -346,35 +366,58 @@ function addAisRadar(card, ais) { if (!targets.length) { const empty=document.createElement("p"); empty.className="empty-state"; empty.textContent=ais?.error ? `AIS unavailable: ${ais.error}` : "No positioned AIS targets nearby."; card.appendChild(empty); return; } const range=Math.max(1,Math.min(10,Math.ceil(Math.max(...targets.map((item)=>Number(item.distance_nm)))))); const svg=document.createElementNS("http://www.w3.org/2000/svg","svg"); svg.classList.add("radar"); svg.setAttribute("viewBox","0 0 300 300"); svg.setAttribute("role","img"); svg.setAttribute("aria-label",`${targets.length} AIS targets within ${range} nautical miles`); - [50,100,140].forEach((radius)=>{const circle=document.createElementNS(svg.namespaceURI,"circle"); circle.setAttribute("cx","150"); circle.setAttribute("cy","150"); circle.setAttribute("r",String(radius)); circle.setAttribute("fill","none"); circle.setAttribute("stroke","#bdd0d2"); circle.setAttribute("stroke-width","1"); svg.appendChild(circle);}); - const own=document.createElementNS(svg.namespaceURI,"circle"); own.setAttribute("cx","150"); own.setAttribute("cy","150"); own.setAttribute("r","6"); own.setAttribute("fill","currentColor"); svg.appendChild(own); - targets.forEach((target)=>{const angle=(Number(target.bearing_deg)-90)*Math.PI/180; const radius=Math.min(136,(Number(target.distance_nm)/range)*136); const dot=document.createElementNS(svg.namespaceURI,"circle"); dot.setAttribute("cx",String(150+Math.cos(angle)*radius)); dot.setAttribute("cy",String(150+Math.sin(angle)*radius)); dot.setAttribute("r",target.position_stale ? "3" : "5"); dot.setAttribute("fill",target.position_stale ? "#d98a17" : "#176b70"); const title=document.createElementNS(svg.namespaceURI,"title"); title.textContent=`${target.name || "Unknown"}: ${target.distance_nm} nm`; dot.appendChild(title); svg.appendChild(dot);}); card.appendChild(svg); + [50,100,140].forEach((radius)=>{const circle=document.createElementNS(svg.namespaceURI,"circle"); circle.setAttribute("class","radar-ring"); circle.setAttribute("cx","150"); circle.setAttribute("cy","150"); circle.setAttribute("r",String(radius)); svg.appendChild(circle);}); + const own=document.createElementNS(svg.namespaceURI,"circle"); own.setAttribute("class","radar-own"); own.setAttribute("cx","150"); own.setAttribute("cy","150"); own.setAttribute("r","6"); svg.appendChild(own); + targets.forEach((target)=>{const angle=(Number(target.bearing_deg)-90)*Math.PI/180; const radius=Math.min(136,(Number(target.distance_nm)/range)*136); const dot=document.createElementNS(svg.namespaceURI,"circle"); dot.setAttribute("cx",String(150+Math.cos(angle)*radius)); dot.setAttribute("cy",String(150+Math.sin(angle)*radius)); dot.setAttribute("r",target.position_stale ? "3" : "5"); dot.setAttribute("class",target.position_stale ? "radar-target is-stale" : "radar-target"); const title=document.createElementNS(svg.namespaceURI,"title"); title.textContent=`${target.name || "Unknown"}: ${target.distance_nm} nm`; dot.appendChild(title); svg.appendChild(dot);}); card.appendChild(svg); const list=document.createElement("ol"); list.className="ais-list"; targets.slice(0,5).forEach((target)=>{const row=document.createElement("li"); row.textContent=`${target.name || "Unknown"} · ${target.distance_nm} nm · ${target.bearing_deg}°${target.position_stale ? " · stale" : ""}`; list.appendChild(row);}); card.appendChild(list); } function downloadTripReport(trip) { - const lines=["VesselStack trip report",`Window: ${trip.start_local} to ${trip.stop_local}`,`Duration: ${trip.duration_minutes ?? "unknown"} minutes`,"",trip.trip_summary || "No summary recorded."]; - const blob=new Blob([`${lines.join("\n")}\n`],{type:"text/plain"}); const url=URL.createObjectURL(blob); const link=document.createElement("a"); link.href=url; link.download=`theoffice-trip-${(trip.start_local || "report").slice(0,10)}.txt`; link.click(); URL.revokeObjectURL(url); + const lines=["Boat trip report",`Window: ${trip.start_local} to ${trip.stop_local}`,`Duration: ${trip.duration_minutes ?? "unknown"} minutes`,"",trip.trip_summary || "No summary recorded."]; + const blob=new Blob([`${lines.join("\n")}\n`],{type:"text/plain"}); const url=URL.createObjectURL(blob); const link=document.createElement("a"); link.href=url; link.download=`vesselstack-trip-${(trip.start_local || "report").slice(0,10)}.txt`; link.click(); URL.revokeObjectURL(url); } +function dueLabel(status) { return status === "overdue" ? "Overdue" : status === "soon" ? "Due soon" : ""; } + async function loadInsights() { - const token=sessionStorage.getItem("boatChatAccessToken") || ""; - insightsContent.textContent="Loading boat insights…"; + const token=accessCode(); + insightsContent.textContent="Loading Boat Hub…"; if (accessTokenRequired && !token) { insightsContent.textContent="Choose Connect to sign in to this device and view your trips and maintenance."; return; } try { - const response=await fetch("/api/insights",{headers:{"X-Boat-Chat-Token":token}}); const data=await response.json(); - if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); insightsContent.replaceChildren(); + const data=await fetchJson("/api/insights",{headers:{"X-Boat-Chat-Token":token}}); insightsContent.replaceChildren(); const alerts=hubCard(`Boat · ${data.status.condition}`); const alertList=document.createElement("ul"); alertList.className="alert-list"; - data.alerts.forEach((item)=>{const row=document.createElement("li"); row.className="alert-row"; row.dataset.severity=item.severity; const label=document.createElement("span"); label.className="alert-label"; const dot=document.createElement("i"); dot.className="alert-dot"; const name=document.createElement("span"); name.textContent=item.label; label.append(dot,name); const value=document.createElement("strong"); value.textContent=item.value; row.append(label,value); alertList.appendChild(row);}); alerts.appendChild(alertList); const enableAlerts=document.createElement("button"); enableAlerts.type="button"; enableAlerts.className="alert-opt-in"; enableAlerts.textContent=localStorage.getItem("boatChatBrowserAlerts")==="on" ? "Browser alerts enabled" : "Enable browser alerts"; enableAlerts.addEventListener("click",()=>enableBrowserAlerts(enableAlerts)); alerts.appendChild(enableAlerts); processProactiveAlerts(data); + data.alerts.forEach((item)=>{const row=document.createElement("li"); row.className="alert-row"; row.dataset.severity=item.severity; const label=document.createElement("span"); label.className="alert-label"; const dot=document.createElement("i"); dot.className="alert-dot"; dot.setAttribute("aria-hidden","true"); const name=document.createElement("span"); name.textContent=item.label; label.append(dot,name); const value=document.createElement("strong"); const level=document.createElement("span"); level.className="sr-only"; level.textContent=`${item.severity}: `; value.append(level,document.createTextNode(item.value)); row.append(label,value); alertList.appendChild(row);}); alerts.appendChild(alertList); const enableAlerts=document.createElement("button"); enableAlerts.type="button"; enableAlerts.className="alert-opt-in"; enableAlerts.textContent=localStorage.getItem("boatChatBrowserAlerts")==="on" ? "Browser alerts enabled" : "Enable browser alerts"; enableAlerts.addEventListener("click",()=>enableBrowserAlerts(enableAlerts)); alerts.appendChild(enableAlerts); processProactiveAlerts(data); const trip=hubCard("Last recorded trip"); if (data.trip) { const summary=document.createElement("p"); summary.textContent=data.trip.trip_summary || `${data.trip.start_local} to ${data.trip.stop_local}`; const actions=document.createElement("div"); actions.className="trip-actions"; const askTrip=document.createElement("button"); askTrip.type="button"; askTrip.textContent="Analyze trip"; askTrip.addEventListener("click",()=>{insightsDialog.close();ask("Give me a detailed report for the last trip");}); const download=document.createElement("button"); download.type="button"; download.textContent="Download report"; download.addEventListener("click",()=>downloadTripReport(data.trip)); actions.append(askTrip,download); trip.append(summary,actions); } else { const empty=document.createElement("p"); empty.className="empty-state"; empty.textContent="No recorded trip window is available yet."; trip.appendChild(empty); } const position=hubCard("Vessel position"); - if (data.position) { const coords=document.createElement("strong"); coords.textContent=data.position.label; const note=document.createElement("p"); note.className="empty-state"; note.textContent="Kept local; no coordinates are sent to a map provider."; position.append(coords,note); } else { const empty=document.createElement("p"); empty.className="empty-state"; empty.textContent="Position is not present in the cached SignalK snapshot."; position.appendChild(empty); } + if (data.position) { const coords=document.createElement("strong"); coords.textContent=data.position.label; const note=document.createElement("p"); note.className="empty-state"; note.textContent="Kept local; no coordinates are sent to a map provider."; position.append(coords,note); } else { const empty=document.createElement("p"); empty.className="empty-state"; empty.textContent="Position is not present in the latest Signal K data."; position.appendChild(empty); } const ais=hubCard(`Nearby AIS · ${data.ais?.target_count || 0}`,true); addAisRadar(ais,data.ais); const maintenance=hubCard(`Maintenance · ${data.maintenance_counts?.open || 0} open${data.maintenance_counts?.overdue ? ` · ${data.maintenance_counts.overdue} overdue` : ""}`,true); const list=document.createElement("ul"); list.className="maintenance-list"; + const maintenanceError=document.createElement("p"); maintenanceError.className="form-error"; maintenanceError.setAttribute("role","alert"); maintenanceError.hidden=true; + const showMaintenanceError=(error)=>{ maintenanceError.textContent=`Could not save: ${error.message}`; maintenanceError.hidden=false; }; if (!data.maintenance.length) { const empty=document.createElement("li"); empty.className="empty-state"; empty.textContent="No maintenance tasks yet."; list.appendChild(empty); } - data.maintenance.forEach((task)=>{const row=document.createElement("li"); row.className="maintenance-row"; row.dataset.due=task.due_status; const check=document.createElement("input"); check.type="checkbox"; check.checked=task.completed; check.addEventListener("change",()=>saveMaintenance({...task,completed:check.checked})); const copy=document.createElement("span"); copy.className="maintenance-copy"; const titleText=document.createElement("strong"); titleText.textContent=`${task.title}${task.due_date ? ` · due ${task.due_date}` : ""}`; copy.appendChild(titleText); if(task.notes){const note=document.createElement("small"); note.textContent=task.notes; copy.appendChild(note);} const edit=document.createElement("button"); edit.type="button"; edit.textContent="Notes"; edit.addEventListener("click",()=>{const notes=window.prompt("Maintenance notes",task.notes || ""); if(notes!==null) saveMaintenance({...task,notes});}); row.append(check,copy,edit); list.appendChild(row);}); - const addForm=document.createElement("form"); addForm.className="maintenance-form"; const title=document.createElement("input"); title.required=true; title.placeholder="New maintenance task"; const due=document.createElement("input"); due.type="date"; const add=document.createElement("button"); add.type="submit"; add.textContent="Add task"; addForm.append(title,due,add); addForm.addEventListener("submit",async(event)=>{event.preventDefault(); add.disabled=true; try { await saveMaintenance({title:title.value,due_date:due.value}); } catch(error) { window.alert(error.message); add.disabled=false; }}); maintenance.append(list,addForm); + data.maintenance.forEach((task)=>{ + const row=document.createElement("li"); row.className="maintenance-row"; row.dataset.due=task.due_status; + const check=document.createElement("input"); check.type="checkbox"; check.checked=task.completed; check.setAttribute("aria-label",`Mark done: ${task.title}`); + check.addEventListener("change",async()=>{ maintenanceError.hidden=true; check.disabled=true; try { await saveMaintenance({...task,completed:check.checked}); } catch(error) { check.checked=!check.checked; check.disabled=false; showMaintenanceError(error); } }); + const copy=document.createElement("span"); copy.className="maintenance-copy"; const titleText=document.createElement("strong"); titleText.textContent=`${task.title}${task.due_date ? ` · due ${task.due_date}` : ""}`; + if (dueLabel(task.due_status)) { const badge=document.createElement("span"); badge.className="due-badge"; badge.textContent=dueLabel(task.due_status); titleText.appendChild(badge); } + copy.appendChild(titleText); if(task.notes){const note=document.createElement("small"); note.textContent=task.notes; copy.appendChild(note);} + const edit=document.createElement("button"); edit.type="button"; edit.textContent="Notes"; edit.setAttribute("aria-label",`Edit notes: ${task.title}`); + edit.addEventListener("click",()=>{ + if (copy.querySelector(".inline-form")) return; + const noteForm=document.createElement("form"); noteForm.className="inline-form"; + const noteInput=document.createElement("input"); noteInput.value=task.notes || ""; noteInput.setAttribute("aria-label",`Maintenance notes for ${task.title}`); + const saveNote=document.createElement("button"); saveNote.type="submit"; saveNote.textContent="Save notes"; + const cancelNote=document.createElement("button"); cancelNote.type="button"; cancelNote.textContent="Cancel"; cancelNote.addEventListener("click",()=>noteForm.remove()); + noteForm.addEventListener("submit",async(event)=>{ event.preventDefault(); maintenanceError.hidden=true; saveNote.disabled=true; try { await saveMaintenance({...task,notes:noteInput.value}); } catch(error) { saveNote.disabled=false; showMaintenanceError(error); } }); + noteForm.append(noteInput,saveNote,cancelNote); copy.appendChild(noteForm); noteInput.focus(); + }); + row.append(check,copy,edit); list.appendChild(row); + }); + const addForm=document.createElement("form"); addForm.className="maintenance-form"; const title=document.createElement("input"); title.required=true; title.placeholder="New maintenance task"; title.setAttribute("aria-label","New maintenance task"); const due=document.createElement("input"); due.type="date"; due.setAttribute("aria-label","Due date (optional)"); const add=document.createElement("button"); add.type="submit"; add.textContent="Add task"; addForm.append(title,due,add); + addForm.addEventListener("submit",async(event)=>{event.preventDefault(); maintenanceError.hidden=true; add.disabled=true; try { await saveMaintenance({title:title.value,due_date:due.value}); } catch(error) { showMaintenanceError(error); add.disabled=false; }}); + maintenance.append(list,addForm,maintenanceError); insightsContent.append(alerts,trip,position,ais,maintenance); } catch(error) { insightsContent.textContent=`Boat Hub unavailable: ${error.message}`; } } @@ -383,28 +426,39 @@ function addFeedbackControls(messageElement, requestId, question, feedbackSessio if (!requestId) return; const controls = document.createElement("div"); controls.className = "feedback-controls"; + const noteForm = document.createElement("form"); noteForm.className = "inline-form"; noteForm.hidden = true; + const noteInput = document.createElement("input"); noteInput.placeholder = "What could be improved? (optional)"; noteInput.setAttribute("aria-label", "What could be improved? (optional)"); + const noteSend = document.createElement("button"); noteSend.type = "submit"; noteSend.textContent = "Send feedback"; + const noteCancel = document.createElement("button"); noteCancel.type = "button"; noteCancel.textContent = "Cancel"; + const noteError = document.createElement("p"); noteError.className = "form-error"; noteError.setAttribute("role", "alert"); noteError.hidden = true; + noteForm.append(noteInput, noteSend, noteCancel); + let pendingRating = ""; + async function sendFeedback(rating, note, button) { + const token = accessCode(); + const headers = {"Content-Type":"application/json"}; + if (token) headers["X-Boat-Chat-Token"] = token; + button.disabled = true; noteError.hidden = true; + try { + const response = await fetch("/api/feedback", {method:"POST", headers, body:JSON.stringify({request_id:requestId, session_id:feedbackSessionId, rating, note})}); + if (!response.ok) throw new Error("Feedback could not be saved"); + controls.textContent = "Feedback saved"; noteForm.remove(); noteError.remove(); + } catch { + button.disabled = false; noteError.textContent = "Feedback could not be saved. Try again."; noteError.hidden = false; + } + } + noteForm.addEventListener("submit", (event) => { event.preventDefault(); sendFeedback(pendingRating, noteInput.value.trim(), noteSend); }); + noteCancel.addEventListener("click", () => { noteForm.hidden = true; noteError.hidden = true; }); [["Helpful", "helpful"], ["Incomplete", "incomplete"], ["Wrong", "wrong"]].forEach(([label, rating]) => { const button = document.createElement("button"); button.type = "button"; button.textContent = label; - button.addEventListener("click", async () => { - const token = sessionStorage.getItem("boatChatAccessToken") || ""; - const headers = {"Content-Type":"application/json"}; - if (token) headers["X-Boat-Chat-Token"] = token; - const note = rating === "helpful" ? "" : (window.prompt("What could be improved? (optional)", "") || ""); - button.disabled = true; - try { - const response = await fetch("/api/feedback", {method:"POST", headers, body:JSON.stringify({request_id:requestId, session_id:feedbackSessionId, rating, note})}); - if (!response.ok) throw new Error("Feedback could not be saved"); - controls.textContent = "Feedback saved"; - } catch { - button.disabled = false; - button.textContent = "Retry feedback"; - } + button.addEventListener("click", () => { + if (rating === "helpful") { sendFeedback(rating, "", button); return; } + pendingRating = rating; noteForm.hidden = false; noteInput.focus(); }); controls.appendChild(button); }); const save=document.createElement("button"); save.type="button"; save.textContent="Save question"; save.addEventListener("click",()=>{saveQuestion(question); save.textContent="Saved";}); controls.appendChild(save); - messageElement.appendChild(controls); + messageElement.append(controls, noteForm, noteError); } function addMetricCards(messageElement, metrics) { @@ -441,45 +495,58 @@ async function ask(message) { addMessage("user", message); const pending = addMessage("assistant", "Waiting for your boat's answer…"); pending.classList.add("is-pending"); + const counter = document.createElement("span"); counter.className = "count-hint"; counter.setAttribute("aria-hidden", "true"); + (pending.querySelector("p") || pending).appendChild(counter); setStatus("Working", "busy"); setBusy(true); + messages.setAttribute("aria-busy", "true"); const controller = new AbortController(); activeController = controller; + let timedOut = false; let statusIsError = false; + const timeoutId = setTimeout(() => { timedOut = true; controller.abort(); }, CHAT_TIMEOUT_MS); const began = Date.now(); - progressTimer = setInterval(() => { pending.textContent = `Waiting for your boat's answer… ${Math.floor((Date.now() - began) / 1000)}s`; }, 1000); + progressTimer = setInterval(() => { counter.textContent = ` ${Math.floor((Date.now() - began) / 1000)}s`; }, 1000); try { - const token = sessionStorage.getItem("boatChatAccessToken") || ""; + const token = accessCode(); const headers = {"Content-Type": "application/json"}; if (token) headers["X-Boat-Chat-Token"] = token; const response = await fetch("/api/chat", {method:"POST", headers, signal:controller.signal, body:JSON.stringify({message, history, session_id:requestSession, answer_mode:answerMode.value})}); - const data = await response.json().catch(() => ({})); - if (!response.ok) { const error = new Error(data.error || `HTTP ${response.status}`); error.status = response.status; throw error; } - if (typeof data.answer !== "string" || !data.answer.trim()) throw new Error("The boat returned an empty answer."); + const data = await response.json().catch(() => null); + if (!response.ok) { const error = new Error((data && data.error) || `HTTP ${response.status}`); error.status = response.status; throw error; } + if (!data || typeof data.answer !== "string" || !data.answer.trim()) throw new Error("The boat returned an empty answer."); clearInterval(progressTimer); pending.classList.remove("is-pending"); pending.replaceChildren(); renderAnswer(pending, data.answer); const turn = {role:"assistant", content:data.answer, experience:data.experience || {}, request_id:data.request_id, answered_at:new Date().toISOString(), session_id:requestSession}; decorateAnswer(pending, turn, message); conversation.push({role:"user", content:message}, turn); conversation = conversation.slice(-8); - setContext(null); - setStatus(persistConversation() ? "Answer ready" : "Answer ready · history not saved"); + const saved = persistConversation(); + announce(saved ? "Answer ready" : "Answer ready. History could not be saved in this browser."); messages.scrollTop = messages.scrollHeight; } catch (error) { clearInterval(progressTimer); pending.classList.remove("is-pending"); pending.replaceChildren(); - const cancelled = error.name === "AbortError"; - const explanation = cancelled ? "Stopped waiting. The boat may still finish processing this question." : - error.status === 401 ? "Connect this device to ask questions. Your question is kept below." : - error.status === 429 ? "Boat Chat is busy. Wait a moment, then retry." : "Couldn't get an answer. Check your connection and try again."; + const cancelled = error.name === "AbortError" && !timedOut; + const explanation = timedOut ? "The boat did not answer within 3 minutes. Your question is back in the message box; try again or ask something simpler." : + cancelled ? "Stopped waiting. The boat may still finish processing this question. Your question is back in the message box." : + error.status === 401 ? "Connect this device to ask questions. Your question is back in the message box." : + error.status === 429 ? "Boat Chat is busy. Wait a moment, then try again. Your question is back in the message box." : + "Couldn't get an answer. Check your connection and try again. Your question is back in the message box."; renderAnswer(pending, explanation); + announce(explanation); + // Put the question back so nothing typed is lost (unless the user already started a new one). + if (!input.value.trim()) { input.value = message; resizeInput(); } // A cancelled/failed request can still finish on the server. Isolate its session // so that an unseen answer cannot contaminate the next conversational turn. sessionId = createSessionId(); sessionStorage.setItem("boatChatSessionId", sessionId); const actions = document.createElement("div"); actions.className = "followups"; - if(error.status === 401) { const connect=document.createElement("button"); connect.type="button"; connect.textContent="Connect"; connect.addEventListener("click",()=>connectDialog.showModal()); actions.appendChild(connect); } - const retry=document.createElement("button"); retry.type="button"; retry.textContent="Retry question"; retry.addEventListener("click",()=>ask(message)); - const edit=document.createElement("button"); edit.type="button"; edit.textContent="Edit question"; edit.addEventListener("click",()=>{input.value=message;resizeInput();input.focus();}); actions.append(retry,edit); pending.appendChild(actions); - setStatus(cancelled ? "Stopped waiting" : error.status === 401 ? "Sign-in needed" : "Try again", cancelled ? "ready" : "error"); + if(error.status === 401) { const connect=document.createElement("button"); connect.type="button"; connect.textContent="Connect"; connect.addEventListener("click",openConnect); actions.appendChild(connect); } + const retry=document.createElement("button"); retry.type="button"; retry.textContent="Retry question"; retry.addEventListener("click",()=>{ if (input.value.trim() === message) { input.value = ""; resizeInput(); } ask(message); }); + actions.appendChild(retry); pending.appendChild(actions); + statusIsError = !cancelled; + setStatus(timedOut ? "Timed out" : cancelled ? "Stopped waiting" : error.status === 401 ? "Sign-in needed" : "Try again", cancelled ? "ready" : "error"); } finally { - clearInterval(progressTimer); progressTimer = null; activeController = null; setBusy(false); + clearTimeout(timeoutId); clearInterval(progressTimer); progressTimer = null; activeController = null; setBusy(false); + messages.removeAttribute("aria-busy"); + if (!statusIsError) refreshConnectionStatus(); } } @@ -488,15 +555,17 @@ cancelButton.addEventListener("click", () => activeController?.abort()); async function loadHealth() { try { const response = await fetch("/health"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); accessTokenRequired = data.access_token_required === true; updateAccessControls(); - setStatus(data.ready ? "Connected" : "Limited data", data.ready ? "ready" : "error"); + healthState = data.ready ? "ready" : "limited"; setRoute(data.provider || "local", data.fallback_provider || "", data.models || {}); } catch (error) { - setStatus("Offline", "error"); + healthState = "offline"; setRoute("local", "", {}); } + refreshConnectionStatus(); } function setSettingValue(name, value) { @@ -605,7 +674,7 @@ function updateProviderSettingsState() { const providers = String(section.dataset.providerConfig || "") .split(/\s+/) .filter(Boolean); - section.classList.toggle("is-relevant", providers.includes(primary) || providers.includes(fallback)); + section.hidden = !(providers.includes(primary) || providers.includes(fallback)); }); } @@ -620,15 +689,36 @@ async function loadModelCatalog() { } } +let settingsLoaded = false; +let initialSettings = {}; +const REQUIRED_SETTING_LABELS = { + BOAT_CHAT_PROVIDER: "Primary provider", + BOAT_CHAT_MAX_TOKENS: "Max tokens", + BOAT_CHAT_CONTEXT_CHARS: "Context chars", + BOAT_CHAT_HOST: "Host", + BOAT_CHAT_PORT: "Port", +}; + +function currentSettingValues() { + const values = {}; + for (const [key, value] of new FormData(settingsForm).entries()) values[key] = String(value).trim(); + values.__model = selectedModel("primary"); + values.__fallbackModel = selectedModel("fallback"); + return values; +} + async function loadSettings() { - const accessToken = sessionStorage.getItem("boatChatAccessToken") || ""; + settingsLoaded = false; + const accessToken = accessCode(); const response = await fetch("/api/settings", { headers: accessToken ? { "X-Boat-Chat-Token": accessToken } : {} }); - const data = await response.json(); + const data = await response.json().catch(() => null); if (response.status === 401) { - throw new Error("Connect this device (access token) before opening settings."); + const error = new Error("Connect this device with your access code before opening Settings."); + error.status = 401; + throw error; } - if (!response.ok) { - throw new Error(data.error || `HTTP ${response.status}`); + if (!response.ok || !data || typeof data !== "object") { + throw new Error((data && data.error) || `The boat returned an error (HTTP ${response.status}).`); } loadedSettings = data.settings || {}; const currentSettingsToken = document.querySelector("#setting-current-token"); @@ -670,6 +760,9 @@ async function loadSettings() { const key = item.dataset.secret; item.textContent = data.secrets?.[key] ? "saved" : ""; }); + document.querySelectorAll("#settings-form input[type=password]").forEach((field) => { if (field.name) field.value = ""; }); + initialSettings = currentSettingValues(); + settingsLoaded = true; } function settingsToken() { @@ -678,32 +771,71 @@ function settingsToken() { return typed || sessionStorage.getItem("boatChatSettingsToken") || ""; } -async function saveSettings(event) { - event.preventDefault(); - settingsMessage.textContent = "Saving..."; - const formData = new FormData(settingsForm); - const settings = {}; - for (const [key, value] of formData.entries()) { - const text = String(value).trim(); - if (secretKeys.has(key) && !text) continue; - settings[key] = text; +function showSettingsError(message, needsConnect) { + settingsForm.dataset.state = "error"; + document.querySelector("#settings-error").hidden = false; + document.querySelector("#settings-error-text").textContent = message; + document.querySelector("#settings-connect").hidden = !needsConnect; + settingsMessage.textContent = ""; +} +function showSettingsReady() { + settingsForm.dataset.state = "ok"; + document.querySelector("#settings-error").hidden = true; +} +async function openSettings() { + settingsMessage.textContent = ""; + try { + await loadModelCatalog(); + await loadSettings(); + showSettingsReady(); + } catch (error) { + settingsLoaded = false; + showSettingsError(error.status === 401 ? error.message : `Settings could not be loaded: ${error.message}`, error.status === 401); } + if (!settingsDialog.open) settingsDialog.showModal(); +} - const primaryProvider = settings.BOAT_CHAT_PROVIDER || ""; - const primaryModel = selectedModel("primary"); - const primaryKey = primaryModelKey(primaryProvider); - settings[primaryKey] = primaryModel; - if (primaryKey !== "BOAT_CHAT_MODEL") { +// Only fields the user actually changed are sent, so an untouched or partly loaded form +// can never overwrite saved settings with blanks. +function changedSettings() { + const now = currentSettingValues(); + const settings = {}; + for (const [key, value] of Object.entries(now)) { + if (key.startsWith("__")) continue; + if (secretKeys.has(key)) { if (value) settings[key] = value; continue; } + if (value === (initialSettings[key] ?? "")) continue; + if (value === "" && REQUIRED_SETTING_LABELS[key]) throw new Error(`${REQUIRED_SETTING_LABELS[key]} cannot be blank.`); + settings[key] = value === "" ? null : value; + } + const provider = now.BOAT_CHAT_PROVIDER || ""; + const primaryKey = primaryModelKey(provider); + if (now.__model !== initialSettings.__model || provider !== (initialSettings.BOAT_CHAT_PROVIDER ?? "")) { + settings[primaryKey] = now.__model || null; // Clear the generic key so a stale value can't shadow the CLI model. - settings.BOAT_CHAT_MODEL = ""; + if (primaryKey !== "BOAT_CHAT_MODEL" && loadedSettings.BOAT_CHAT_MODEL) settings.BOAT_CHAT_MODEL = null; + } + const fallbackProvider = now.BOAT_CHAT_FALLBACK_PROVIDER || ""; + if (now.__fallbackModel !== initialSettings.__fallbackModel || fallbackProvider !== (initialSettings.BOAT_CHAT_FALLBACK_PROVIDER ?? "")) { + settings.BOAT_CHAT_FALLBACK_MODEL = fallbackProvider ? (now.__fallbackModel || null) : null; } - settings.BOAT_CHAT_FALLBACK_MODEL = settings.BOAT_CHAT_FALLBACK_PROVIDER ? selectedModel("fallback") : ""; + return settings; +} + +async function saveSettings(event) { + event.preventDefault(); + if (!settingsLoaded) { settingsMessage.textContent = "Settings are not loaded. Close this window and open Settings again."; return; } + let settings; + try { settings = changedSettings(); } catch (error) { settingsMessage.textContent = error.message; return; } + if (!Object.keys(settings).length) { settingsMessage.textContent = "No changes to save."; return; } + settingsMessage.textContent = "Saving..."; const token = settingsToken(); + const accessToken = accessCode(); const headers = { "Content-Type": "application/json" }; - if (token) { - headers["X-Boat-Chat-Token"] = token; - } + // X-Boat-Chat-Token carries the admin code; the access code travels separately so one + // request can prove both when they differ. + if (token || accessToken) headers["X-Boat-Chat-Token"] = token || accessToken; + if (accessToken) headers["X-Boat-Chat-Access-Token"] = accessToken; try { const response = await fetch("/api/settings", { @@ -711,9 +843,11 @@ async function saveSettings(event) { headers, body: JSON.stringify({ settings }), }); - const data = await response.json(); + const data = await response.json().catch(() => null); if (!response.ok) { - throw new Error(data.error || `HTTP ${response.status}`); + if (response.status === 401) throw new Error("Access code needed or not accepted. Close Settings and choose Connect."); + if (response.status === 403) throw new Error("Settings can only be changed from this device, or with the correct admin code."); + throw new Error((data && data.error) || `HTTP ${response.status}`); } if (settings.BOAT_CHAT_SETTINGS_TOKEN || token) { sessionStorage.setItem("boatChatSettingsToken", settings.BOAT_CHAT_SETTINGS_TOKEN || token); @@ -750,6 +884,8 @@ input.addEventListener("focus", () => { }); input.addEventListener("keydown", (event) => { + // Enter confirms an IME composition (e.g. Japanese, Chinese); it must not send. + if (event.isComposing || event.keyCode === 229) return; if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); const message = input.value.trim(); @@ -767,43 +903,51 @@ document.querySelectorAll("[data-prompt]").forEach((button) => { }); }); +let clearArmTimer = null; +function disarmClear() { + clearTimeout(clearArmTimer); clearArmTimer = null; + clearButton.textContent = "Clear"; + clearButton.removeAttribute("aria-label"); +} clearButton.addEventListener("click", () => { + if (conversation.length && !clearArmTimer) { + // Two-step confirmation: the second press within 5 seconds clears. + clearButton.textContent = "Confirm clear"; + clearButton.setAttribute("aria-label", "Confirm: clear this conversation"); + announce("Press Clear again to confirm clearing this conversation."); + clearArmTimer = setTimeout(disarmClear, 5000); + return; + } + disarmClear(); const clearedSession = sessionId; sessionId = createSessionId(); sessionStorage.setItem("boatChatSessionId", sessionId); conversation = []; sessionStorage.removeItem("boatChatConversation"); - const token = sessionStorage.getItem("boatChatAccessToken") || ""; + const token = accessCode(); const headers = {"Content-Type":"application/json"}; if (token) headers["X-Boat-Chat-Token"] = token; fetch("/api/session/clear", {method:"POST", headers, body:JSON.stringify({session_id:clearedSession})}).catch(() => {}); messages.replaceChildren(); - setContext(null); addWelcomeMessage(); + announce("Conversation cleared"); input.focus(); }); document.querySelector("#capabilities-open").addEventListener("click", async () => { - capabilitiesContent.textContent = "Loading capabilities…"; capabilitiesDialog.showModal(); - try { const data=await (await fetch("/api/capabilities")).json(); capabilitiesContent.replaceChildren(); data.groups.forEach((group)=>{ const section=document.createElement("section"); section.className="capability-group"; const title=document.createElement("h2"); title.textContent=group.name; const prompts=document.createElement("div"); prompts.className="capability-prompts"; group.prompts.forEach((prompt)=>{ const button=document.createElement("button"); button.type="button"; button.textContent=prompt; button.addEventListener("click",()=>{capabilitiesDialog.close(); ask(prompt);}); prompts.appendChild(button); }); section.append(title,prompts); capabilitiesContent.appendChild(section); }); } catch(error) { capabilitiesContent.textContent="Capabilities are unavailable."; } + capabilitiesContent.textContent = "Loading the list of things you can ask…"; capabilitiesDialog.showModal(); + try { const data=await fetchJson("/api/capabilities"); capabilitiesContent.replaceChildren(); data.groups.forEach((group)=>{ const section=document.createElement("section"); section.className="capability-group"; const title=document.createElement("h2"); title.textContent=group.name; const prompts=document.createElement("div"); prompts.className="capability-prompts"; group.prompts.forEach((prompt)=>{ const button=document.createElement("button"); button.type="button"; button.textContent=prompt; button.addEventListener("click",()=>{capabilitiesDialog.close(); ask(prompt);}); prompts.appendChild(button); }); section.append(title,prompts); capabilitiesContent.appendChild(section); }); } catch(error) { capabilitiesContent.textContent=`The list of things you can ask is unavailable: ${error.message}`; } }); document.querySelector("#capabilities-close").addEventListener("click",()=>capabilitiesDialog.close()); document.querySelector("#insights-open").addEventListener("click",()=>{insightsDialog.showModal();loadInsights();}); document.querySelector("#maintenance-open").addEventListener("click",()=>{insightsDialog.showModal();loadInsights();}); document.querySelector("#insights-close").addEventListener("click",()=>insightsDialog.close()); -setInterval(async()=>{if(localStorage.getItem("boatChatBrowserAlerts")!=="on") return; const token=sessionStorage.getItem("boatChatAccessToken")||""; if(accessTokenRequired && !token) return; try {const response=await fetch("/api/insights",{headers:{"X-Boat-Chat-Token":token}}); if(response.ok) processProactiveAlerts(await response.json());} catch(error) { /* Retry on the next interval. */ }},60000); +setInterval(async()=>{if(localStorage.getItem("boatChatBrowserAlerts")!=="on") return; const token=accessCode(); if(accessTokenRequired && !token) return; try {const response=await fetch("/api/insights",{headers:{"X-Boat-Chat-Token":token}}); if(response.ok) processProactiveAlerts(await response.json());} catch(error) { /* Retry on the next interval. */ }},60000); -document.querySelector("#settings-open").addEventListener("click", async () => { - settingsMessage.textContent = ""; - try { - await loadModelCatalog(); - await loadSettings(); - } catch (error) { - settingsMessage.textContent = `Load failed: ${error.message}`; - } - settingsDialog.showModal(); -}); +document.querySelector("#settings-open").addEventListener("click", openSettings); +document.querySelector("#settings-retry").addEventListener("click", openSettings); +document.querySelector("#settings-connect").addEventListener("click", () => { settingsDialog.close(); openConnect(); }); document.querySelector("#settings-close").addEventListener("click", () => { settingsDialog.close(); @@ -837,12 +981,25 @@ function addWelcomeMessage() { } addMessage( "assistant", - "Ask about the boat now, investigate a change, or review a trip. I’ll show the evidence with each answer." + "Ask about the boat now, look into a change, or review a trip. Each answer shows where its information came from." ); } -setContext(null); addWelcomeMessage(); renderSavedQuestions(); loadHealth(); loadBoatStatus(); + +// Header: on phones everything except the two view toggles lives under "More". +const moreMenu = document.querySelector(".more-menu"); +const narrowHeader = window.matchMedia("(max-width: 480px)"); +function syncMoreMenu() { moreMenu.open = !narrowHeader.matches; } +syncMoreMenu(); +narrowHeader.addEventListener("change", syncMoreMenu); +moreMenu.querySelector(".more-items").addEventListener("click", (event) => { + if (narrowHeader.matches && event.target.closest("button")) moreMenu.open = false; +}); +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && narrowHeader.matches && moreMenu.open) { moreMenu.open = false; moreMenu.querySelector("summary").focus(); } +}); +showView(document.body.dataset.view || "overview"); diff --git a/boat-chat/static/index.html b/boat-chat/static/index.html index 6f57aea..48faeff 100644 --- a/boat-chat/static/index.html +++ b/boat-chat/static/index.html @@ -3,10 +3,11 @@ - + + VesselStack Boat Chat - +
@@ -14,32 +15,39 @@
-

VesselStack Boat Chat

-

Telemetry-backed assistant for VesselStack.

+

Boat Chat

+

Ask questions about your boat, answered from its own recorded data.

-
+
Starting
- - - - - - +
+ + +
+
+ More +
+ + + + +
+
- - - - + + + +
-

THEOFFICE · YOUR BOAT

Connect to your boat

+

YOUR BOAT

Connect to your boat

Connect this device to view boat observations.

Needs attention

Boat status has not been checked.

@@ -52,17 +60,10 @@

What would you like to do?

System status

Connect to check system status.

-
+
+
- - -
+
- + @@ -85,22 +86,27 @@

What would you like to do?

Connect this device

-

Enter your Boat Chat access token. It stays in this tab and is cleared when you disconnect.

- +

Enter your Boat Chat access code. It stays in this tab and is cleared when you disconnect.

+

-
+

Settings

+ +
- Routing + Models
-
+
+ Advanced +
+
Telegram
@@ -347,42 +356,44 @@

Settings

+
+
- - + +

What Boat Chat can do

-
Loading capabilities…
+
Loading the list of things you can ask…

Boat Hub

Safety, trip, position, and maintenance at a glance.

-
Loading boat insights…
+
Loading Boat Hub…
- + diff --git a/boat-chat/static/styles.css b/boat-chat/static/styles.css index 1bda73b..65af00f 100644 --- a/boat-chat/static/styles.css +++ b/boat-chat/static/styles.css @@ -1,21 +1,31 @@ +/* Brand tokens: light (default) and dark. Brand reference: brand/README.md. + Fonts load only from the visitor's system; there are no external requests. */ :root { color-scheme: light; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #f3f6f7; - color: #10181d; - --bg: #f3f6f7; + --font-body: "Public Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-head: Archivo, "Public Sans", ui-sans-serif, system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-family: var(--font-body); + background: var(--bg); + color: var(--text); + --bg: #f4f8fa; --panel: #ffffff; - --panel-soft: #e9eff2; - --border: #c4cdd2; - --border-strong: #7f9099; - --text: #10181d; - --muted: #40515b; - --accent: #075f7e; - --accent-strong: #064b64; - --accent-soft: #cce8f3; - --ok: #207456; - --warning: #8a5b12; - --danger: #a83f36; + --panel-soft: #eaf2f6; + --border: #d4e0e8; + --border-strong: #778b9b; + --text: #0a2341; + --muted: #526778; + --accent: #006d89; + --accent-strong: #00566d; + --accent-soft: #dceef3; + --on-accent: #ffffff; + --ok: #1e6f45; + --ok-soft: #e1f1e8; + --warning: #8a6218; + --warning-soft: #f8eed6; + --danger: #b3382e; + --danger-soft: #fae5e2; + --backdrop: rgba(10, 35, 65, 0.45); } * { @@ -151,13 +161,13 @@ h2 { display: inline-flex; align-items: center; justify-content: center; - border: 1px solid #b7c3cc; + border: 1px solid var(--ok); border-radius: 999px; padding: 5px 10px; font-size: 12px; font-weight: 700; color: var(--ok); - background: #f7fbf8; + background: var(--ok-soft); white-space: nowrap; } @@ -165,20 +175,21 @@ h2 { max-width: 260px; color: var(--text); background: var(--panel-soft); + border-color: var(--border-strong); overflow: hidden; text-overflow: ellipsis; } .status.is-busy { color: var(--warning); - background: #fff8ec; - border-color: #e1c58d; + background: var(--warning-soft); + border-color: var(--warning); } .status.is-error { color: var(--danger); - background: #fff2f0; - border-color: #e2aaa4; + background: var(--danger-soft); + border-color: var(--danger); } .examples { @@ -204,9 +215,9 @@ h2 { .status-card { min-height:62px; padding:9px 11px; text-align:left; border-radius:8px; background:var(--panel); } .status-card span { display:block; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.04em; } .status-card strong { display:block; margin-top:5px; font-size:14px; } -.status-card[data-tone="ok"] { border-color:#9fcdbd; } -.status-card[data-tone="warning"] { border-color:#e1c58d; } -.status-card[data-tone="danger"] { border-color:#e2aaa4; } +.status-card[data-tone="ok"] { border-color:var(--ok); } +.status-card[data-tone="warning"] { border-color:var(--warning); } +.status-card[data-tone="danger"] { border-color:var(--danger); } .messages { flex: 1; @@ -229,7 +240,7 @@ h2 { .message.user { margin-left: auto; background: var(--accent-soft); - border: 1px solid #75aabd; + border: 1px solid var(--accent); border-radius: 8px; padding: 11px 13px; } @@ -264,68 +275,6 @@ h2 { color: var(--muted); } -.context-drawer { - margin: 0 24px 14px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--panel); -} - -.context-drawer summary { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 42px; - padding: 10px 12px; - color: var(--text); - cursor: pointer; - font-size: 13px; - font-weight: 700; - list-style: none; -} - -.context-drawer summary::-webkit-details-marker { - display: none; -} - -.context-drawer summary::after { - content: "+"; - flex: 0 0 auto; - color: var(--muted); - font-weight: 800; -} - -.context-drawer[open] summary::after { - content: "-"; -} - -#context-summary { - flex: 1; - min-width: 0; - color: var(--muted); - font-weight: 600; - text-align: right; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.answer-details { - width: 100%; - max-height: min(420px, 55vh); - margin: 0; - overflow: auto; - border-top: 1px solid var(--border); - padding: 12px; - background: var(--panel-soft); - font: 13px/1.45 inherit; - color: var(--text); -} - -.evidence-list { display:grid; gap:8px; margin:0; padding:12px; list-style:none; } -.evidence-list li { display:flex; justify-content:space-between; gap:12px; } -.evidence-list span { color:var(--muted); text-align:right; } .followups { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; } .followups button { min-height:32px; padding:6px 9px; font-size:12px; color:var(--accent-strong); } .capabilities-panel { min-width:min(700px,92vw); padding:18px; } @@ -337,14 +286,23 @@ h2 { .alert-list,.maintenance-list { display:grid; gap:7px; margin:0; padding:0; list-style:none; } .alert-row,.maintenance-row { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:7px 0; border-bottom:1px solid var(--border); } .alert-row:last-child,.maintenance-row:last-child { border-bottom:0; } -.alert-dot { width:9px; height:9px; border-radius:50%; background:#2a9d68; flex:0 0 auto; } -.alert-row[data-severity="warning"] .alert-dot { background:#d98a17; } +.alert-dot { width:9px; height:9px; border-radius:50%; background:var(--ok); flex:0 0 auto; } +.alert-row[data-severity="warning"] .alert-dot { background:var(--warning); } +.alert-row[data-severity="danger"] .alert-dot { background:var(--danger); } +.alert-row[data-severity="unknown"] .alert-dot { background:var(--panel); border:2px solid var(--muted); } .alert-label { display:flex; align-items:center; gap:8px; } .position-link { display:inline-block; margin-top:8px; } .maintenance-form { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; margin-top:12px; } .maintenance-form input { min-width:0; border:1px solid var(--border-strong); border-radius:6px; padding:8px; } -.maintenance-row[data-due="overdue"] { color:#a84033; } -.maintenance-row[data-due="soon"] { color:#9a6410; } +.maintenance-row[data-due="overdue"] { color:var(--danger); box-shadow:inset 4px 0 0 var(--danger); padding-left:10px; } +.maintenance-row[data-due="soon"] { color:var(--warning); box-shadow:inset 4px 0 0 var(--warning); padding-left:10px; } +.due-badge { display:inline-block; margin-left:6px; padding:1px 7px; border:1px solid currentColor; border-radius:999px; font-size:11px; font-weight:700; background:var(--panel); vertical-align:1px; } +.maintenance-row[data-due="overdue"] .due-badge { background:var(--danger-soft); } +.maintenance-row[data-due="soon"] .due-badge { background:var(--warning-soft); } +.maintenance-row input[type="checkbox"] { width:24px; min-height:24px; flex:0 0 auto; accent-color:var(--accent); } +.inline-form { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; align-items:center; } +.inline-form input { flex:1 1 180px; min-width:0; } +.form-error { color:var(--danger); font-size:13px; margin-top:8px; } .maintenance-copy { display:grid; gap:2px; flex:1; } .maintenance-copy small { color:var(--muted); } .trip-actions { display:flex; flex-wrap:wrap; gap:8px; } @@ -383,8 +341,8 @@ textarea { align-items: flex-end; gap: 8px; } -.mode-control { display:flex; flex-direction:column; gap:3px; color:var(--muted); font-size:10px; } -.mode-control select { min-height:32px; border:1px solid var(--border-strong); border-radius:6px; background:var(--panel); color:var(--text); } +.mode-control { display:flex; flex-direction:column; gap:3px; color:var(--muted); font-size:12px; font-weight:600; } +.mode-control select { min-height:44px; border:1px solid var(--border-strong); border-radius:6px; background:var(--panel); color:var(--text); } .composer button { align-self: end; @@ -394,7 +352,7 @@ textarea { .composer #send-button { background: var(--accent); border-color: var(--accent); - color: #ffffff; + color: var(--on-accent); font-weight: 720; } @@ -419,7 +377,7 @@ dialog { } dialog::backdrop { - background: rgba(23, 32, 38, 0.45); + background: var(--backdrop); } .dialog-header, @@ -465,16 +423,19 @@ dialog::backdrop { } .settings-section[data-provider-config] { - order: 10; - background: var(--panel-soft); -} - -.settings-section.is-relevant { - order: 1; border-left-color: var(--accent); background: var(--panel-soft); } +.settings-advanced { border: 1px solid var(--border); border-radius: 8px; background: var(--panel); } +.settings-advanced > summary { min-height: 44px; display: flex; align-items: center; padding: 8px 13px; cursor: pointer; font-size: 13px; font-weight: 760; } +.settings-advanced-body { display: grid; gap: 12px; padding: 0 13px 13px; } +.settings-error { display: grid; gap: 12px; padding: 16px; } +.settings-error p { color: var(--danger); } +.settings-error .followups { margin-top: 0; } +form[data-state="error"] .settings-grid, form[data-state="error"] #settings-save { display: none; } +form[data-state="ok"] .settings-error { display: none; } + .settings-section legend { padding: 0 6px; font-size: 13px; @@ -497,7 +458,7 @@ dialog::backdrop { } .route-summary span { - border: 1px solid #b7c3cc; + border: 1px solid var(--border-strong); border-radius: 999px; padding: 5px 9px; font-size: 12px; @@ -609,10 +570,6 @@ select { max-width: 100%; } - .context-drawer { - margin: 0 14px 12px; - } - .composer { grid-template-columns: 1fr; gap: 10px; @@ -654,7 +611,6 @@ html[data-embedded="ha"] .app-header { } html[data-embedded="ha"] .header-brand p, -html[data-embedded="ha"] .brand-vessel, html[data-embedded="ha"] .status-secondary { display: none; } @@ -675,7 +631,8 @@ html[data-embedded="ha"] .status-stack { flex-wrap: nowrap; } -html[data-embedded="ha"] .header-actions > button { +html[data-embedded="ha"] .header-actions button, +html[data-embedded="ha"] .more-menu > summary { min-height: 34px; padding: 6px 9px; font-size: 12px; @@ -699,10 +656,6 @@ html[data-embedded="ha"] .messages { padding: 12px; } -html[data-embedded="ha"] .context-drawer { - margin: 0 12px 8px; -} - html[data-embedded="ha"] .composer { gap: 8px; padding: 9px 12px max(10px, env(safe-area-inset-bottom)); @@ -744,9 +697,6 @@ html[data-embedded="ha"] textarea { padding: 10px; } - html[data-embedded="ha"] .context-drawer { - margin-inline: 10px; - } } @media (max-width: 420px) { @@ -754,81 +704,12 @@ html[data-embedded="ha"] textarea { display: none; } - html[data-embedded="ha"] .header-actions > button { + html[data-embedded="ha"] .header-actions button, +html[data-embedded="ha"] .more-menu > summary { padding-inline: 7px; } } -@media (prefers-color-scheme: dark) { - :root { - color-scheme: dark; - --bg: #091014; - --panel: #151e23; - --panel-soft: #202c32; - --border: #52636c; - --border-strong: #83949c; - --text: #f7fafb; - --muted: #c4d0d5; - --accent: #1685aa; - --accent-strong: #79c7e1; - --accent-soft: #17495c; - --ok: #82d9b5; - --warning: #f1c36e; - --danger: #ff9d94; - } - - button:hover, - .secondary-button { - background: #2b3940; - } - - button, - input, - select, - textarea, - dialog, - .message.assistant, - .message.user, - .context-drawer, - .settings-section, - .status-card { - color: #ffffff; - } - - .message.assistant, - textarea, - input, - select, - dialog, - .context-drawer, - .settings-section, - .status-card { - background: #111a1f; - } - - .message.user { - background: #174f64; - border-color: #76c8e3; - } - - .status, - .status-secondary, - .route-summary span, - .answer-details, - .settings-section[data-provider-config] { - background: #1d292f; - color: var(--text); - } - - .status.is-busy { - background: #372e1d; - } - - .status.is-error { - background: #3b2423; - } -} - @media (min-width: 1180px) { .message.assistant { max-width: 980px; @@ -851,7 +732,7 @@ html[data-embedded="ha"] textarea { .status-card[data-tone="unknown"] { border-style:dashed; color:var(--muted); } .owner-overview[data-outdated="true"] .status-card { border-style:dashed; } #observation-age { color:var(--muted); } -body[data-view="overview"] .messages, body[data-view="overview"] .examples, body[data-view="overview"] .context-drawer { display:none; } +body[data-view="overview"] .messages, body[data-view="overview"] .examples { display:none; } body[data-view="chat"] .owner-overview { display:none; } #connect-dialog { max-width:480px; } #connect-dialog input { width:100%; margin-top:8px; } @@ -882,21 +763,47 @@ body[data-view="overview"] #capabilities-open, body[data-view="overview"] #insig .message-evidence ul { padding-left:1.3em; } .feedback-controls { flex-wrap:wrap; } -/* Chart-room presentation tokens; existing selectors retain behavior. */ -:root { --bg:#f4f8fa; --panel:#fff; --panel-soft:#eaf2f6; --border:#d4e0e8; --border-strong:#778b9b; --text:#0a2341; --muted:#526778; --accent:#006d89; --accent-strong:#00566d; --accent-soft:#dceef3; --ok:#1e6f45; --warning:#8a6218; --danger:#b3382e; font-family:"Public Sans",ui-sans-serif,system-ui,sans-serif; } +/* Chart-room presentation: non-token rules. */ body { color:var(--text); background:var(--bg); } -h1,h2,h3 { font-family:Archivo,ui-sans-serif,system-ui,sans-serif; } button,input,select,textarea { min-height:44px; border-radius:8px; } -button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible { outline:3px solid #006d89; outline-offset:2px; } -@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms!important; animation-duration:.01ms!important; } } +h1,h2,h3 { font-family:var(--font-head); } button,input,select,textarea { min-height:44px; border-radius:8px; } +code,pre { font-family:var(--font-mono); } +button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible { outline:3px solid var(--accent); outline-offset:2px; } +@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms!important; animation-duration:.01ms!important; scroll-behavior:auto!important; } } .header-icon { width:42px; height:42px; border-radius:8px; vertical-align:middle; margin-right:10px; } .header-brand { display:flex; align-items:center; gap:10px; } @media (prefers-color-scheme:dark) { - :root { --bg:#0a1826; --panel:#10253a; --panel-soft:#173247; --border:#36536a; --border-strong:#7892a5; --text:#eaf2f6; --muted:#b3c5d0; --accent:#4aaebe; --accent-strong:#72c6ce; --accent-soft:#173d50; --ok:#78c99b; --warning:#e5bd68; --danger:#f08a80; } -body { background:var(--bg); color:var(--text); } - .app-header,.examples,.composer,.message.assistant,.context-drawer,dialog,.dialog-header,.dialog-footer,.settings-section { background:var(--panel); color:var(--text); } - button,input,select,textarea { background:var(--panel); color:var(--text); } - .message.user { background:var(--accent-soft); border-color:var(--border); } - .answer-details,pre,.secondary-button { background:var(--panel-soft); color:var(--text); } - .composer #send-button { color:#04121c; } - button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible { outline-color:#4aaebe; } -} + :root { + color-scheme: dark; + --bg:#0a1826; --panel:#10253a; --panel-soft:#173247; --border:#36536a; --border-strong:#7892a5; + --text:#eaf2f6; --muted:#b3c5d0; --accent:#4aaebe; --accent-strong:#72c6ce; --accent-soft:#173d50; + --on-accent:#04121c; --ok:#78c99b; --ok-soft:#16382d; --warning:#e5bd68; --warning-soft:#3a3015; + --danger:#f08a80; --danger-soft:#3c2222; --backdrop:rgba(2, 8, 14, 0.7); + } +} + +/* Header: view toggles stay visible; the other buttons collapse into "More" on phones. */ +.view-toggle { display:flex; gap:6px; } +.view-toggle button[aria-pressed="true"] { background:var(--accent-soft); border-color:var(--accent); color:var(--text); font-weight:700; box-shadow:inset 0 -3px 0 var(--accent); } +.more-menu { position:relative; } +.more-menu > summary { display:none; align-items:center; justify-content:center; min-height:44px; padding:9px 12px; border:1px solid var(--border-strong); border-radius:8px; background:var(--panel); cursor:pointer; list-style:none; } +.more-menu > summary::-webkit-details-marker { display:none; } +.more-items { display:flex; flex-wrap:wrap; align-items:center; gap:10px; } +@media (max-width:480px) { + .more-menu > summary { display:inline-flex; } + .more-menu[open] > summary { background:var(--panel-soft); } + .more-items { position:absolute; right:0; top:calc(100% + 6px); z-index:5; flex-direction:column; align-items:stretch; min-width:190px; padding:8px; background:var(--panel); border:1px solid var(--border-strong); border-radius:8px; box-shadow:0 8px 24px var(--backdrop); } + .header-actions { justify-content:space-between; } + .view-toggle { flex:1 1 auto; } + .view-toggle button { flex:1 1 0; } + .examples button { white-space:nowrap; } +} +@media (max-width:480px) { + html[data-embedded="ha"] .more-items { top:calc(100% + 4px); } +} +.messages:focus-visible { outline:3px solid var(--accent); outline-offset:-3px; } +.count-hint { color:var(--muted); } +.setting-help { color:var(--muted); font-size:12px; font-weight:400; line-height:1.35; } +.radar-ring { fill:none; stroke:var(--border-strong); stroke-width:1; } +.radar-own { fill:currentColor; } +.radar-target { fill:var(--accent-strong); } +.radar-target.is-stale { fill:var(--warning); } diff --git a/boat-chat/status_model.py b/boat-chat/status_model.py index c087972..1e1be98 100644 --- a/boat-chat/status_model.py +++ b/boat-chat/status_model.py @@ -67,7 +67,7 @@ def display(value, unit, precision='g'): mode = 'Underway' if underway == 'on' else f'{watch} watch' if watch.lower() in {'dock','away','anchor'} else 'Unavailable' card('mode', 'Watch mode', mode, 'normal' if mode != 'Unavailable' else 'unknown', 'Give me a current boat status briefing') charging = state('binary_sensor.shore_power_connected') - card('shore', 'Charging proxy', {'on':'Charging detected','off':'No charging detected'}.get(charging,'Unavailable'), 'normal' if charging in {'on','off'} else 'unknown', 'What is the current shore power status?') + card('shore', 'Charging', {'on':'Charging detected','off':'No charging detected'}.get(charging,'Unavailable'), 'normal' if charging in {'on','off'} else 'unknown', 'What is the current shore power status?') soc = measured('electrical.batteries.shunt.capacity.stateOfCharge', True) voltage = measured('electrical.batteries.shunt.voltage') card('battery','Battery',display(soc,'%')+' · '+display(voltage,' V','.2f'), 'unknown' if soc is None or voltage is None else 'warning' if soc < 20 else 'normal', 'Tell me about the battery right now') diff --git a/control-panel/app.py b/control-panel/app.py index a093443..327b5c0 100644 --- a/control-panel/app.py +++ b/control-panel/app.py @@ -16,6 +16,7 @@ import threading import time import urllib.parse +import zoneinfo from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -83,92 +84,127 @@ def field( "config/vesselstack.env and re-apply the configuration); see the README, Rotating secrets." ) +SEC_VESSEL = "Vessel" +SEC_NETWORK = "Network & access" +SEC_CHAT = "Boat Chat" +SEC_SIGNALK = "Signal K & NMEA 2000" +SEC_DISPLAYS = "Displays" +SEC_STORAGE = "Storage & history" +SEC_SERVICES = "Services & ports" +SEC_AIS = "AIS receiver" +SEC_HA = "Home Assistant" +SEC_INSTALL = "Installation" + +# Settings sections in display order. Advanced sections start collapsed in the UI. +SECTIONS = [ + {"name": SEC_VESSEL, "lead": "Who the boat is and how numbers are shown.", "advanced": False}, + {"name": SEC_NETWORK, "lead": "Who can reach the boat computer and its web pages.", "advanced": False}, + {"name": SEC_CHAT, "lead": "The assistant you can ask about your boat, and Telegram alerts.", "advanced": False}, + {"name": SEC_SIGNALK, "lead": "How boat data gets in.", "advanced": False}, + {"name": SEC_DISPLAYS, "lead": "Instrument displays that show boat data.", "advanced": False}, + {"name": SEC_STORAGE, "lead": "How long data is kept, where backups go, and the database settings.", "advanced": True}, + {"name": SEC_SERVICES, "lead": "Ports and accounts for the individual services.", "advanced": True}, + {"name": SEC_AIS, "lead": "The AIS-catcher receiver that shows nearby ships.", "advanced": True}, + {"name": SEC_HA, "lead": "How VesselStack reaches Home Assistant.", "advanced": True}, + {"name": SEC_INSTALL, "lead": "Read-only here. Change these from a recovery shell with a reviewed migration.", "advanced": True}, +] + FIELDS = [ - field("BOAT_NAME", "Boat name", "Vessel", description="Display name used across VesselStack."), - field("BOAT_TYPE", "Boat type", "Vessel"), - field("BOAT_MMSI", "MMSI", "Vessel", kind="secret", description="Optional private vessel identity; never commit it."), - field("BOAT_CALLSIGN", "Call sign", "Vessel", kind="secret", description="Optional private vessel identity; never commit it."), - field("BOAT_LOA_M", "Length overall (m)", "Vessel", kind="number"), - field("BOAT_BEAM_M", "Beam (m)", "Vessel", kind="number"), - field("BOAT_TIMEZONE", "Timezone", "Vessel", description="IANA timezone such as America/Los_Angeles."), - field("BOAT_UNITS", "Display units", "Vessel", kind="select", choices=["us_customary", "metric"]), - field("VESSELSTACK_USER", "Service account", "Installation", read_only=True, description="Change from the recovery shell with a reviewed migration."), - field("VESSELSTACK_ROOT", "Application path", "Installation", kind="path", read_only=True, description="Change from the recovery shell with a reviewed migration."), - field("VESSELSTACK_DATA", "Data path", "Installation", kind="path", read_only=True, description="Relocate data only with a stopped stack and verified backup."), - field("VESSELSTACK_BACKUP", "Backup path", "Installation", kind="path"), - field("VESSELSTACK_DISABLED_COMPONENTS", "Disabled components", "Installation", read_only=True, description="Read-only membership summary; use Components to add or remove core services."), - field("SIGNALK_MODE", "SignalK mode", "SignalK", kind="select", choices=["existing", "docker", "native"]), - field("SIGNALK_VERSION", "SignalK version", "SignalK"), - field("SIGNALK_URL", "SignalK URL", "SignalK", kind="url"), - field("SIGNALK_VESSELTWIN_PRELOAD", "Preload VesselTwin plugin", "SignalK", kind="boolean", description="Managed Docker/native modes install the pinned signalk-vesseltwin plugin before SignalK starts. Existing mode is never modified; false skips installation and never uninstalls."), - field("SOCKETCAN_ENABLE", "Enable SocketCAN", "SignalK", kind="boolean"), - field("SOCKETCAN_INTERFACE", "CAN interface", "SignalK"), - field("SOCKETCAN_BITRATE", "CAN bitrate", "SignalK", kind="number"), - field("AIS_ENABLE", "Enable AIS-catcher", "AIS", kind="boolean"), - field("AIS_IMAGE", "AIS image", "AIS"), - field("AIS_DEVICE", "AIS device", "AIS", kind="path"), - field("AIS_CATCHER_ARGS", "AIS-catcher arguments", "AIS"), - field("AIS_WEB_PORT", "AIS web port", "AIS", kind="port"), - field("AIS_TCP_PORT", "AIS TCP output port", "AIS", kind="port"), - field("HOME_ASSISTANT_URL", "Home Assistant URL", "Home Assistant", kind="url"), - field("HOME_ASSISTANT_TOKEN", "Home Assistant token", "Home Assistant", kind="secret"), - field("INFLUXDB_URL", "InfluxDB URL", "InfluxDB", kind="url"), - field("VESSELSTACK_DATA_BIND_ADDRESS", "Data services bind address", "Network", description="InfluxDB, Prometheus and MQTT publish on this IPv4 address. Keep 127.0.0.1 unless external devices must connect over a trusted network."), - field("VESSELSTACK_WEB_BIND_ADDRESS", "Web UI bind address", "Network", description="Grafana and Heimdall publish on this IPv4 address (0.0.0.0 = every interface, 127.0.0.1 = this device only)."), - field("HOMEASSISTANT_BIND_ADDRESS", "Home Assistant bind address", "Network", description="0.0.0.0 = every interface, 127.0.0.1 = this device only. Applied through a managed http block in Home Assistant's configuration.yaml by the installer or `vesselstackctl lan`; SignalK has no such setting."), - field("INFLUXDB_PORT", "Published port", "InfluxDB", kind="port"), - field("INFLUXDB_CONTAINER_NAME", "Container name", "InfluxDB"), - field("INFLUXDB_ORG", "Organization", "InfluxDB"), - field("INFLUXDB_USERNAME", "Admin username", "InfluxDB"), - field("INFLUXDB_RAW_BUCKET", "Raw bucket", "InfluxDB"), - field("INFLUXDB_HISTORY_BUCKET", "History bucket", "InfluxDB"), - field("INFLUXDB_HOME_ASSISTANT_BUCKET", "Home Assistant bucket", "InfluxDB"), - field("INFLUXDB_AIS_BUCKET", "AIS bucket", "InfluxDB"), - field("INFLUXDB_RAW_RETENTION", "Raw bucket retention", "InfluxDB", description="Duration such as 720h, 30d or 8w, or 0 to keep forever. Empty = 30d on new installs and no change on existing ones. A value is applied to the existing raw bucket on the next apply/start and permanently deletes raw data older than the window."), - field("PROMETHEUS_RETENTION", "Prometheus retention", "Prometheus", description="Duration such as 720h, 30d or 8w. Empty = 30d. Metrics older than this are deleted by Prometheus."), - field("INFLUXDB_PASSWORD", "Admin password", "InfluxDB", kind="secret", read_only=True, description=INIT_ONLY_NOTE.format(service="InfluxDB")), - field("INFLUXDB_TOKEN", "Admin token", "InfluxDB", kind="secret", read_only=True, description=INIT_ONLY_NOTE.format(service="InfluxDB") + " Boat Chat and Grafana also use this token."), - field("GRAFANA_ADMIN_PASSWORD", "Grafana admin password", "Grafana", kind="secret", read_only=True, description=INIT_ONLY_NOTE.format(service="Grafana")), - field("GRAFANA_PORT", "Published port", "Grafana", kind="port"), - field("HEIMDALL_PORT", "HTTP port", "Heimdall", kind="port"), - field("HEIMDALL_HTTPS_PORT", "HTTPS port", "Heimdall", kind="port"), - field("PROMETHEUS_PORT", "Published port", "Prometheus", kind="port"), - field("MQTT_USERNAME", "MQTT username", "MQTT"), - field("MQTT_PASSWORD", "MQTT password", "MQTT", kind="secret", description="Applied to the Mosquitto password file on the next apply/start; update Home Assistant's MQTT integration to match."), - field("MQTT_PORT", "Published port", "MQTT", kind="port"), - field("DISPLAYS_ENABLE", "Enable instrument displays", "Displays", kind="boolean", description="Adds the scoped bridge and per-display read-only MQTT accounts to the Mosquitto access list."), - field("NODE_EXPORTER_ENABLE", "Enable host metrics", "Prometheus", kind="boolean", description="Runs the optional hardened host metrics exporter on the private Compose metrics profile."), - field("DISPLAYS_BRIDGE_MQTT_USERNAME", "Displays bridge MQTT username", "Displays", description="Letters, numbers, dot, underscore and hyphen; must differ from the MQTT username."), - field("DISPLAYS_SIGNALK_TOKEN", "Displays bridge SignalK token", "Displays", kind="secret", description="Optional. Sent as a bearer token when SignalK requires authentication; leave blank otherwise."), - field("DISPLAYS_HA_TOKEN", "Displays bridge Home Assistant token", "Displays", kind="secret", description="Optional, only for Home Assistant items (ha) in displays.json; leave blank otherwise. A leaked token can operate every device in your Home Assistant; a non-admin user only prevents configuration changes. Keep it on the Pi (root-only), never reuse it, and revoke it in Home Assistant (Profile -> Security) if it leaks."), - field("VESSELSTACK_FIREWALL_ENABLE", "Enable interface firewall", "Network", kind="boolean"), - field("VESSELSTACK_UNTRUSTED_INTERFACE", "Untrusted interface", "Network"), - field("VESSELSTACK_FIREWALL_STRICT", "Strict firewall (allow-list only)", "Network", kind="boolean", description="Drops everything on the untrusted interface except the allow-lists; keep another way to reach the device."), - field("VESSELSTACK_FIREWALL_ALLOW_TCP", "Strict mode allowed TCP ports", "Network", description="Comma-separated ports or LOW:HIGH ranges; default 22."), - field("VESSELSTACK_FIREWALL_ALLOW_UDP", "Strict mode allowed UDP ports", "Network", description="Comma-separated ports or LOW:HIGH ranges; default none."), - field("CONTROL_PANEL_HOST", "Control panel host", "Control Panel", description="Keep 127.0.0.1 for local-only access. For a trusted LAN, use `sudo vesselstackctl panel-access lan`; this exposes unencrypted HTTP on every interface and may still be blocked by a strict firewall. Roll back with `sudo vesselstackctl panel-access local`."), - field("CONTROL_PANEL_PORT", "Control panel port", "Control Panel", kind="port"), - field("BOAT_CHAT_PROVIDER", "Primary provider", "Boat Chat", source="chat", kind="select", choices=["local", "codex_cli", "claude_cli", "openai", "vercel", "bedrock", "google", "ollama", "openai_compatible"]), - field("BOAT_CHAT_MODEL", "Primary model", "Boat Chat", source="chat"), - field("BOAT_CHAT_FALLBACK_PROVIDER", "Fallback provider", "Boat Chat", source="chat", kind="select", choices=["", "local", "codex_cli", "claude_cli", "openai", "vercel", "bedrock", "google", "ollama", "openai_compatible"]), - field("BOAT_CHAT_FALLBACK_MODEL", "Fallback model", "Boat Chat", source="chat"), - field("BOAT_CHAT_HOST", "Boat Chat host", "Boat Chat", source="chat"), - field("BOAT_CHAT_PORT", "Boat Chat port", "Boat Chat", source="chat", kind="port"), - field("TELEMETRY_INDEXER_ENABLE", "Refresh telemetry memory", "Boat Chat", kind="boolean"), - field("OLLAMA_HOST", "Ollama URL", "Boat Chat", source="chat", kind="url"), - field("BOAT_CHAT_BASE_URL", "Compatible API base URL", "Boat Chat", source="chat", kind="url"), - field("OPENAI_API_KEY", "OpenAI API key", "Boat Chat", source="chat", kind="secret"), - field("AI_GATEWAY_API_KEY", "Vercel AI Gateway key", "Boat Chat", source="chat", kind="secret"), - field("BOAT_CHAT_API_KEY", "Compatible API key", "Boat Chat", source="chat", kind="secret"), - field("AWS_REGION", "AWS region", "Boat Chat", source="chat"), - field("AWS_ACCESS_KEY_ID", "AWS access key", "Boat Chat", source="chat", kind="secret"), - field("AWS_SECRET_ACCESS_KEY", "AWS secret key", "Boat Chat", source="chat", kind="secret"), - field("GOOGLE_API_KEY", "Google API key", "Boat Chat", source="chat", kind="secret"), - field("GOOGLE_CLOUD_PROJECT", "Google Cloud project", "Boat Chat", source="chat"), - field("BOAT_CHAT_ACCESS_TOKEN", "Boat Chat access token", "Boat Chat", source="chat", kind="secret", description="Required by chat, status and data APIs and the Telegram bridge."), - field("TELEGRAM_BOT_TOKEN", "Telegram bot token", "Telegram", source="chat", kind="secret"), - field("TELEGRAM_ALLOWED_CHAT_IDS", "Allowed Telegram chat IDs", "Telegram", source="chat", kind="secret"), - field("TELEGRAM_ENABLE", "Run Telegram worker", "Telegram", kind="boolean"), + # Vessel + field("BOAT_NAME", "Boat name", SEC_VESSEL, description="Shown in headers, alerts and Boat Chat."), + field("BOAT_TYPE", "Boat type", SEC_VESSEL, description="For example Motor vessel, Sailing yacht or Catamaran. Shown to people and Boat Chat; it does not change how anything is installed."), + field("BOAT_UNITS", "Display units", SEC_VESSEL, kind="select", choices=["us_customary", "metric"], description="US customary shows feet, degrees F, psi and US gallons; metric shows metres, degrees C, kPa and litres. Speeds stay in knots and distances in nautical miles either way."), + field("BOAT_TIMEZONE", "Timezone", SEC_VESSEL, description="A region and city name, such as Europe/Lisbon or America/Los_Angeles. Used for timestamps and daily summaries."), + field("BOAT_LOA_M", "Length overall (m)", SEC_VESSEL, kind="number", description="Shown to Boat Chat as part of your boat's details. A number above 0, up to 500."), + field("BOAT_BEAM_M", "Beam (m)", SEC_VESSEL, kind="number", description="Width at the widest point of the hull. A number above 0, up to 100."), + field("BOAT_MMSI", "MMSI", SEC_VESSEL, kind="secret", description="Optional. Your vessel's radio identity number. Kept private on this boat computer; never commit it."), + field("BOAT_CALLSIGN", "Call sign", SEC_VESSEL, kind="secret", description="Optional. Kept private on this boat computer; never commit it."), + # Network & access + field("VESSELSTACK_WEB_BIND_ADDRESS", "Web pages visible to", SEC_NETWORK, description="Grafana and Heimdall listen on this address. 127.0.0.1 means this device only; 0.0.0.0 means every device on the boat network."), + field("VESSELSTACK_DATA_BIND_ADDRESS", "Data services visible to", SEC_NETWORK, description="InfluxDB, Prometheus and MQTT listen on this address. Keep 127.0.0.1 unless another device must connect over a network you trust."), + field("HOMEASSISTANT_BIND_ADDRESS", "Home Assistant visible to", SEC_NETWORK, description="127.0.0.1 means this device only; 0.0.0.0 means every device on the boat network. Applied through a managed http block in Home Assistant's configuration.yaml when you apply your settings or run sudo vesselstackctl lan. Signal K has no such setting."), + field("CONTROL_PANEL_HOST", "Control Panel address", SEC_NETWORK, description="Keep 127.0.0.1 to reach the panel through an SSH tunnel only. To open it to a trusted network, run sudo vesselstackctl panel-access lan on the boat computer; that exposes unencrypted HTTP on every interface and a strict firewall may still block it. Undo it with sudo vesselstackctl panel-access local."), + field("CONTROL_PANEL_PORT", "Control Panel port", SEC_NETWORK, kind="port", description="The port the panel listens on. Restart the Control Panel after changing it."), + field("VESSELSTACK_FIREWALL_ENABLE", "Protect the boat network with a firewall", SEC_NETWORK, kind="boolean", description="Blocks incoming connections on the interface named below. Strict mode allows only the ports you list, so keep another way in."), + field("VESSELSTACK_UNTRUSTED_INTERFACE", "Untrusted network interface", SEC_NETWORK, description="The interface facing the marina or shore Wi-Fi, for example wlan0."), + field("VESSELSTACK_FIREWALL_STRICT", "Strict firewall (allow-list only)", SEC_NETWORK, kind="boolean", description="Drops everything on the untrusted interface except the allowed ports below; keep another way to reach the device."), + field("VESSELSTACK_FIREWALL_ALLOW_TCP", "Allowed TCP ports (strict mode)", SEC_NETWORK, description="Comma-separated ports or LOW:HIGH ranges, for example 22,8000:8100. Default 22."), + field("VESSELSTACK_FIREWALL_ALLOW_UDP", "Allowed UDP ports (strict mode)", SEC_NETWORK, description="Comma-separated ports or LOW:HIGH ranges. Default none."), + # Boat Chat + field("BOAT_CHAT_PROVIDER", "Primary provider", SEC_CHAT, source="chat", kind="select", choices=["local", "codex_cli", "claude_cli", "openai", "vercel", "bedrock", "google", "ollama", "openai_compatible"], description="Where answers come from. Local keeps everything on the boat."), + field("BOAT_CHAT_MODEL", "Primary model", SEC_CHAT, source="chat", description="Model name for the provider you picked."), + field("BOAT_CHAT_FALLBACK_PROVIDER", "Fallback provider", SEC_CHAT, source="chat", kind="select", choices=["", "local", "codex_cli", "claude_cli", "openai", "vercel", "bedrock", "google", "ollama", "openai_compatible"], description="Used when the primary provider cannot be reached, for example offshore. Optional."), + field("BOAT_CHAT_FALLBACK_MODEL", "Fallback model", SEC_CHAT, source="chat", description="Model name for the fallback provider."), + field("BOAT_CHAT_HOST", "Boat Chat address", SEC_CHAT, source="chat", description="The address Boat Chat listens on. 127.0.0.1 keeps it on this device."), + field("BOAT_CHAT_PORT", "Boat Chat port", SEC_CHAT, source="chat", kind="port", description="The port Boat Chat listens on."), + field("BOAT_CHAT_ACCESS_TOKEN", "Boat Chat access token", SEC_CHAT, source="chat", kind="secret", description="Required by chat, status and data pages and the Telegram bridge."), + field("TELEMETRY_INDEXER_ENABLE", "Refresh telemetry memory", SEC_CHAT, kind="boolean", description="Keeps Boat Chat's memory of your boat data up to date so it can answer questions about past readings."), + field("OLLAMA_HOST", "Ollama address", SEC_CHAT, source="chat", kind="url", description="Where your Ollama server is. Used only when Ollama is a provider."), + field("BOAT_CHAT_BASE_URL", "Compatible API base address", SEC_CHAT, source="chat", kind="url", description="Address of an OpenAI-compatible service. Used only with that provider."), + field("OPENAI_API_KEY", "OpenAI API key", SEC_CHAT, source="chat", kind="secret", description="Write-only. Used only when OpenAI is a provider."), + field("AI_GATEWAY_API_KEY", "Vercel AI Gateway key", SEC_CHAT, source="chat", kind="secret", description="Write-only. Used only when Vercel AI Gateway is a provider."), + field("BOAT_CHAT_API_KEY", "Compatible API key", SEC_CHAT, source="chat", kind="secret", description="Write-only. Used only with an OpenAI-compatible service."), + field("AWS_REGION", "AWS region", SEC_CHAT, source="chat", description="For example eu-west-1. Used only with Amazon Bedrock."), + field("AWS_ACCESS_KEY_ID", "AWS access key", SEC_CHAT, source="chat", kind="secret", description="Write-only. Used only with Amazon Bedrock."), + field("AWS_SECRET_ACCESS_KEY", "AWS secret key", SEC_CHAT, source="chat", kind="secret", description="Write-only. Used only with Amazon Bedrock."), + field("GOOGLE_API_KEY", "Google API key", SEC_CHAT, source="chat", kind="secret", description="Write-only. Used only when Google is a provider."), + field("GOOGLE_CLOUD_PROJECT", "Google Cloud project", SEC_CHAT, source="chat", description="The project name, if your Google setup needs one."), + field("TELEGRAM_ENABLE", "Send alerts to Telegram", SEC_CHAT, kind="boolean", description="Runs the Telegram alerts component. It also needs the bot token below."), + field("TELEGRAM_BOT_TOKEN", "Telegram bot token", SEC_CHAT, source="chat", kind="secret", description="From BotFather. Needed only for Telegram alerts."), + field("TELEGRAM_ALLOWED_CHAT_IDS", "Allowed Telegram chats", SEC_CHAT, source="chat", kind="secret", description="The Telegram chat IDs that may use the bot. Kept private."), + # Signal K & NMEA 2000 + field("SIGNALK_MODE", "Signal K mode", SEC_SIGNALK, kind="select", choices=["existing", "docker", "native"], description="Docker: VesselStack runs Signal K for you in a container (recommended). Existing: you already run a Signal K server and VesselStack only connects to it. Native: Signal K is installed directly on this Pi. Changing it takes effect when you apply your settings."), + field("SIGNALK_VERSION", "Signal K version", SEC_SIGNALK, description="Release tag of the Signal K container image, for example v2.27.0."), + field("SIGNALK_URL", "Signal K address", SEC_SIGNALK, kind="url", description="Where VesselStack reaches Signal K. Needed when the mode is Existing."), + field("SIGNALK_VESSELTWIN_PRELOAD", "Preload the VesselTwin plugin", SEC_SIGNALK, kind="boolean", description="Installs the pinned signalk-vesseltwin plugin before Signal K starts (Docker and Native modes). Existing mode is never modified; turning this off skips installation and never uninstalls."), + field("SOCKETCAN_ENABLE", "Read the NMEA 2000 network (SocketCAN)", SEC_SIGNALK, kind="boolean", description="Turn on only after the CAN adapter is installed and tested."), + field("SOCKETCAN_INTERFACE", "CAN interface", SEC_SIGNALK, description="Name of the CAN interface on this Pi, usually can0."), + field("SOCKETCAN_BITRATE", "CAN speed (bits per second)", SEC_SIGNALK, kind="number", description="NMEA 2000 always uses 250000. Change it only for a non-marine CAN network. Whole number from 10000 to 1000000."), + # Displays + field("DISPLAYS_ENABLE", "Enable instrument displays", SEC_DISPLAYS, kind="boolean", description="Lets the bridge publish boat data to each display, read-only. Adds the bridge and per-display accounts to the Mosquitto access list."), + field("DISPLAYS_BRIDGE_MQTT_USERNAME", "Displays bridge MQTT username", SEC_DISPLAYS, description="Letters, numbers, dot, underscore and hyphen. Must differ from the main MQTT username."), + field("DISPLAYS_SIGNALK_TOKEN", "Displays bridge Signal K token", SEC_DISPLAYS, kind="secret", description="Optional. Only needed if Signal K requires sign-in; leave blank otherwise."), + field("DISPLAYS_HA_TOKEN", "Displays bridge Home Assistant token", SEC_DISPLAYS, kind="secret", description="Optional, only for Home Assistant items (ha) in displays.json; leave blank otherwise. A leaked token can operate every device in your Home Assistant; a non-admin user only prevents configuration changes. Keep it on the Pi (root-only), never reuse it, and revoke it in Home Assistant (Profile, Security) if it leaks."), + # Advanced: Storage & history + field("INFLUXDB_RAW_RETENTION", "Keep raw data for", SEC_STORAGE, description="A duration such as 720h, 30d or 8w, or 0 to keep forever. Empty means 30d on new installs and no change on existing ones. A value is applied to the existing raw bucket on the next apply or start and permanently deletes raw data older than the window."), + field("PROMETHEUS_RETENTION", "Keep health numbers for", SEC_STORAGE, description="How long Prometheus keeps Pi and service health history: a duration such as 720h, 30d or 8w. Empty means 30d. Older metrics are deleted."), + field("VESSELSTACK_BACKUP", "Backup folder", SEC_STORAGE, kind="path", description="Where backups are saved. Use a mounted external drive under /media or /mnt so a failed SD card does not take the backups with it."), + field("INFLUXDB_URL", "InfluxDB address", SEC_STORAGE, kind="url", description="Where VesselStack reaches InfluxDB."), + field("INFLUXDB_ORG", "InfluxDB organization", SEC_STORAGE, description="The InfluxDB organization name."), + field("INFLUXDB_USERNAME", "InfluxDB admin username", SEC_STORAGE, description="The InfluxDB administrator account name."), + field("INFLUXDB_RAW_BUCKET", "Raw data bucket", SEC_STORAGE, description="The InfluxDB bucket that holds incoming readings."), + field("INFLUXDB_HISTORY_BUCKET", "History bucket", SEC_STORAGE, description="The InfluxDB bucket that holds the one-minute history averages."), + field("INFLUXDB_HOME_ASSISTANT_BUCKET", "Home Assistant bucket", SEC_STORAGE, description="The InfluxDB bucket that holds data sent from Home Assistant."), + field("INFLUXDB_AIS_BUCKET", "AIS bucket", SEC_STORAGE, description="The InfluxDB bucket that holds AIS targets."), + field("INFLUXDB_PASSWORD", "InfluxDB admin password", SEC_STORAGE, kind="secret", read_only=True, description=INIT_ONLY_NOTE.format(service="InfluxDB")), + field("INFLUXDB_TOKEN", "InfluxDB admin token", SEC_STORAGE, kind="secret", read_only=True, description=INIT_ONLY_NOTE.format(service="InfluxDB") + " Boat Chat and Grafana also use this token."), + field("GRAFANA_ADMIN_PASSWORD", "Grafana admin password", SEC_STORAGE, kind="secret", read_only=True, description=INIT_ONLY_NOTE.format(service="Grafana")), + # Advanced: Services & ports + field("INFLUXDB_PORT", "InfluxDB port", SEC_SERVICES, kind="port", description="The port InfluxDB is published on."), + field("INFLUXDB_CONTAINER_NAME", "InfluxDB container name", SEC_SERVICES, description="The Docker container name. Change only if it clashes with another container."), + field("GRAFANA_PORT", "Grafana port", SEC_SERVICES, kind="port", description="The port Grafana is published on."), + field("HEIMDALL_PORT", "Heimdall HTTP port", SEC_SERVICES, kind="port", description="The port Heimdall's start page is published on."), + field("HEIMDALL_HTTPS_PORT", "Heimdall HTTPS port", SEC_SERVICES, kind="port", description="The secure port Heimdall is published on."), + field("PROMETHEUS_PORT", "Prometheus port", SEC_SERVICES, kind="port", description="The port Prometheus is published on."), + field("MQTT_USERNAME", "MQTT username", SEC_SERVICES, description="The account name used for the Mosquitto message broker."), + field("MQTT_PASSWORD", "MQTT password", SEC_SERVICES, kind="secret", description="Applied to the Mosquitto password file on the next apply or start; update Home Assistant's MQTT integration to match."), + field("MQTT_PORT", "MQTT port", SEC_SERVICES, kind="port", description="The port Mosquitto is published on."), + field("NODE_EXPORTER_ENABLE", "Report Pi health numbers (Node Exporter)", SEC_SERVICES, kind="boolean", description="Runs the optional hardened host metrics exporter so Prometheus can record Pi temperature and disk use."), + # Advanced: AIS receiver + field("AIS_ENABLE", "Enable AIS-catcher", SEC_AIS, kind="boolean", description="Shows nearby ships from a USB AIS receiver. Turn on only when a receiver is plugged in."), + field("AIS_IMAGE", "AIS-catcher image", SEC_AIS, description="The Docker image to run. Change only if you were told to."), + field("AIS_DEVICE", "AIS receiver device", SEC_AIS, kind="path", description="Where the USB AIS receiver appears on this Pi. The default, /dev/bus/usb, works for most single receivers."), + field("AIS_CATCHER_ARGS", "AIS-catcher options", SEC_AIS, description="Extra command-line options for AIS-catcher. Leave empty unless you know you need them."), + field("AIS_WEB_PORT", "AIS web page port", SEC_AIS, kind="port", description="The port of the AIS-catcher web page."), + field("AIS_TCP_PORT", "AIS data output port", SEC_AIS, kind="port", description="The TCP port AIS-catcher sends decoded messages on."), + # Advanced: Home Assistant + field("HOME_ASSISTANT_URL", "Home Assistant address", SEC_HA, kind="url", description="Where VesselStack reaches Home Assistant."), + field("HOME_ASSISTANT_TOKEN", "Home Assistant token", SEC_HA, kind="secret", description="Write-only. Use a token from a non-admin user and revoke it in Home Assistant if it leaks."), + # Advanced: Installation (read-only) + field("VESSELSTACK_USER", "Service account", SEC_INSTALL, read_only=True, description="The account VesselStack services run as. Change it from the recovery shell with a reviewed migration."), + field("VESSELSTACK_ROOT", "Application folder", SEC_INSTALL, kind="path", read_only=True, description="Where VesselStack is installed. Change it from the recovery shell with a reviewed migration."), + field("VESSELSTACK_DATA", "Data folder", SEC_INSTALL, kind="path", read_only=True, description="Where your data lives. Move it only with services stopped and a verified backup."), + field("VESSELSTACK_DISABLED_COMPONENTS", "Disabled components", SEC_INSTALL, read_only=True, description="A read-only list. Use Disable and Enable on the Home tab to change which core services run."), ] FIELD_BY_KEY = {item["key"]: item for item in FIELDS} @@ -184,14 +220,28 @@ def field( "node-exporter": "node-exporter", } CORE_COMPONENTS = ("homeassistant", "influxdb", "grafana", "prometheus", "mosquitto", "heimdall") -CORE_METADATA = { - "homeassistant": ("Home Assistant", "Home automation and vessel dashboard", "Home Assistant"), - "influxdb": ("InfluxDB", "Time-series history storage", "InfluxDB"), - "grafana": ("Grafana", "History and system dashboards", "Grafana"), - "prometheus": ("Prometheus", "Metrics collection", ""), - "mosquitto": ("Mosquitto", "MQTT message broker", "MQTT"), - "heimdall": ("Heimdall", "Local service launcher", ""), +# Display name, plain description and the Settings section that configures it. +COMPONENT_INFO = { + "homeassistant": ("Home Assistant", "Dashboards and automations for the boat.", SEC_HA), + "influxdb": ("InfluxDB", "Stores the boat's data history.", SEC_STORAGE), + "grafana": ("Grafana", "Charts of your history: trips, batteries, temperatures.", SEC_SERVICES), + "prometheus": ("Prometheus", "Collects Pi and service health numbers.", SEC_STORAGE), + "mosquitto": ("Mosquitto", "Passes messages between devices (MQTT).", SEC_SERVICES), + "heimdall": ("Heimdall", "A start page with links to every boat web page.", SEC_SERVICES), + "signalk": ("Signal K", "Reads boat data and shares it with everything else.", SEC_SIGNALK), + "ais-catcher": ("AIS-catcher", "Shows nearby ships from your AIS receiver.", SEC_AIS), + "node-exporter": ("Node Exporter", "Reports Pi health (temperature, disk) for Prometheus.", SEC_SERVICES), + "boat-chat": ("Boat Chat", "Ask questions about your boat in plain language.", SEC_CHAT), + "telegram": ("Telegram alerts", "Sends boat alerts to your phone through Telegram.", SEC_CHAT), + "telemetry-indexer": ("Telemetry memory", "Keeps Boat Chat's memory of your boat data up to date.", SEC_CHAT), + "telemetry-sample": ("Telemetry sampler", "Records regular samples of your boat data for Boat Chat.", SEC_CHAT), + "displays-bridge": ("Displays bridge", "Feeds data to the boat's instrument displays.", SEC_DISPLAYS), + "signalk-native": ("Signal K (on this Pi)", "Signal K installed directly on this Pi instead of in a container.", SEC_SIGNALK), + "socketcan": ("SocketCAN", "Connects to the NMEA 2000 network through a CAN adapter.", SEC_SIGNALK), + "firewall": ("Network firewall", "Blocks unwanted connections on the marina-facing network.", SEC_NETWORK), + "control-panel": ("Control Panel", "This page. Restart it from the Maintenance tab.", ""), } +RUNNING_STATES = {"running", "active", "activating", "reloading", "restarting"} LIFECYCLE_LOCK = threading.Lock() @@ -261,6 +311,48 @@ def write_env(path: Path, values: dict[str, str], preferred_order: list[str]) -> pass +# Allowed range for each plain-number setting: (minimum, maximum, whole number only). +# The minimum is exclusive for sizes (zero metres is not a boat) and inclusive otherwise. +NUMBER_RULES: dict[str, tuple[float, float, bool, bool]] = { + "BOAT_LOA_M": (0, 500, False, True), + "BOAT_BEAM_M": (0, 100, False, True), + "SOCKETCAN_BITRATE": (10000, 1000000, True, False), +} +NUMBER_TEXT = re.compile(r"[0-9]+(\.[0-9]+)?") + + +def validate_number(spec: dict[str, Any], text: str) -> None: + label = spec["label"] + if not NUMBER_TEXT.fullmatch(text): + raise ValueError(f"{label} must be a plain number such as 12.5 (no units, signs or exponents)") + minimum, maximum, whole, exclusive = NUMBER_RULES.get(spec["key"], (0, 1e12, False, True)) + if whole and "." in text: + raise ValueError(f"{label} must be a whole number") + value = float(text) + low_ok = value > minimum if exclusive else value >= minimum + if not low_ok or value > maximum or value != value: + shown_min = f"more than {minimum:g}" if exclusive else f"at least {minimum:g}" + raise ValueError(f"{label} must be {shown_min} and at most {maximum:g}") + + +def timezone_exists(name: str) -> bool: + try: + known = zoneinfo.available_timezones() + except Exception: + return True + # Without a timezone database nothing can be checked; do not block saving. + return not known or name in known or name == "UTC" + + +class FieldErrors(ValueError): + """Validation failed for one or more settings; ``fields`` maps key to message.""" + + def __init__(self, fields: dict[str, str]) -> None: + self.fields = dict(fields) + first = next(iter(self.fields.values())) + super().__init__(first if len(self.fields) == 1 else f"{len(self.fields)} settings need fixing. {first}") + + MQTT_ACCOUNT_NAME = re.compile(r"[A-Za-z0-9_.-]{1,64}") DEFAULT_BRIDGE_MQTT_USERNAME = "vesselstack-bridge" @@ -281,14 +373,15 @@ def validate_value(spec: dict[str, Any], value: Any) -> str: if len(values) != len(set(values)) or any(item not in CORE_COMPONENTS for item in values): raise ValueError("Disabled components must be unique allowed core component IDs") return ",".join(values) - if kind in {"number", "port"} and text: - try: - float(text) - except ValueError as exc: - raise ValueError(f"{spec['label']} must be numeric") from exc if kind == "port" and text: - if not text.isdigit() or not 1 <= int(text) <= 65535: + if not re.fullmatch(r"[0-9]+", text): + raise ValueError(f"{spec['label']} must be numeric") + if not 1 <= int(text) <= 65535: raise ValueError(f"{spec['label']} must be an integer from 1 to 65535") + if kind == "number" and text: + validate_number(spec, text) + if spec["key"] == "BOAT_TIMEZONE" and text and not timezone_exists(text): + raise ValueError("Timezone not recognised. Use a name like Europe/Lisbon") if spec["key"] in {"CONTROL_PANEL_HOST", "BOAT_CHAT_HOST", "VESSELSTACK_DATA_BIND_ADDRESS", "VESSELSTACK_WEB_BIND_ADDRESS", "HOMEASSISTANT_BIND_ADDRESS"} and text: try: address = ipaddress.ip_address(text) @@ -441,7 +534,7 @@ def public_configuration() -> dict[str, Any]: "Saving this field replaces the override." ) fields.append(spec) - return {"fields": fields, "values": result, "overridden": sorted(overridden & {s["key"] for s in FIELDS if s["source"] == "chat"})} + return {"sections": SECTIONS, "fields": fields, "values": result, "overridden": sorted(overridden & {s["key"] for s in FIELDS if s["source"] == "chat"})} def take_lifecycle_slot(): @@ -474,6 +567,7 @@ def _update_configuration(updates: dict[str, Any]) -> str: vessel = parse_env(VESSEL_ENV) chat = parse_env(CHAT_ENV) panel = parse_env(PANEL_ENV) + problems: dict[str, str] = {} for key, raw in updates.items(): spec = FIELD_BY_KEY.get(key) if not spec: @@ -487,17 +581,23 @@ def _update_configuration(updates: dict[str, Any]) -> str: continue value = "" else: - value = validate_value(spec, raw) + try: + value = validate_value(spec, raw) + except ValueError as exc: + problems[key] = str(exc) + continue target = chat if spec["source"] == "chat" else vessel target[key] = value if spec["source"] == "chat": saved_chat_keys.add(key) if key in {"CONTROL_PANEL_HOST", "CONTROL_PANEL_PORT"}: panel[key] = value + if problems: + raise FieldErrors(problems) if vessel.get("DISPLAYS_ENABLE", "false") == "true": bridge_user = vessel.get("DISPLAYS_BRIDGE_MQTT_USERNAME") or DEFAULT_BRIDGE_MQTT_USERNAME if bridge_user == vessel.get("MQTT_USERNAME", ""): - raise ValueError("Displays bridge MQTT username must differ from the MQTT username") + raise FieldErrors({"DISPLAYS_BRIDGE_MQTT_USERNAME": "Displays bridge MQTT username must differ from the MQTT username"}) paths = [VESSEL_ENV, CHAT_ENV, PANEL_ENV] stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + f"-{time.time_ns() % 1_000_000_000:09d}" backup = CONFIG_BACKUP_ROOT / stamp @@ -639,6 +739,30 @@ def command_output(command: list[str], timeout: int = 8) -> tuple[int, str]: return 1, str(exc) +def describe_component(name: str, kind: str, enabled: bool, state: str) -> dict[str, Any]: + """One component as the UI shows it: real name, plain status, no contradictions.""" + label, description, section = COMPONENT_INFO.get(name, (name.replace("-", " ").title(), "", "")) + raw = str(state or "unknown").strip().lower() + running = raw.split(" ")[0] in RUNNING_STATES + if not enabled: + status = "disabled" + elif running: + status = "running" + elif raw == "unknown": + status = "unknown" + else: + status = "stopped" + item: dict[str, Any] = { + "id": name, "label": label, "description": description, "section": section, + "kind": kind, "enabled": enabled, "state": state, "status": status, + "still_running": status == "disabled" and running, + "manageable": name in CORE_COMPONENTS, + } + if status == "stopped" and raw not in {"stopped", "inactive", "dead", "exited"}: + item["detail"] = raw + return item + + def component_status() -> list[dict[str, Any]]: vessel = parse_env(VESSEL_ENV) disabled = disabled_components(vessel) @@ -680,11 +804,7 @@ def component_status() -> list[dict[str, Any]]: elif name == "node-exporter": enabled = vessel.get("NODE_EXPORTER_ENABLE", "false") == "true" state = compose_state.get(service, "unknown" if compose_parse_failed else "stopped") - item = {"id": name, "label": name.replace("-", " ").title(), "kind": "container", "enabled": enabled, "state": state} - if name in CORE_METADATA: - label, description, section = CORE_METADATA[name] - item.update({"label": label, "description": description, "section": section, "manageable": True}) - components.append(item) + components.append(describe_component(name, "container", enabled, state)) for name, unit in SYSTEMD_COMPONENTS.items(): code, output = command_output(["systemctl", "is-active", unit], timeout=3) enabled = True @@ -702,10 +822,102 @@ def component_status() -> list[dict[str, Any]]: enabled = vessel.get("VESSELSTACK_FIREWALL_ENABLE", "false") == "true" elif name == "displays-bridge": enabled = vessel.get("DISPLAYS_ENABLE", "false") == "true" - components.append({"id": name, "label": name.replace("-", " ").title(), "kind": "systemd", "enabled": enabled, "state": output or ("active" if code == 0 else "inactive")}) + components.append(describe_component(name, "systemd", enabled, output or ("active" if code == 0 else "inactive"))) return components +DEFAULT_BACKUP_DIR = "/var/backups/vesselstack" +BACKUP_ARCHIVE = re.compile(r"vesselstack-[0-9A-Za-z._-]+\.tar\.gz") + + +def backup_status(vessel: dict[str, str] | None = None) -> dict[str, Any]: + """Read-only view of the backup destination, derived the way vesselstackctl backup does. + + The destination is VESSELSTACK_BACKUP (or the default). A /media or /mnt destination must + sit on a mounted filesystem, otherwise vesselstackctl refuses to back up. The newest archive + that has its .sha256 manifest next to it is reported by modification time; nothing is hashed + or written. Any problem reading the folder yields "unknown" and never an error. + """ + result: dict[str, Any] = {"path": "", "mount": "unknown", "writable": None, "last_backup_at": None, "state": "unknown", "message": ""} + try: + settings = vessel if vessel is not None else parse_env(VESSEL_ENV) + destination = os.path.realpath(settings.get("VESSELSTACK_BACKUP") or os.environ.get("VESSELSTACK_BACKUP") or DEFAULT_BACKUP_DIR) + result["path"] = destination + parts = Path(destination).parts + removable = len(parts) >= 3 and parts[1] in {"media", "mnt"} + if removable: + mount_dir = os.path.join("/", parts[1], parts[2]) + result["mount"] = "mounted" if os.path.ismount(mount_dir) else "not_mounted" + else: + result["mount"] = "system_disk" + if result["mount"] == "not_mounted": + result["message"] = "The backup drive is not mounted, so backups cannot be saved or checked." + return result + if not os.path.isdir(destination): + result["state"] = "none" + result["message"] = "No backup has been made yet." + return result + result["writable"] = os.access(destination, os.W_OK | os.X_OK) + newest = 0 + with os.scandir(destination) as entries: + for entry in entries: + if not BACKUP_ARCHIVE.fullmatch(entry.name) or not entry.is_file(follow_symlinks=False): + continue + if not os.path.isfile(os.path.join(destination, entry.name + ".sha256")): + continue + newest = max(newest, int(entry.stat(follow_symlinks=False).st_mtime)) + if newest: + result["last_backup_at"] = newest + result["state"] = "ok" if result["writable"] else "read_only" + else: + result["state"] = "none" + result["message"] = "No backup has been made yet." + except OSError: + result.update({"state": "unknown", "last_backup_at": None, "message": "The backup folder could not be read."}) + except Exception: + result.update({"state": "unknown", "last_backup_at": None, "message": "Backup status is not available."}) + return result + + +ERROR_LINE = re.compile(r"^(?:\[[^\]]*\]\s*)?(?:ERROR|Error|error|FATAL|Refusing[^:]*|Backup[^:]*failed)[:\s]+(.*)$") + + +def summarize_operation(operation: dict[str, Any]) -> dict[str, Any]: + """Plain facts about a finished operation, read from its output only.""" + lines = [str(line).strip() for line in operation.get("output") or [] if str(line).strip()] + summary: dict[str, Any] = {} + if operation.get("state") == "succeeded" and operation.get("label") == "backup": + for line in reversed(lines): + if line.startswith("/") and line.endswith(".tar.gz"): + summary["saved_path"] = line + break + if operation.get("state") == "failed" and lines: + reason = lines[-1] + for line in reversed(lines): + found = ERROR_LINE.match(line) + if found: + reason = found.group(1).strip() or line + break + summary["reason"] = reason[:400] + return summary + + +def with_summary(operation: dict[str, Any]) -> dict[str, Any]: + if operation.get("state") in {"succeeded", "failed"}: + return {**operation, "summary": summarize_operation(operation)} + return operation + + +def restart_needed() -> bool: + """True when a newer Control Panel is installed than the one now running.""" + try: + installed = installed_version(ROOT) + running = str(RUNNING_IDENTITY.get("running_version") or "") + return bool(running) and running != "unknown" and installed != running + except Exception: + return False + + class OperationStore: def __init__(self) -> None: self.lock = threading.Lock() @@ -754,8 +966,8 @@ def _run(self, operation: dict[str, Any], command: list[str], slot_fd) -> None: exit_code = process.wait() succeeded = exit_code == 0 with self.lock: - if succeeded and operation["label"] in {"apply", "install", "update"}: - operation["output"].append("If this operation changed Control Panel code or its listener, restart vesselstack-control-panel.service from the recovery shell.") + if succeeded and operation["label"] in {"apply", "install", "update"} and restart_needed(): + operation["output"].append("A newer Control Panel is installed than the one running. Use Restart Control Panel on the Maintenance tab.") except Exception as exc: with self.lock: operation["output"].append("Operation failed") @@ -782,6 +994,15 @@ def panel_settings() -> dict[str, str]: return settings +def normalize_token(text: str) -> str: + """Accept a pasted ``CONTROL_PANEL_TOKEN=...`` line as well as the bare token.""" + value = text.strip() + value = re.sub(r"^(export\s+)?CONTROL_PANEL_TOKEN\s*=\s*", "", value).strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"": + value = value[1:-1].strip() + return value + + class ControlPanelHandler(BaseHTTPRequestHandler): server_version = f"VesselStackControlPanel/{os.environ.get('VESSELSTACK_VERSION', 'development')}" @@ -802,7 +1023,7 @@ def send_json(self, status: int, payload: Any) -> None: def authenticated(self) -> bool: expected = panel_settings().get("CONTROL_PANEL_TOKEN", "").strip() - supplied = (self.headers.get("X-VesselStack-Token") or "").strip() + supplied = normalize_token(self.headers.get("X-VesselStack-Token") or "") return bool(expected) and hmac.compare_digest(expected.encode("utf-8"), supplied.encode("utf-8")) def require_auth(self) -> bool: @@ -870,11 +1091,12 @@ def do_GET(self) -> None: persisted = public_operation(ROOT) memory = OPERATIONS.public() operation = memory if memory.get("state") == "running" else max((persisted, memory), key=lambda item: int(item.get("finished_at") or item.get("started_at") or 0)) - self.send_json(200, {"components": component_status(), "operation": operation, "installed_version": installed_version(ROOT), **RUNNING_IDENTITY}) + vessel = parse_env(VESSEL_ENV) + self.send_json(200, {"components": component_status(), "operation": with_summary(operation), "installed_version": installed_version(ROOT), "boat_name": vessel.get("BOAT_NAME", ""), "backup": backup_status(vessel), "restart_needed": restart_needed(), **RUNNING_IDENTITY}) elif path == "/api/operation": persisted = public_operation(ROOT) memory = OPERATIONS.public() - self.send_json(200, memory if memory.get("state") == "running" else max((persisted, memory), key=lambda item: int(item.get("finished_at") or item.get("started_at") or 0))) + self.send_json(200, with_summary(memory if memory.get("state") == "running" else max((persisted, memory), key=lambda item: int(item.get("finished_at") or item.get("started_at") or 0)))) elif path == "/api/releases": channel = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("channel", ["stable"])[0] if channel not in {"stable", "lab"}: @@ -952,6 +1174,8 @@ def do_POST(self) -> None: self.send_json(202, OPERATIONS.start(f"{verb} {component}", lambda: component_command(component, verb))) return self.send_json(404, {"error": "Not found"}) + except FieldErrors as exc: + self.send_json(400, {"error": str(exc), "fields": exc.fields}) except (ValueError, json.JSONDecodeError) as exc: self.send_json(400, {"error": str(exc)}) except Exception as exc: diff --git a/control-panel/static/app.js b/control-panel/static/app.js index 6cdc7f8..03589bb 100644 --- a/control-panel/static/app.js +++ b/control-panel/static/app.js @@ -3,17 +3,26 @@ const state = { config: null, poller: null, components: [], + status: null, componentFilter: "all", componentQuery: "", + menuOpen: null, + componentSignature: "", busy: false, saving: false, session: 0, operationRunning: false, pendingOperation: null, lastOperationId: null, + operation: null, + watching: new Set(), + dismissed: new Set(), + notices: {}, releaseEpoch: 0, releaseChannel: "stable", releases: [], + releaseChecked: false, + releaseError: "", bootId: null, reconnectTimer: null, reconnectAttempts: 0, @@ -22,8 +31,14 @@ const state = { installedVersion: "unknown", runningVersion: "unknown", releaseCheckedAt: "", + rollbackPath: "", + openSections: new Set(), }; +const IDLE_POLL_MS = 15000; +const BUSY_POLL_MS = 1200; +const RECENT_RESULT_SECONDS = 900; + const $ = (selector) => document.querySelector(selector); const $$ = (selector) => [...document.querySelectorAll(selector)]; const escapeHtml = (value) => String(value ?? "") @@ -38,25 +53,124 @@ async function api(path, options = {}) { if (options.body) headers["Content-Type"] = "application/json"; const response = await fetch(path, { ...options, headers }); const payload = await response.json().catch(() => ({})); - if (!response.ok) { const error = new Error(payload.error || `${response.status} ${response.statusText}`); error.status = response.status; throw error; } + if (!response.ok) { + const error = new Error(payload.error || `${response.status} ${response.statusText}`); + error.status = response.status; + if (payload.fields && typeof payload.fields === "object") error.fields = payload.fields; + throw error; + } return payload; } +// A pasted "CONTROL_PANEL_TOKEN=..." line works as well as the bare token. +function normalizeToken(value) { + let text = String(value ?? "").trim().replace(/^(export\s+)?CONTROL_PANEL_TOKEN\s*=\s*/, "").trim(); + if (text.length >= 2 && text[0] === text[text.length - 1] && "'\"".includes(text[0])) text = text.slice(1, -1).trim(); + return text; +} + +const OPTION_LABELS = { + BOAT_UNITS: { us_customary: "US customary", metric: "Metric" }, + SIGNALK_MODE: { docker: "Docker (recommended)", existing: "Existing server", native: "Native (on this Pi)" }, + PROVIDER: { "": "None", local: "Local (built-in)", codex_cli: "Codex CLI", claude_cli: "Claude CLI", openai: "OpenAI", vercel: "Vercel AI Gateway", bedrock: "Amazon Bedrock", google: "Google Gemini", ollama: "Ollama", openai_compatible: "OpenAI-compatible API" }, +}; +const optionLabel = (key, choice) => { + const table = OPTION_LABELS[key] || (key.startsWith("BOAT_CHAT_") && key.endsWith("PROVIDER") ? OPTION_LABELS.PROVIDER : null); + return (table && table[choice]) || choice || "None"; +}; + +// ---------------------------------------------------------------- messages + function toast(message, isError = false) { const node = $("#toast"); - node.textContent = message; + $("#toast-text").textContent = message; node.className = isError ? "show error" : "show"; + $("#toast-close").hidden = !isError; clearTimeout(node.timer); - node.timer = setTimeout(() => { node.className = ""; }, 4200); + // Errors stay until dismissed; confirmations fade on their own. + if (!isError) node.timer = setTimeout(() => { node.className = ""; }, 4200); +} + +function setNotice(id, text, kind = "info") { + if (text) state.notices[id] = { text, kind }; + else delete state.notices[id]; + const host = $("#notice"); + host.innerHTML = Object.entries(state.notices).map(([key, item]) => ``).join(""); } -function setConnected(connected) { - $("#connection-dot").classList.toggle("online", connected); - $("#connection-label").textContent = connected ? "Connected" : "Locked"; +function setConnection(mode) { + $("#connection-dot").className = mode === "connected" ? "dot online" : mode === "disconnected" ? "dot offline" : "dot"; + $("#connection-label").textContent = mode === "connected" ? "Connected" : mode === "disconnected" ? "Disconnected" : "Locked"; + $("#retry").hidden = mode !== "disconnected"; } -function lock() { - if (isConfigDirty() && !window.confirm("Discard unsaved configuration edits and lock the panel?")) return; +// ---------------------------------------------------------------- dialogs + +const LIVE_DATA_NOTE = "Live instrument data pauses briefly."; +const CONFIRMS = { + stop: { title: "Stop all services?", intro: "These will go dark:", items: ["Instruments in Home Assistant and Grafana", "Signal K (no boat data comes in)", "Boat Chat and Telegram alerts", "Displays"], note: "The Control Panel stays available. Press Start all to bring them back.", ok: "Stop all", danger: true }, + restart: { title: "Restart all services?", intro: "Every service stops and starts again. Live data pauses for under a minute.", ok: "Restart all" }, + start: { title: "Start all services?", intro: `Starts every enabled service that is stopped. ${LIVE_DATA_NOTE}`, ok: "Start all" }, + install: { title: "Reinstall & start?", intro: "This re-applies your saved settings and restarts every service.", items: ["Live data pauses for about a minute", "Unsaved edits on the Settings page are not included", "Your data and backups are not touched"], ok: "Reinstall & start" }, + apply: { title: "Apply saved settings?", intro: "Writes your saved settings into VesselStack's service files. Running services keep running; changes take effect the next time each service restarts.", items: ["The displays bridge, if it is running, restarts now", "Other services are not restarted: restart a service from Home, or use Restart all, to use the new values", "Services that are stopped stay stopped"], ok: "Apply" }, + "restart-panel": { title: "Restart the Control Panel?", intro: "This page will disconnect for a few seconds and reconnect by itself. Boat services are not affected.", ok: "Restart panel" }, +}; +const updateConfirm = (extra = {}) => ({ + title: "Back up & update from this folder?", + intro: extra.source_directory ? `Folder: ${extra.source_directory}` : "A backup is taken first. Then the release in that folder is installed.", + items: ["A backup is taken first and needs a writable backup folder", "The Control Panel and services may be interrupted", ...(isConfigDirty() ? ["Unsaved edits on the Settings page are kept but not applied"] : [])], + ok: "Back up & update", +}); +const releaseConfirm = (release) => ({ + title: `Back up & install ${release.version}?`, + intro: `A backup is taken first. Then ${release.version} is installed.`, + items: ["Needs a writable backup folder; if the backup cannot be saved the update stops", ...(isConfigDirty() ? ["Unsaved edits on the Settings page are kept but not applied"] : [])], + note: "The Control Panel may restart. This page reconnects by itself, usually within a minute.", + ok: "Back up & install", +}); +const componentConfirm = (verb, name) => ({ + stop: { title: `Stop ${name}?`, intro: `${name} turns off and anything that depends on it stops working until you start it again. ${LIVE_DATA_NOTE}`, ok: "Stop", danger: true }, + restart: { title: `Restart ${name}?`, intro: `${name} stops and starts again. ${LIVE_DATA_NOTE}`, ok: "Restart" }, + remove: { title: `Disable ${name}?`, intro: `${name} will stop now and stay off, including after a restart or Start all. Anything that depends on it will stop working until you enable it again. Your saved data is kept.`, note: `Technically this removes the ${name} container and marks it disabled; Enable puts it back.`, ok: "Disable", danger: true }, +}[verb]); +const LOCK_CONFIRM = { title: "Lock the panel?", intro: "You have unsaved edits on the Settings page. Locking discards them.", ok: "Lock and discard", danger: true }; +const discardConfirm = (count) => ({ title: "Discard your edits?", intro: `${count} unsaved ${count === 1 ? "change" : "changes"} on this page will be reset to the saved settings.`, ok: "Discard", danger: true }); + +function ask(spec) { + const dialog = $("#confirm-dialog"); + if (dialog.open) return Promise.resolve(false); + $("#confirm-title").textContent = spec.title; + $("#confirm-intro").textContent = spec.intro || ""; + $("#confirm-intro").hidden = !spec.intro; + $("#confirm-items").innerHTML = (spec.items || []).map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); + $("#confirm-items").hidden = !(spec.items || []).length; + $("#confirm-note").textContent = spec.note || ""; + $("#confirm-note").hidden = !spec.note; + $("#confirm-ok").textContent = spec.ok; + $("#confirm-ok").className = spec.danger ? "danger solid" : "primary"; + const opener = document.activeElement; + return new Promise((resolve) => { + dialog.onclose = () => { + dialog.onclose = null; + const target = spec.returnFocus ? $(spec.returnFocus) : opener; + if (target && target.isConnected !== false && typeof target.focus === "function") target.focus(); + resolve(dialog.returnValue === "ok"); + }; + $("#confirm-ok").onclick = () => dialog.close("ok"); + $("#confirm-cancel").onclick = () => dialog.close("cancel"); + // Escape closes the dialog without a return value: that is Cancel. + dialog.returnValue = ""; + dialog.showModal(); + }); +} + +// ---------------------------------------------------------------- session + +function lock(force = false) { + if (!force && isConfigDirty()) { + ask(LOCK_CONFIRM).then((ok) => { if (ok) lock(true); }); + return; + } clearTimeout(state.poller); clearTimeout(state.reconnectTimer); state.poller = null; @@ -65,119 +179,40 @@ function lock() { state.saving = false; state.config = null; state.components = []; + state.status = null; state.releases = []; + state.releaseChecked = false; + state.releaseError = ""; state.busy = false; state.operationRunning = false; state.pendingOperation = null; state.lastOperationId = null; + state.operation = null; + state.notices = {}; state.bootId = null; + state.rollbackPath = ""; + state.componentSignature = ""; + $("#toast").className = ""; $("#config-form").innerHTML = ""; - $("#operation-output").textContent = "No operation has run in this session."; - $("#operation-state").textContent = "Idle"; - $("#operation-progress").hidden = true; - $("#release-select").innerHTML = ""; - $("#release-select").disabled = true; - $("#install-release").disabled = true; + $("#banner").innerHTML = ""; + state.bannerHtml = ""; + $("#notice").innerHTML = ""; + $("#release-result").innerHTML = ""; sessionStorage.removeItem("vesselstack_control_token"); $("#app").hidden = true; $("#lock").hidden = true; $("#login").hidden = false; + $("#version-chip").hidden = true; + $("#boat-name").textContent = ""; $("#token").value = ""; $("#login-error").textContent = ""; - setConnected(false); -} - -function releaseUrl(value) { - try { - const url = new URL(value); - return url.protocol === "https:" && url.hostname === "github.com" && /^\/[^/]+\/[^/]+\/releases\/tag\/[^/]+$/.test(url.pathname) ? url.href : ""; - } catch (_) { return ""; } -} - -function selectedRelease() { - const id = Number($("#release-select")?.value); - return state.releases.find((release) => release.id === id && releaseEligible(release)) || null; -} - -function versionTuple(value) { - const match = String(value || "").match(/^(\d+)\.(\d+)\.(\d+)/); - return match ? match.slice(1).map(Number) : null; -} - -function releaseEligible(release) { - const candidate = versionTuple(release.version); - const installed = versionTuple(state.installedVersion); - if (!candidate || !installed) return false; - for (let index = 0; index < 3; index += 1) { - if (candidate[index] !== installed[index]) return candidate[index] > installed[index]; - } - return false; + setConnection("locked"); } -function updateReleaseControls() { - const release = selectedRelease(); - $("#install-release").disabled = state.releaseCheckPending || state.busy || !release; - $("#release-select").disabled = state.releaseCheckPending || state.busy || !state.releases.some(releaseEligible); - $("#install-release").textContent = release ? `Back up & install ${release.version}` : "Back up & install version"; -} - -function renderReleases(data) { - state.installedVersion = data.installed_version || state.installedVersion; - state.runningVersion = data.running_version || state.runningVersion; - state.releaseCheckedAt = data.checked_at ? new Date(data.checked_at).toLocaleString() : new Date().toLocaleString(); - state.releases = Array.isArray(data.releases) ? data.releases.filter((release) => Number.isInteger(release.id) && release.tag && release.version && release.name && releaseUrl(release.html_url)) : []; - $("#release-versions").textContent = `Installed: ${state.installedVersion} · Running: ${state.runningVersion} · Checked ${state.releaseCheckedAt}`; - $("#release-warning").hidden = data.channel !== "lab"; - $("#release-warning").textContent = data.channel === "lab" ? "Lab releases are for testing. Read the release notes before installing." : ""; - $("#release-select").innerHTML = state.releases.length ? state.releases.map((release) => ``).join("") : ""; - updateReleaseControls(); - updateReleaseDetail(); -} - -function updateReleaseDetail() { - const release = selectedRelease(); - $("#release-detail").innerHTML = release ? `${escapeHtml(release.name)} · ${escapeHtml(release.tag)} · View fixed GitHub release` : ""; -} - -async function checkReleases() { - const session = state.session; - const epoch = ++state.releaseEpoch; - const channel = $("#include-lab").checked ? "lab" : "stable"; - state.releaseChannel = channel; - state.releaseCheckPending = true; - updateReleaseControls(); - try { - const data = await api(`/api/releases?channel=${channel}`); - if (session !== state.session || epoch !== state.releaseEpoch || !state.token) return; - renderReleases(data); - } catch (error) { - if (session === state.session && epoch === state.releaseEpoch && state.token) { - if (error.status === 401) lock(); - else toast(`Could not check for updates: ${error.message}`, true); - } - } finally { - if (session === state.session && epoch === state.releaseEpoch && state.token) { - state.releaseCheckPending = false; - $("#check-releases").disabled = state.busy; - updateReleaseControls(); - } - } -} - -function operationSucceeded(operation) { - if (operation?.state !== "succeeded") return false; - // Only these exact operation labels require boot/version confirmation. The - // legacy operation store omits phase for ordinary backup/apply/component work. - if (!["update-release", "restart-panel"].includes(operation.label)) return true; - return operation.phase === "complete" && Boolean(operation.target_version && operation.running_version === operation.target_version); -} - -async function installSelectedRelease() { - const release = selectedRelease(); - if (!release || state.busy) return; - if (isConfigDirty() && !window.confirm("Configuration has unsaved edits. Save them first; installation will not discard them.")) return; - if (!window.confirm(`Back up and install ${release.version}? Services and this panel may be interrupted, and a writable backup mount is required.`)) return; - startOperation({ action: "update-release", release_id: release.id, channel: state.releaseChannel }); +function loginError(message) { + $("#login-error").textContent = message; + $("#token").setAttribute("aria-invalid", message ? "true" : "false"); + $("#login").classList.toggle("bad", Boolean(message)); } async function unlock() { @@ -187,10 +222,11 @@ async function unlock() { if (session !== state.session) return; state.config = config; sessionStorage.setItem("vesselstack_control_token", state.token); + loginError(""); $("#login").hidden = true; $("#app").hidden = false; $("#lock").hidden = false; - setConnected(true); + setConnection("connected"); if (state.reauthDirty) { // Keep the user's locally edited controls intact across re-auth; the // fetched config becomes the new save/discard baseline. @@ -202,9 +238,12 @@ async function unlock() { await refreshStatus(); } catch (error) { if (session !== state.session) return; - setConnected(false); - $("#login-error").textContent = error.message; - sessionStorage.removeItem("vesselstack_control_token"); + setConnection("locked"); + // Only a rejected token is forgotten; a network problem keeps it for the retry. + if (error.status === 401) { sessionStorage.removeItem("vesselstack_control_token"); state.token = ""; } + loginError(error.status === 401 + ? "That token wasn't accepted. Paste only the token, or the whole CONTROL_PANEL_TOKEN=… line." + : `Couldn't reach the Control Panel (${error.message}). Check that the SSH tunnel is still open.`); } } @@ -222,74 +261,240 @@ function requireReauth(message) { sessionStorage.removeItem("vesselstack_control_token"); $("#login").hidden = false; $("#app").hidden = true; - $("#login-error").textContent = `${message} Unlock again to continue. Unsaved edits are preserved.`; + $("#lock").hidden = true; + loginError(""); + $("#login-error").textContent = `${message} Unlock again.${state.reauthDirty ? " Your unsaved edits are kept." : ""}`; $("#token").value = ""; - setConnected(false); + setConnection("locked"); } -function statusClass(value) { - const text = String(value || "unknown").toLowerCase().trim(); - if (["active", "running", "succeeded"].includes(text)) return "active"; - if (["failed", "error", "exited"].includes(text)) return "failed"; - return ""; +// ---------------------------------------------------------------- tabs + +function selectTab(name) { + $$("[data-tab]").forEach((item) => { + const selected = item.dataset.tab === name; + item.classList.toggle("active", selected); + item.setAttribute("aria-selected", String(selected)); + item.tabIndex = selected ? 0 : -1; + }); + $$(".tab-panel").forEach((panel) => { panel.hidden = panel.id !== name; }); + if (name === "settings") updateScrollSpy(); } -function renderComponents(components) { +// ---------------------------------------------------------------- components and the Home band + +const STATUS_WORDS = { running: "Running", stopped: "Stopped", disabled: "Disabled", unknown: "Unknown" }; +const visibleComponents = () => state.components.filter((item) => item.id !== "control-panel"); + +function formatWhen(seconds) { + const when = new Date(seconds * 1000); + const now = new Date(); + const clock = when.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + const days = Math.round((new Date(now.getFullYear(), now.getMonth(), now.getDate()) - new Date(when.getFullYear(), when.getMonth(), when.getDate())) / 86400000); + if (days === 0) return `today ${clock}`; + if (days === 1) return `yesterday ${clock}`; + return `${when.toLocaleDateString([], { day: "numeric", month: "short", year: "numeric" })} ${clock}`; +} + +function backupSummary() { + const backup = state.status?.backup; + if (!backup || !backup.path) return { tone: "warn", value: "Last backup: unknown", sub: "Backup status is not available yet." }; + if (backup.last_backup_at) { + const sub = backup.mount === "mounted" ? "Backup drive OK" + : backup.state === "read_only" ? "Backup folder is not writable" + : backup.mount === "system_disk" ? "Saved on this computer's own disk, not an external drive" : ""; + return { tone: backup.state === "ok" && backup.mount !== "system_disk" ? "good" : "warn", value: `Last backup: ${formatWhen(backup.last_backup_at)}`, sub }; + } + if (backup.mount === "not_mounted") return { tone: "warn", value: "Last backup: unknown", sub: "Backup drive not mounted, so it cannot be checked." }; + if (backup.state === "none") return { tone: "warn", value: "No backup yet", sub: backup.mount === "mounted" ? "Backup drive OK" : "Press Back up now to make the first one." }; + return { tone: "warn", value: "Last backup: unknown", sub: backup.message || "The backup folder could not be read." }; +} + +function softwareSummary() { + const version = state.installedVersion; + const available = state.releases.filter(releaseEligible); + if (available.length) return { tone: "warn", value: `${available[0].version} available`, sub: "", html: `` }; + if (state.releaseChecked) return { tone: "good", value: `Up to date on ${version}`, sub: "Checked only when you ask" }; + return { tone: "plain", value: `Version ${version}`, sub: "Updates are checked only when you ask" }; +} + +function renderBand() { + const enabled = visibleComponents().filter((item) => item.status !== "disabled"); + const stopped = enabled.filter((item) => item.status === "stopped"); + const unknown = enabled.filter((item) => item.status === "unknown"); + let services; + if (!state.components.length) services = { tone: "plain", value: "Loading…", sub: "" }; + else if (stopped.length) services = { tone: "warn", value: `${stopped.length} of ${enabled.length} stopped`, sub: stopped.map((item) => item.label).join(", ") + (unknown.length ? ` · status unknown for ${unknown.map((item) => item.label).join(", ")}` : "") }; + else if (unknown.length) services = { tone: "warn", value: "Status unknown", sub: unknown.map((item) => item.label).join(", ") }; + else services = { tone: "good", value: "All systems running", sub: `${enabled.length} components` }; + const backup = backupSummary(); + const software = softwareSummary(); + const cell = (key, item) => `
    ${key}${escapeHtml(item.value)}${item.html ?? escapeHtml(item.sub)}
    `; + $("#band").innerHTML = `${cell("Services", services)}${cell("Backup", backup)}${cell("Software", software)}
    `; + $("#maint-backup-last").textContent = backup.value; + if (state.busy) setBusy(true); +} + +function componentCard(component) { + const status = component.status || "unknown"; + const id = escapeHtml(component.id); + const name = escapeHtml(component.label || component.id); + const verb = (action, text, extra = "") => ``; + let acts; + if (status === "running") acts = `${verb("restart", "Restart")}${verb("stop", "Stop")}`; + else if (status === "disabled") { + if (component.manageable) acts = component.still_running ? verb("remove", "Retry disabling", 'class="danger"') : verb("add", "Enable"); + else acts = ``; + } else acts = verb("start", "Start", 'class="primary"'); + const canMenu = component.manageable && status !== "disabled"; + const more = canMenu ? `` : ""; + const menu = canMenu && state.menuOpen === component.id + ? `` : ""; + const configure = component.section && status !== "disabled" ? `` : ""; + const detail = component.detail ? `${escapeHtml(component.detail)}` : ""; + const leftover = component.still_running ? `

    Still running although it is turned off.

    ` : ""; + return `

    ${name}

    ${STATUS_WORDS[status] || "Unknown"}

    ${escapeHtml(component.description || "")}

    ${detail}${leftover}${configure}
    ${acts}${more}
    ${menu}
    `; +} + +function renderComponents(components, force = false) { state.components = components || []; - const query = state.componentQuery.toLowerCase(); - const filtered = state.components.filter((component) => { - const availability = component.enabled ? "enabled" : "available"; - const haystack = `${component.label || component.id} ${component.description || ""}`.toLowerCase(); - return (state.componentFilter === "all" || state.componentFilter === availability) && (!query || haystack.includes(query)); + const list = visibleComponents(); + const signature = JSON.stringify([list, state.componentFilter, state.componentQuery, state.menuOpen]); + const counts = { all: list.length, running: 0, stopped: 0, disabled: 0, unknown: 0 }; + list.forEach((item) => { counts[item.status] = (counts[item.status] || 0) + 1; }); + $$("[data-component-filter]").forEach((button) => { + const selected = button.dataset.componentFilter === state.componentFilter; + button.classList?.toggle("active", selected); + button.setAttribute?.("aria-pressed", String(selected)); + const badge = button.querySelector(".n"); + if (badge) badge.textContent = counts[button.dataset.componentFilter] ?? 0; }); - const active = state.components.filter((item) => item.enabled && statusClass(item.state) === "active").length; - const enabled = state.components.filter((item) => item.enabled).length; - $("#component-overview").innerHTML = `
    ${state.components.length}Total
    ${enabled}Enabled
    ${active}Active
    ${state.components.length - enabled}Available
    `; - $("#component-grid").innerHTML = filtered.length ? filtered.map((component) => { - const disabled = !component.enabled; - const stateLabel = escapeHtml(component.state || "unknown"); - const availabilityLabel = disabled ? "available" : "enabled"; - const componentId = escapeHtml(component.id); - const componentLabel = escapeHtml(component.label); - const description = escapeHtml(component.description || (component.kind === "container" ? "Docker Compose service" : "Host systemd service")); - let actions; - const fallbackSections = { "signalk-native": "SignalK", socketcan: "SignalK", firewall: "Network", "ais-catcher": "AIS" }; - const configureSection = component.section || fallbackSections[component.id]; - const configure = configureSection ? `` : ""; - const controls = ``; - if (component.manageable) { - const retryRemoval = disabled && ["running", "restarting", "paused"].includes(String(component.state || "").toLowerCase()); - const lifecycleAction = retryRemoval - ? `` - : ``; - actions = disabled ? `
    ${configure}${lifecycleAction}
    ` : `
    ${configure}${controls}
    `; - } else if (component.id === "control-panel") { - actions = "

    Managed from the recovery shell

    "; - } else { - actions = `
    ${configure}${controls}
    `; - } - return `
    -

    ${componentLabel}

    ${stateLabel}${availabilityLabel}

    ${description}

    - ${actions} -
    `; - }).join("") : `
    No matching components

    Try a different search or filter.

    `; - $$('[data-component]').forEach((button) => button.addEventListener("click", () => runComponent(button.dataset.component, button.dataset.verb))); - $$('[data-configure-section]').forEach((button) => button.addEventListener("click", () => jumpToConfiguration(button.dataset.configureSection))); + $("#component-note").textContent = `${counts.all} components: ${counts.running} running, ${counts.stopped} stopped, ${counts.disabled} disabled${counts.unknown ? `, ${counts.unknown} unknown` : ""}. Core services can be disabled from the ⋯ menu; the rest follow your Settings.`; + renderBand(); + if (signature === state.componentSignature && !force) return; + state.componentSignature = signature; + const active = document.activeElement?.dataset?.fk; + const query = state.componentQuery.toLowerCase(); + const filtered = list.filter((item) => (state.componentFilter === "all" || item.status === state.componentFilter) && (!query || `${item.label} ${item.description || ""}`.toLowerCase().includes(query))); + $("#component-grid").innerHTML = filtered.length ? filtered.map(componentCard).join("") : `
    No components match.

    Try a different search or choose All.

    `; + const focusKey = state.pendingFocus || active; + state.pendingFocus = null; + if (focusKey) $(`[data-fk="${focusKey}"]`)?.focus(); if (state.busy) setBusy(true); } function jumpToConfiguration(sectionName) { - $('[data-tab="configuration"]')?.click(); - const section = $$(".config-section").find((item) => item.querySelector("h3")?.textContent === sectionName); - section?.scrollIntoView({ behavior: "smooth", block: "start" }); - section?.querySelector("input:not([disabled]), select:not([disabled])")?.focus({ preventScroll: true }); + selectTab("settings"); + const section = $$("[data-section]").find((item) => item.dataset.section === sectionName); + if (!section) return; + if (section.tagName === "DETAILS") section.open = true; + section.scrollIntoView({ behavior: "smooth", block: "start" }); + section.querySelector("input:not([disabled]), select:not([disabled])")?.focus({ preventScroll: true }); +} + +async function runComponent(component, action) { + const known = state.components.find((item) => item.id === component); + const name = known ? known.label : component; + if (["stop", "restart", "remove"].includes(action)) { + if (!(await ask({ ...componentConfirm(action, name), returnFocus: `[data-fk="${component}-${action === "remove" ? "more" : action}"]` }))) return; + } + startOperation({ component, action }); +} + +// ---------------------------------------------------------------- operations and the result banner + +const OPERATION_TEXT = { + backup: ["Backing up… Services pause while the copy is made; this can take a few minutes.", "Backup finished", "Backup failed", "Check the details, then try again."], + apply: ["Applying your saved settings…", "Settings applied", "Applying settings failed", "Check the details, fix the cause, then apply again."], + install: ["Reinstalling and starting…", "Reinstall finished", "Reinstall failed", "Check the details, then try again."], + preflight: ["Checking setup…", "Setup check finished", "Setup check found a problem", "Check the details for what to fix."], + start: ["Starting all services…", "All services started", "Starting the services failed", "Check the details, then try again."], + stop: ["Stopping all services…", "All services stopped", "Stopping the services failed", "Check the details, then try again."], + restart: ["Restarting all services…", "All services restarted", "Restarting the services failed", "Check the details, then try again."], + "bootstrap-history": ["Setting up history storage…", "History storage is set up", "History storage setup failed", "Check the details, then try again. It is safe to run again."], + update: ["Backing up, then updating from the folder…", "Update finished", "Update failed", "Nothing further was changed once it failed; check the details before trying again."], + "restart-panel": ["Restarting the Control Panel…", "Control Panel restarted", "Control Panel restart failed", "Check the details, then try again."], +}; +const COMPONENT_VERBS = { + start: (n) => [`Starting ${n}…`, `${n} started`, `${n} did not start`], + stop: (n) => [`Stopping ${n}…`, `${n} stopped`, `${n} did not stop`], + restart: (n) => [`Restarting ${n}…`, `${n} restarted`, `${n} did not restart`], + add: (n) => [`Enabling ${n}…`, `${n} enabled`, `${n} could not be enabled`], + remove: (n) => [`Disabling ${n}…`, `${n} disabled`, `${n} could not be disabled`], +}; + +function describeOperation(operation) { + const label = String(operation.label || ""); + const component = /^(start|stop|restart|add|remove) (.+)$/.exec(label); + if (component) { + const known = state.components.find((item) => item.id === component[2]); + const [running, done, failed] = COMPONENT_VERBS[component[1]](known?.label || component[2]); + return { running, done, failed, next: "Check the details, then try again." }; + } + if (label === "update-release") { + const target = operation.target_version || "the update"; + return { running: `Backing up, then installing ${target}…`, done: `Installed ${target}`, failed: `Installing ${target} failed`, next: "Your boat was backed up first if the backup step finished. Check the details before trying again." }; + } + const entry = OPERATION_TEXT[label]; + if (entry) return { running: entry[0], done: entry[1], failed: entry[2], next: entry[3] }; + return { running: "Working…", done: "Finished", failed: "Failed", next: "Check the details, then try again." }; +} + +const clockTime = (seconds) => new Date(seconds * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +const operationKey = (operation) => `${state.bootId}:${operation.id}:${operation.started_at || ""}:${operation.label}`; +const logText = (operation) => (operation.output || []).join("\n"); + +function renderBanner(operation, context) { + const host = $("#banner"); + const text = describeOperation(operation || {}); + let html = ""; + const log = (summary) => (operation?.output?.length ? `
    ${summary}
    ${escapeHtml(logText(operation))}
    ` : ""); + const wasOpen = host.querySelector?.("details")?.open; + if (context.uncertain || context.active) { + const pendingLabel = state.pendingOperation?.label; + const shown = operation?.label && operation.state === "running" ? text : describeOperation({ label: pendingLabel || operation?.label }); + const phase = operation?.phase && operation.state === "running" ? ` (${operation.phase})` : ""; + const waiting = context.verificationRequired ? "Finishing up… waiting for the Control Panel to confirm the new version." : shown.running + phase; + html = ``; + } else if (operation && ["succeeded", "failed"].includes(operation.state) && operation.id != null) { + const key = operationKey(operation); + const finished = operation.finished_at || operation.started_at; + const recent = finished ? Date.now() / 1000 - finished < RECENT_RESULT_SECONDS : false; + if (!state.dismissed.has(key) && (state.watching.has(key) || recent)) { + const when = operation.finished_at ? ` ${clockTime(operation.finished_at)}` : ""; + const summary = operation.summary || {}; + if (operation.state === "succeeded") { + let tail = ""; + if (operation.label === "backup") tail = ` — saved to ${escapeHtml(summary.saved_path || state.status?.backup?.path || "the backup folder")}`; + if (operation.label === "update-release") tail = " — the Control Panel confirmed the new version."; + html = ``; + } else { + const reason = String(summary.reason || "see the details below").replace(/[.\s]+$/, ""); + const next = /mount/i.test(reason) && operation.label === "backup" ? "Plug in or mount the backup drive and try again." : text.next; + html = ``; + } + } + } + if (state.bannerHtml !== html) { + state.bannerHtml = html; + host.innerHTML = html; + const details = host.querySelector?.("details"); + if (details && wasOpen) details.open = true; + } +} + +function operationSucceeded(operation) { + if (operation?.state !== "succeeded") return false; + // Only these exact operation labels require boot/version confirmation. The + // legacy operation store omits phase for ordinary backup/apply/component work. + if (!["update-release", "restart-panel"].includes(operation.label)) return true; + return operation.phase === "complete" && Boolean(operation.target_version && operation.running_version === operation.target_version); } function renderOperation(operation) { if (!operation && state.operationRunning) { setBusy(true); - $("#operation-progress").hidden = false; - $("#operation-progress").textContent = "Operation status is unavailable. Controls stay locked; retry status or unlock to recover the session."; + setNotice("opstatus", "Operation status is unavailable. Controls stay locked; use Retry now or unlock again to recover the session."); return; } const current = operation || {}; @@ -300,34 +505,74 @@ function renderOperation(operation) { || (pending.operationId == null && current.id != null && current.id !== pending.baselineId)); if (terminalForPending) state.pendingOperation = null; const label = current.state === "succeeded" && !verified ? "verification-required" : (current.state || "idle"); - const badge = $("#operation-state"); - badge.textContent = label; - badge.className = `pill ${statusClass(label)} ${label}`; const active = Boolean(state.pendingOperation || label === "running" || label === "verification-required" || (label !== "succeeded" && label !== "failed" && current.phase && current.phase !== "complete")); setBusy(active); state.operationRunning = active; - const progress = $("#operation-progress"); - progress.hidden = !active && !current.phase; - progress.textContent = state.pendingOperation && !["running", "succeeded", "failed"].includes(current.state) ? "Operation status is unavailable. Controls stay locked; retry status or unlock to recover the session." : (label === "failed" ? "Update failed. Review the operation log and backend guidance, then retry when ready." : (current.phase ? `${current.label || "Operation"}: ${current.phase}${current.target_version ? ` · ${current.target_version}` : ""}${current.backup_path ? ` · backup ${current.backup_path}` : ""}` : "")); - const output = (current.output || []).join("\n"); - $("#operation-output").textContent = output || "No operation has run in this session."; + const uncertain = Boolean(state.pendingOperation && !["running", "succeeded", "failed"].includes(current.state)); + setNotice("opstatus", uncertain ? "Operation status is unavailable. Controls stay locked; use Retry now or unlock again to recover the session." : ""); + state.operation = current; + if (current.id != null && (active || terminalForPending)) state.watching.add(operationKey(current)); + renderBanner(current, { active, uncertain, verificationRequired: label === "verification-required" }); if (active) { clearTimeout(state.poller); - state.poller = setTimeout(refreshStatus, 1200); + state.poller = setTimeout(refreshStatus, BUSY_POLL_MS); } - if (label === "failed") toast("Operation failed. Review guidance and retry.", true); - if (verified && current.label === "update-release") toast(`Installed ${current.target_version}. The running panel confirmed the new version.`); - if (verified && current.label === "restart-panel") toast("Control panel restart completed and was verified."); +} + +async function startOperation(payload) { + const session = state.session; + if (state.busy) return; + const label = payload.component ? `${payload.action} ${payload.component}` : payload.action; + state.pendingOperation = { baselineId: state.lastOperationId, operationId: null, label }; + setBusy(true); + renderBanner(state.operation, { active: true, uncertain: false }); + try { + const operation = await api(payload.component ? "/api/component" : "/api/action", { method: "POST", body: JSON.stringify(payload) }); + if (session !== state.session || !state.token) return; + if (state.pendingOperation) state.pendingOperation.operationId = operation.id ?? null; + renderOperation(operation); + refreshStatus(); + } catch (error) { + if (session !== state.session || !state.token) return; + if (error.status === 401) { requireReauth("Your session ended."); return; } + if (error.status >= 400 && error.status < 500) { + state.pendingOperation = null; + setBusy(false); + renderBanner(state.operation, { active: false, uncertain: false }); + toast(error.message, true); + refreshStatus(); + return; + } + // A POST can be accepted even when its response is lost. Reconcile status + // before releasing controls; only a terminal status may clear busy state. + toast(`${error.message}. Reconnecting to verify the operation…`, true); + refreshStatus(); + } +} + +async function runAction(action, extra = {}) { + const spec = action === "update" ? updateConfirm(extra) : CONFIRMS[action]; + if (spec && !(await ask(spec))) return; + startOperation({ action, ...extra }); +} + +// ---------------------------------------------------------------- status polling + +function schedulePoll(delay) { + clearTimeout(state.poller); + state.poller = setTimeout(refreshStatus, delay); } async function refreshStatus() { const session = state.session; + if (!state.token) return; + if (typeof document.hidden === "boolean" && document.hidden && !state.operationRunning) { schedulePoll(IDLE_POLL_MS); return; } try { const data = await api("/api/status"); if (session !== state.session || !state.token) return; if (state.bootId && data.boot_id && state.bootId !== data.boot_id) { if (isConfigDirty()) { - toast("The panel restarted, but unsaved configuration edits were preserved. Reload after saving or discarding them.", true); + setNotice("restarted", "The Control Panel restarted. Your unsaved edits are kept. Save or discard them, then reload this page."); } else { state.bootId = data.boot_id; window.location.reload(); @@ -336,78 +581,240 @@ async function refreshStatus() { } state.bootId = data.boot_id || state.bootId; state.reconnectAttempts = 0; + clearTimeout(state.reconnectTimer); + setNotice("offline", ""); if (data.operation?.id != null) state.lastOperationId = data.operation.id; + state.status = data; if (data.installed_version) state.installedVersion = data.installed_version; if (data.running_version) state.runningVersion = data.running_version; - $("#release-versions").textContent = `Installed: ${state.installedVersion} · Running: ${state.runningVersion}${state.releaseCheckedAt ? ` · Checked ${state.releaseCheckedAt}` : ""}`; + $("#version-chip").textContent = state.installedVersion; + $("#version-chip").hidden = state.installedVersion === "unknown"; + $("#boat-name").textContent = data.boat_name || ""; + setNotice("restart-needed", data.restart_needed ? "A newer Control Panel is installed than the one running. Restart it from the Maintenance tab to use the new version." : ""); + renderUpdates(); renderComponents(data.components); + setConnection("connected"); renderOperation(data.operation); - const active = data.components.filter((item) => item.enabled && statusClass(item.state) === "active").length; - const enabled = data.components.filter((item) => item.enabled).length; - $("#summary").textContent = `${active} of ${enabled} enabled components are active.`; - setConnected(true); + if (!state.operationRunning) schedulePoll(IDLE_POLL_MS); } catch (error) { if (session !== state.session || !state.token) return; - if (error.status === 401) { requireReauth("Session expired."); return; } - setConnected(false); + if (error.status === 401) { requireReauth("Your session ended."); return; } + setConnection("disconnected"); setBusy(true); state.reconnectAttempts += 1; - $("#component-grid").innerHTML = `
    Reconnecting…

    ${escapeHtml(error.message)} Controls stay locked until the backend confirms state. Retry ${Math.min(state.reconnectAttempts, 5)} of 5.

    `; + const more = state.reconnectAttempts <= 5 ? `Retrying (${Math.min(state.reconnectAttempts, 5)} of 5)…` : "Press Retry now to try again."; + setNotice("offline", `Can't reach the boat computer (${error.message}). Controls are paused until it answers. ${more}`); clearTimeout(state.reconnectTimer); if (state.reconnectAttempts <= 5) state.reconnectTimer = setTimeout(refreshStatus, Math.min(2000 * state.reconnectAttempts, 8000)); - else toast("Reconnect timed out. Use Unlock to retry the session.", true); } } -function groupFields(fields) { - return fields.reduce((groups, item) => { - (groups[item.section] ||= []).push(item); - return groups; - }, {}); +// ---------------------------------------------------------------- updates + +function releaseUrl(value) { + try { + const url = new URL(value); + return url.protocol === "https:" && url.hostname === "github.com" && /^\/[^/]+\/[^/]+\/releases\/tag\/[^/]+$/.test(url.pathname) ? url.href : ""; + } catch (_) { return ""; } +} + +function versionTuple(value) { + const match = String(value || "").match(/^(\d+)\.(\d+)\.(\d+)/); + return match ? match.slice(1).map(Number) : null; +} + +function compareVersions(a, b) { + const left = versionTuple(a); + const right = versionTuple(b); + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +function releaseEligible(release) { + const candidate = versionTuple(release.version); + const installed = versionTuple(state.installedVersion); + if (!candidate || !installed) return false; + return compareVersions(release.version, state.installedVersion) > 0; +} + +const eligibleReleases = () => state.releases.filter(releaseEligible).sort((a, b) => compareVersions(b.version, a.version)); + +function updateReleaseControls() { + $("#check-releases").disabled = state.releaseCheckPending || state.busy; + $$("[data-install]").forEach((button) => { button.disabled = state.releaseCheckPending || state.busy; }); +} + +function renderUpdates() { + $("#installed-version").textContent = state.installedVersion; + $("#running-note").textContent = state.runningVersion !== "unknown" && state.runningVersion !== state.installedVersion ? `Running ${state.runningVersion}. Restart the Control Panel to use ${state.installedVersion}.` : "Running now"; + const available = eligibleReleases(); + const badge = $("#update-badge"); + badge.hidden = !available.length; + badge.textContent = String(available.length); + badge.setAttribute?.("aria-label", `${available.length} update available`); + let html = ""; + if (state.releaseCheckPending) html = `

    Contacting github.com…

    `; + else if (state.releaseError) html = ``; + else if (!state.releaseChecked) html = `

    Not checked yet. Nothing is contacted until you press the button.

    `; + else if (!available.length) html = ``; + else { + html = ``; + html += available.map((release) => `

    ${escapeHtml(release.name)}${release.prerelease ? " (early test release)" : ""}

    Read the release notes on GitHub

    Takes a backup first, then installs. The Control Panel may restart and this page will reconnect by itself. Boat services pause briefly.
    `).join(""); + if (state.releaseChannel === "lab") html += `

    Early test releases are for testing. Read the release notes before installing.

    `; + } + $("#release-result").innerHTML = html; + updateReleaseControls(); + renderBand(); +} + +function renderReleases(data) { + state.installedVersion = data.installed_version || state.installedVersion; + state.runningVersion = data.running_version || state.runningVersion; + state.releaseCheckedAt = clockTime((data.checked_at ? new Date(data.checked_at).getTime() : Date.now()) / 1000); + state.releases = Array.isArray(data.releases) ? data.releases.filter((release) => Number.isInteger(release.id) && release.tag && release.version && release.name && releaseUrl(release.html_url)) : []; + state.releaseChecked = true; + state.releaseError = ""; + renderUpdates(); +} + +async function checkReleases() { + const session = state.session; + const epoch = ++state.releaseEpoch; + const channel = $("#include-lab").checked ? "lab" : "stable"; + state.releaseChannel = channel; + state.releaseCheckPending = true; + state.releaseError = ""; + renderUpdates(); + try { + const data = await api(`/api/releases?channel=${channel}`); + if (session !== state.session || epoch !== state.releaseEpoch || !state.token) return; + renderReleases(data); + } catch (error) { + if (session === state.session && epoch === state.releaseEpoch && state.token) { + if (error.status === 401) requireReauth("Your session ended."); + else { state.releaseError = error.message; state.releaseChecked = false; } + } + } finally { + if (session === state.session && epoch === state.releaseEpoch && state.token) { + state.releaseCheckPending = false; + renderUpdates(); + } + } +} + +async function installRelease(releaseId) { + const release = state.releases.find((item) => item.id === Number(releaseId) && releaseEligible(item)); + if (!release || state.busy) return; + if (!(await ask(releaseConfirm(release)))) return; + startOperation({ action: "update-release", release_id: release.id, channel: state.releaseChannel }); } +// ---------------------------------------------------------------- settings + +const slug = (name) => String(name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + function fieldMarkup(spec, value) { const id = `setting-${spec.key}`; - const help = spec.description ? `${spec.description}` : ""; - const source = spec.source === "chat" ? "Boat Chat" : "VesselStack"; - const sourceNote = `${source}${spec.read_only ? " · read-only" : ""}`; + const help = spec.description ? `${escapeHtml(spec.description)}` : ""; + const described = ` aria-describedby="${id}-err${spec.description ? ` ${id}-help` : ""}"`; + const error = ``; + const readOnly = spec.read_only ? ' disabled' : ""; + const tag = spec.read_only ? 'read-only' : ""; + if (spec.kind === "boolean") { + return `
    ${error}
    `; + } let input; if (spec.kind === "select") { - input = ``; - } else if (spec.kind === "boolean") { - input = ``; + input = ``; } else { - const type = spec.kind === "secret" ? "password" : ["number", "port"].includes(spec.kind) ? "number" : "text"; + const type = spec.kind === "secret" ? "password" : "text"; const shown = spec.kind === "secret" ? "" : escapeHtml(value || ""); - const placeholder = spec.kind === "secret" && value?.configured ? "Configured — leave blank to keep" : ""; - const numeric = spec.kind === "number" ? 'step="any"' : spec.kind === "port" ? 'step="1" min="1" max="65535"' : ""; - input = ``; + const configured = spec.kind === "secret" && value?.configured; + const placeholder = spec.kind === "secret" ? (configured ? "••••••••••••" : "Not set") : ""; + // Numeric settings stay text inputs so a saved value such as "12m" is shown as stored + // (a type=number input would display it blank and the next Save would clear it). + const numeric = spec.kind === "number" ? 'inputmode="decimal"' : spec.kind === "port" ? 'inputmode="numeric"' : ""; + const secretAttrs = spec.kind === "secret" ? ' autocomplete="new-password"' : ' autocomplete="off"'; + input = ``; } + const saved = spec.kind === "secret" && value?.configured ? `✓ Saved${spec.read_only ? "" : " · leave blank to keep"}` : ""; const clear = spec.kind === "secret" && value?.configured && !spec.read_only ? `` : ""; - return `
    ${input}${clear}${help}
    `; + return `
    ${input}${error}${help}${saved}${clear}
    `; } function renderConfiguration(config) { - const sections = groupFields(config.fields); - $("#config-form").innerHTML = Object.entries(sections).map(([name, fields]) => ` -

    ${name}

    ${fields.map((spec) => fieldMarkup(spec, config.values[spec.key])).join("")}
    - `).join(""); - $("#config-form").oninput = () => updateConfigStatus(); - $("#config-form").onchange = () => updateConfigStatus(); + $$("#config-form details[data-section]").forEach((item) => { if (item.open) state.openSections.add(item.dataset.section); }); + const names = (config.sections || []).map((item) => item.name); + [...new Set(config.fields.map((item) => item.section))].forEach((name) => { if (!names.includes(name)) names.push(name); }); + const info = Object.fromEntries((config.sections || []).map((item) => [item.name, item])); + const bySection = (name) => config.fields.filter((item) => item.section === name); + const body = (name) => `${info[name]?.advanced && info[name]?.lead && bySection(name).every((item) => item.read_only) ? `

    ${escapeHtml(info[name].lead)}

    ` : ""}
    ${bySection(name).map((spec) => fieldMarkup(spec, config.values[spec.key])).join("")}
    `; + const common = names.filter((name) => !info[name]?.advanced && bySection(name).length); + const advanced = names.filter((name) => info[name]?.advanced && bySection(name).length); + const jump = ``; + const commonHtml = common.map((name) => `

    ${escapeHtml(name)}

    ${escapeHtml(info[name]?.lead || "")}

    ${body(name)}
    `).join(""); + const advancedHtml = advanced.length ? `

    Advanced

    Rarely needed. Open a section only if you know you need it.

    ${advanced.map((name) => `

    ${escapeHtml(name)}

    ${escapeHtml(info[name]?.lead || "")}

    ${body(name)}
    `).join("")}
    ` : ""; + $("#config-form").innerHTML = `${jump}
    ${commonHtml}${advancedHtml}
    `; + $("#config-form").oninput = (event) => { clearFieldError(event.target); updateConfigStatus(); }; + $("#config-form").onchange = (event) => { clearFieldError(event.target); updateConfigStatus(); }; updateConfigStatus(); + updateScrollSpy(); +} + +function clearFieldError(control) { + const field = control?.closest?.(".field"); + if (!field || !field.classList.contains("invalid")) return; + field.classList.remove("invalid"); + control.removeAttribute("aria-invalid"); + const message = field.querySelector(".err"); + if (message) message.textContent = ""; +} + +function showFieldErrors(fields) { + let first = null; + Object.entries(fields).forEach(([key, message]) => { + const control = $(`#setting-${key}`); + const field = control?.closest?.(".field"); + if (!field) return; + field.classList.add("invalid"); + control.setAttribute("aria-invalid", "true"); + field.querySelector(".err").textContent = message; + const details = control.closest("details"); + if (details) details.open = true; + first ||= control; + }); + if (first) { first.scrollIntoView({ block: "center" }); first.focus({ preventScroll: true }); } + updateConfigStatus(); +} + +function updateScrollSpy() { + const links = $$("[data-jump]"); + if (!links.length) return; + let current = links[0]; + links.forEach((link) => { + const target = $(`#s-${slug(link.dataset.jump)}`); + if (target && target.getBoundingClientRect().top < 160) current = link; + }); + links.forEach((link) => { + link.classList.toggle("on", link === current); + if (link === current) link.setAttribute("aria-current", "location"); + else link.removeAttribute("aria-current"); + }); } function currentSettings() { - const form = new FormData($("#config-form")); - const settings = Object.fromEntries(form.entries()); - // FormData omits disabled controls. Busy-state freezes the form, so read - // editable controls directly to keep unsaved edits visible to dirty checks. + const settings = {}; + // Read editable controls directly: busy-state disables the form, and unchecked + // switches are absent from FormData, so neither can be left to the browser. $$("#config-form input[name], #config-form select[name]").forEach((control) => { - if (control.dataset.readOnly !== "true" && control.type !== "checkbox") settings[control.name] = control.value; + if (control.dataset.readOnly === "true") return; + settings[control.name] = control.dataset.bool === "true" ? String(Boolean(control.checked)) : control.value; }); - $$('[data-clear-secret]:checked').forEach((checkbox) => { settings[checkbox.dataset.clearSecret] = null; }); + $$('[data-clear-secret]').forEach((checkbox) => { if (checkbox.checked) settings[checkbox.dataset.clearSecret] = null; }); return settings; } @@ -419,10 +826,10 @@ function defaultFieldValue(spec, original) { return ""; } -function isConfigDirty() { - if (!state.config) return false; +function dirtyKeys() { + if (!state.config) return []; const current = currentSettings(); - return state.config.fields.some((spec) => { + return state.config.fields.filter((spec) => { if (spec.read_only) return false; const original = state.config.values[spec.key]; const next = Object.hasOwn(current, spec.key) ? current[spec.key] : defaultFieldValue(spec, original); @@ -433,18 +840,31 @@ function isConfigDirty() { } const baseline = spec.kind === "boolean" && (original === undefined || original === null || original === "") ? "false" : spec.kind === "select" && (original === undefined || original === null || original === "") ? String(spec.choices?.[0] || "") : String(original ?? ""); return String(next ?? "") !== baseline; - }); + }).map((spec) => spec.key); } +const isConfigDirty = () => dirtyKeys().length > 0; + function updateConfigStatus() { - const dirty = isConfigDirty(); - $("#config-status").textContent = dirty ? "Unsaved changes" : "All changes saved"; - $("#save-config").disabled = !dirty || state.busy || state.saving; - $("#discard-config").disabled = !dirty || state.busy || state.saving; + const dirty = dirtyKeys(); + const count = dirty.length; + const invalid = $$(".field.invalid").length; + $$(".field").forEach((field) => { + const control = field.querySelector("[name]"); + field.classList.toggle("dirty", Boolean(control) && dirty.includes(control.name)); + }); + const stateNode = $("#sb-state"); + stateNode.textContent = count ? `Unsaved changes (${count})${invalid ? ` · ${invalid} need${invalid === 1 ? "s" : ""} fixing` : ""}` : (state.config ? "All changes saved" : "No changes"); + stateNode.className = `state${invalid ? " err" : count ? " dirty" : ""}`; + $("#sb-note").textContent = count ? "Save first to apply" : "Saved settings are ready to apply"; + $("#rollback-note").textContent = state.rollbackPath ? `Previous settings were kept at ${state.rollbackPath}` : ""; + $("#sb-apply").disabled = count > 0 || state.busy || state.saving; + $("#sb-save").disabled = !count || state.busy || state.saving; + $("#sb-discard").disabled = !count || state.busy || state.saving; } async function saveConfiguration() { - if (!isConfigDirty()) return toast("No configuration changes to save."); + if (!isConfigDirty()) return toast("No settings changes to save."); const current = currentSettings(); const session = state.session; const controls = $$("#config-form input, #config-form select"); @@ -470,11 +890,17 @@ async function saveConfiguration() { if (session !== state.session || !state.token) return; state.config = result.configuration; renderConfiguration(state.config); - toast(result.backup ? `Configuration saved. Rollback copy: ${result.backup}` : "Configuration saved. Apply it when ready."); + if (result.backup) state.rollbackPath = result.backup; + updateConfigStatus(); + toast("Settings saved. Nothing has changed on the boat yet. Press Apply to put them into effect."); } catch (error) { if (session !== state.session || !state.token) return; + if (error.status === 401) { requireReauth("Your session ended."); return; } controls.forEach((control) => { control.disabled = control.dataset.wasDisabled === "true"; }); - toast(error.message, true); + if (error.fields) { + showFieldErrors(error.fields); + toast(`${Object.keys(error.fields).length} ${Object.keys(error.fields).length === 1 ? "setting needs" : "settings need"} fixing before they can be saved.`, true); + } else toast(error.message, true); } finally { if (session === state.session && state.token) { state.saving = false; @@ -483,92 +909,116 @@ async function saveConfiguration() { } } -async function startOperation(payload) { - const session = state.session; - if (state.busy) return; - state.pendingOperation = { baselineId: state.lastOperationId, operationId: null }; - setBusy(true); - try { - const operation = await api(payload.component ? "/api/component" : "/api/action", { method: "POST", body: JSON.stringify(payload) }); - if (session !== state.session || !state.token) return; - if (state.pendingOperation) state.pendingOperation.operationId = operation.id ?? null; - renderOperation(operation); - toast(`Started: ${operation.label}`); - refreshStatus(); - } catch (error) { - if (session !== state.session || !state.token) return; - if (error.status === 401) { requireReauth("Session expired."); return; } - if (error.status >= 400 && error.status < 500) { - state.pendingOperation = null; - setBusy(false); - toast(error.message, true); - refreshStatus(); - return; - } - // A POST can be accepted even when its response is lost. Reconcile status - // before releasing controls; only a terminal status may clear busy state. - toast(`${error.message}. Reconnecting to verify the operation…`, true); - refreshStatus(); - } -} - -function runAction(action, extra = {}) { - const confirmations = { - install: "Install and start the configured VesselStack components?", - apply: "Apply the saved configuration to the installed system?", - stop: "Stop the managed VesselStack services? The control panel will remain available.", - update: "Create a backup and apply this reviewed release? The panel and services may be interrupted, and a writable backup mount is required.", - }; - if (confirmations[action] && !window.confirm(confirmations[action])) return; - startOperation({ action, ...extra }); -} - -function runComponent(component, action) { - if (action === "remove" && !window.confirm(`Remove ${component}? Existing data is preserved, but integrations may stop working.`)) return; - if (action === "stop" && !window.confirm(`Stop ${component}?`)) return; - startOperation({ component, action }); +async function discardEdits() { + const count = dirtyKeys().length; + if (!count || !(await ask(discardConfirm(count)))) return; + renderConfiguration(state.config); + toast("Edits discarded."); } function setBusy(busy) { state.busy = busy; state.operationRunning = busy; - $$('[data-action], #save-config, #discard-config, #run-update, #check-releases, #release-select, #install-release, #restart-panel, [data-component]').forEach((button) => { button.disabled = busy || button.dataset.configDisabled === "true"; }); - $("#refresh").disabled = false; + $$('[data-action], #sb-save, #sb-discard, #run-update, #check-releases, [data-install], [data-component], [data-more], [data-disable]').forEach((button) => { button.disabled = busy; }); $$("#config-form input, #config-form select").forEach((control) => { control.disabled = busy || state.saving || control.dataset.readOnly === "true"; }); - $("#check-releases").disabled = busy || state.releaseCheckPending; updateReleaseControls(); updateConfigStatus(); } +// ---------------------------------------------------------------- wiring + $("#login-form").addEventListener("submit", (event) => { event.preventDefault(); - state.token = $("#token").value.trim(); + const token = normalizeToken($("#token").value); + if (!token) { loginError("Paste the token first. It is the long value after CONTROL_PANEL_TOKEN= on the boat computer."); $("#token").focus(); return; } + loginError(""); + state.token = token; unlock(); }); -$$('[data-tab]').forEach((button) => button.addEventListener("click", () => { - $$('[data-tab]').forEach((item) => item.classList.toggle("active", item === button)); - $$('.tab-panel').forEach((panel) => { panel.hidden = panel.id !== button.dataset.tab; }); +$("#token").addEventListener("input", () => loginError("")); +$("#show-token").addEventListener("change", (event) => { $("#token").type = event.target.checked ? "text" : "password"; }); + +$$("[data-tab]").forEach((button) => button.addEventListener("click", () => selectTab(button.dataset.tab))); +$$("[data-tab]").forEach((button, index, tabs) => button.addEventListener("keydown", (event) => { + const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; + const edge = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : null; + if (!step && edge === null) return; + event.preventDefault(); + const target = edge !== null ? tabs[edge] : tabs[(index + step + tabs.length) % tabs.length]; + target.click(); + target.focus(); })); -$$('[data-action]').forEach((button) => button.addEventListener("click", () => runAction(button.dataset.action))); -$("#refresh").addEventListener("click", refreshStatus); -$("#save-config").addEventListener("click", saveConfiguration); -$("#discard-config").addEventListener("click", () => { if (!isConfigDirty() || window.confirm("Discard unsaved configuration edits?")) renderConfiguration(state.config); }); + +document.addEventListener("click", (event) => { + const target = event.target.closest?.("[data-action], [data-tab-link], [data-dismiss-notice], #banner-x, [data-install]"); + if (!target) return; + if (target.dataset.action) runAction(target.dataset.action); + else if (target.dataset.tabLink) selectTab(target.dataset.tabLink); + else if (target.dataset.dismissNotice) setNotice(target.dataset.dismissNotice, ""); + else if (target.dataset.install) installRelease(target.dataset.install); + else if (target.id === "banner-x") { + if (state.operation?.id != null) state.dismissed.add(operationKey(state.operation)); + $("#banner").innerHTML = ""; + state.bannerHtml = ""; + } +}); + +$("#component-grid").addEventListener("click", (event) => { + const target = event.target.closest("button"); + if (!target) return; + if (target.dataset.more) { + state.menuOpen = state.menuOpen === target.dataset.more ? null : target.dataset.more; + state.pendingFocus = state.menuOpen ? `${state.menuOpen}-disable` : `${target.dataset.more}-more`; + renderComponents(state.components, true); + } else if (target.dataset.disable) { + state.menuOpen = null; + runComponent(target.dataset.disable, "remove"); + renderComponents(state.components, true); + } else if (target.dataset.verb) runComponent(target.dataset.component, target.dataset.verb); + else if (target.dataset.configureSection) jumpToConfiguration(target.dataset.configureSection); +}); +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && state.menuOpen && !$("#confirm-dialog").open) { + const id = state.menuOpen; + state.menuOpen = null; + state.pendingFocus = `${id}-more`; + renderComponents(state.components, true); + } +}); + +$("#config-form").addEventListener("click", (event) => { + const link = event.target.closest?.("[data-jump]"); + if (!link) return; + event.preventDefault(); + const target = $(`#s-${slug(link.dataset.jump)}`); + if (target?.tagName === "DETAILS") target.open = true; + target?.scrollIntoView({ behavior: "smooth", block: "start" }); +}); +window.addEventListener("scroll", updateScrollSpy, { passive: true }); + +$("#toast-close").addEventListener("click", () => { $("#toast").className = ""; }); +$("#retry").addEventListener("click", () => { state.reconnectAttempts = 0; clearTimeout(state.reconnectTimer); refreshStatus(); }); +$("#sb-save").addEventListener("click", saveConfiguration); +$("#sb-discard").addEventListener("click", discardEdits); $("#run-update").addEventListener("click", () => runAction("update", { source_directory: $("#update-source").value.trim() })); $("#check-releases").addEventListener("click", checkReleases); -$("#include-lab").addEventListener("change", checkReleases); -$("#release-select").addEventListener("change", updateReleaseDetail); -$("#install-release").addEventListener("click", installSelectedRelease); -$("#restart-panel").addEventListener("click", () => { if (window.confirm("Restart the control panel? The panel will briefly disconnect.")) startOperation({ action: "restart-panel" }); }); -$("#lock").addEventListener("click", lock); -$("#component-search").addEventListener("input", (event) => { state.componentQuery = event.target.value; renderComponents(state.components); }); -$$('[data-component-filter]').forEach((button) => button.addEventListener("click", () => { +$("#include-lab").addEventListener("change", () => { + // Choosing a channel never contacts GitHub; the next check uses it. + state.releaseEpoch += 1; + state.releaseCheckPending = false; + state.releases = []; + state.releaseChecked = false; + state.releaseError = ""; + $("#early-note").hidden = !$("#include-lab").checked; + renderUpdates(); +}); +$("#lock").addEventListener("click", () => lock()); +$("#component-search").addEventListener("input", (event) => { state.componentQuery = event.target.value; renderComponents(state.components, true); }); +$$("[data-component-filter]").forEach((button) => button.addEventListener("click", () => { state.componentFilter = button.dataset.componentFilter; - $$('[data-component-filter]').forEach((item) => item.classList.toggle("active", item === button)); - renderComponents(state.components); -})); -$$('[data-tab]').forEach((button) => button.addEventListener("click", () => { - if (button.dataset.tab === "configuration" && state.config) setTimeout(() => $("#config-form input:not([disabled]), #config-form select:not([disabled])")?.focus(), 0); + renderComponents(state.components, true); })); +document.addEventListener("visibilitychange", () => { if (!document.hidden && state.token && !state.busy) refreshStatus(); }); window.addEventListener("beforeunload", (event) => { if (isConfigDirty()) { event.preventDefault(); event.returnValue = ""; } }); diff --git a/control-panel/static/index.html b/control-panel/static/index.html index 8d42dc4..f6f062d 100644 --- a/control-panel/static/index.html +++ b/control-panel/static/index.html @@ -3,8 +3,9 @@ - - + + + VesselStack Control Panel @@ -13,77 +14,151 @@
    -
    VesselStackControl Panel
    + VesselStack Control Panel +
    +
    + Locked + +
    -
    Locked
    -
    +
    +

    +

    +
      + +
      +
      + +
      diff --git a/control-panel/static/styles.css b/control-panel/static/styles.css index f335b63..d7b4283 100644 --- a/control-panel/static/styles.css +++ b/control-panel/static/styles.css @@ -1,137 +1,310 @@ +/* VesselStack Control Panel. Brand tokens: brand/README.md. Dark palette follows prefers-color-scheme. No external requests. */ :root { - --ink: #eef8f7; - --muted: #91aaa8; - --panel: #102421; - --panel-2: #152e2a; - --line: #29433e; - --sea: #4ed6b1; - --sea-dark: #163c33; - --amber: #f5bf66; - --red: #ff7b72; - --bg: #081714; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color-scheme: light dark; + --bg:#f4f8fa; --panel:#fff; --panel-2:#eaf2f6; --input-bg:#fff; --ink:#0a2341; --secondary:#344c61; --muted:#526778; + --line:#d4e0e8; --strong:#778b9b; --sea:#006d89; --sea-dark:#00566d; --on-sea:#fff; --sea-soft:#dceef3; + --success:#1e6f45; --success-soft:#e1f1e8; --amber:#8a6218; --amber-soft:#f6ecd6; --red:#b3382e; --red-soft:#f8e4e1; --on-red:#fff; + --hover-bg:rgba(0,109,137,.08); --topbar-bg:rgba(255,255,255,.96); --shadow:0 14px 34px rgba(10,35,65,.07); --focus:#006d89; + --font-body:"Public Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; + --font-head:Archivo,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; + --font-mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; + font-family:var(--font-body); } -* { box-sizing: border-box; } -body { margin: 0; min-height: 100vh; color: var(--ink); background: radial-gradient(circle at 15% 0%, #17332e 0, transparent 30rem), var(--bg); } -button, input, select { font: inherit; } -button { min-height: 2.65rem; padding: .7rem 1rem; color: var(--ink); background: transparent; border: 1px solid var(--line); border-radius: .65rem; cursor: pointer; transition: .15s ease; } -button:hover { border-color: var(--sea); background: rgba(78, 214, 177, .08); } -button:disabled { opacity: .45; cursor: not-allowed; } -button.primary { color: #06221b; font-weight: 750; background: var(--sea); border-color: var(--sea); } -button.primary:hover { background: #75e4c5; } -button.danger { color: var(--red); } -input, select { width: 100%; min-height: 2.65rem; padding: .65rem .75rem; color: var(--ink); background: #0a1d1a; border: 1px solid var(--line); border-radius: .55rem; } -input:focus, select:focus { outline: 2px solid rgba(78, 214, 177, .32); border-color: var(--sea); } -input:disabled, select:disabled { color: #78918e; background: #0d1917; cursor: not-allowed; } -label { display: block; margin-bottom: .4rem; font-size: .86rem; font-weight: 650; } -.field-source { float: right; color: var(--muted); font-size: .68rem; font-weight: 500; letter-spacing: .02em; } -code { display: block; overflow-x: auto; padding: .8rem; color: var(--muted); background: #07110f; border-radius: .5rem; } -.topbar { height: 4.75rem; padding: 0 clamp(1rem, 4vw, 4rem); display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(8, 23, 20, .8); backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 5; } -.topbar-actions { display: flex; align-items: center; gap: 1rem; } -.quiet { min-height: 2rem; padding: .35rem .65rem; font-size: .78rem; } -.brand { display: flex; align-items: center; gap: .75rem; } -.brand .brand-mark { display: grid; place-items: center; width: 2.4rem; height: 2.4rem; color: #06221b; font-weight: 900; background: var(--sea); border-radius: .55rem; } -.brand strong, .brand span { display: block; } -.brand div span { color: var(--muted); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; } -.connection { display: flex; align-items: center; gap: .55rem; color: var(--muted); font-size: .85rem; } -.dot { width: .55rem; height: .55rem; background: var(--amber); border-radius: 50%; box-shadow: 0 0 0 .25rem rgba(245, 191, 102, .12); } -.dot.online { background: var(--sea); box-shadow: 0 0 0 .25rem rgba(78, 214, 177, .12); } -main { width: min(1240px, calc(100% - 2rem)); margin: 0 auto; padding: clamp(2rem, 5vw, 5rem) 0 5rem; } -.card { background: linear-gradient(145deg, rgba(21, 46, 42, .94), rgba(13, 31, 27, .94)); border: 1px solid var(--line); border-radius: 1rem; box-shadow: 0 24px 70px rgba(0,0,0,.18); } -.login { max-width: 650px; margin: 7vh auto; padding: clamp(1.5rem, 5vw, 3.5rem); } -h1, h2, h3, p { margin-top: 0; } -h1 { max-width: 820px; margin-bottom: 1rem; font-size: clamp(2.2rem, 6vw, 5.5rem); line-height: .98; letter-spacing: -.045em; } -.login h1 { font-size: clamp(2.2rem, 5vw, 4rem); } -h2 { margin-bottom: .2rem; font-size: clamp(1.65rem, 3vw, 2.3rem); letter-spacing: -.025em; } -h3 { margin-bottom: .5rem; } -p { color: var(--muted); line-height: 1.6; } -.eyebrow { margin-bottom: .75rem; color: var(--sea); font-size: .7rem; font-weight: 800; letter-spacing: .15em; } -.token-row, .inline-actions { display: flex; gap: .6rem; } -.token-row { margin-bottom: 1rem; } -.error { margin-top: 1rem; color: var(--red); } -.hero { display: grid; grid-template-columns: 1fr auto; gap: 2rem; align-items: end; padding-bottom: 3.5rem; } -.hero-actions { display: grid; gap: .75rem; min-width: 13rem; } -.tabs { display: flex; gap: 1.75rem; margin-bottom: 2.4rem; border-bottom: 1px solid var(--line); } -.tabs button { padding: .85rem 0; border: 0; border-bottom: 2px solid transparent; border-radius: 0; color: var(--muted); } -.tabs button:hover, .tabs button.active { color: var(--ink); background: transparent; border-bottom-color: var(--sea); } -.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.2rem; } -.component-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; } -.overview { display: grid; grid-template-columns: repeat(4, 1fr); gap: .75rem; margin-bottom: .9rem; } -.overview div { padding: .9rem 1rem; background: var(--panel); border: 1px solid var(--line); border-radius: .7rem; } -.overview strong, .overview span { display: block; } -.overview strong { font-size: 1.5rem; } -.overview span, .catalog-note { color: var(--muted); font-size: .8rem; } -.catalog-note { margin: 0 0 1.1rem; } -.component-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; } -.search-field { width: min(26rem, 100%); margin: 0; } -.filter-group { display: flex; gap: .35rem; } -.filter-group button { min-height: 2.2rem; padding: .45rem .7rem; font-size: .8rem; } -.filter-group button.active { color: var(--sea); border-color: var(--sea); } -.empty-state { grid-column: 1 / -1; padding: 2.4rem; text-align: center; background: var(--panel); border: 1px dashed var(--line); border-radius: .85rem; } -.empty-state p { margin: .35rem 0 0; } -.form-status { min-height: 1.4rem; margin: -.3rem 0 .8rem; color: var(--amber); font-size: .82rem; } -.component { min-width: 0; min-height: 175px; padding: 1.2rem; display: flex; flex-direction: column; justify-content: space-between; background: var(--panel); border: 1px solid var(--line); border-radius: .85rem; } -.component-top { display: flex; align-items: flex-start; justify-content: space-between; gap: .5rem; } -.component-top h3 { min-width: 0; overflow-wrap: anywhere; } -.component-status { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: .35rem; min-width: 0; } -.availability { color: var(--muted); font-size: .68rem; text-transform: capitalize; } -.component h3 { text-transform: capitalize; } -.component p { margin: 0; font-size: .82rem; } -.component-actions { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.25rem; } -.component-actions button { min-width: 0; min-height: 2.2rem; flex: 1 1 auto; padding: .45rem .65rem; font-size: .8rem; } -.pill { display: inline-flex; align-items: center; width: fit-content; padding: .25rem .55rem; color: var(--muted); background: #0a1d1a; border: 1px solid var(--line); border-radius: 999px; font-size: .72rem; text-transform: capitalize; } -.pill.active, .pill.running, .pill.succeeded { color: var(--sea); border-color: rgba(78, 214, 177, .35); } -.pill.failed { color: var(--red); border-color: rgba(255, 123, 114, .35); } -.pill.disabled { color: var(--amber); } -.notice { margin-bottom: 1.2rem; padding: 1rem; color: var(--muted); background: rgba(78, 214, 177, .06); border: 1px solid var(--line); border-radius: .7rem; line-height: 1.5; } -.config-section { margin-bottom: 1rem; padding: clamp(1rem, 3vw, 1.6rem); background: var(--panel); border: 1px solid var(--line); border-radius: .85rem; } -.config-section h3 { margin-bottom: 1.2rem; color: var(--sea); } -.field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(235px, 1fr)); gap: 1.15rem; } -.field small { display: block; margin-top: .35rem; color: var(--muted); line-height: 1.35; } -.field.secret input::placeholder { color: #708b87; } -.clear-secret { display: flex; align-items: center; gap: .45rem; margin: .45rem 0 0; color: var(--muted); font-size: .75rem; font-weight: 500; } -.clear-secret input { width: auto; min-height: auto; } -.maintenance-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; } -.action-card { padding: 1.3rem; } -.action-card p { min-height: 4.8rem; font-size: .9rem; } -.release-card { grid-column: 1 / -1; order: -1; } -.release-card p { min-height: 0; } -.release-card .release-toolbar { max-width: 42rem; } -.action-card > button { width: 100%; } -.action-card input { margin-bottom: .7rem; } -.release-toolbar { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; margin-bottom: .8rem; } -.release-toolbar .check-label { display: flex; align-items: center; gap: .45rem; margin: 0; color: var(--muted); font-size: .78rem; font-weight: 500; } -.release-toolbar .check-label input { width: auto; min-height: auto; } -.release-versions, .release-detail, .operation-progress { min-height: 1.4rem; margin: .45rem 0 .8rem; font-size: .8rem; } -.release-detail a { color: var(--sea); } -.release-warning { margin: .6rem 0 .8rem; color: var(--amber); font-size: .8rem; } -.release-progress { min-height: 1.5rem; margin-top: .8rem; color: var(--amber); font-size: .82rem; } -.advanced-update { margin-top: 1.15rem; padding-top: 1rem; border-top: 1px solid var(--line); } -.advanced-update summary { color: var(--muted); cursor: pointer; font-size: .8rem; } -.advanced-update p { margin: .8rem 0; font-size: .8rem; } -.advanced-update button { width: 100%; } -.operation { margin-top: 3rem; padding: clamp(1rem, 3vw, 1.5rem); } -.operation-progress { color: var(--amber); } -.operation pre { min-height: 9rem; max-height: 28rem; margin: 0; padding: 1rem; overflow: auto; white-space: pre-wrap; color: #bcd1ce; background: #050d0c; border-radius: .6rem; line-height: 1.55; } -#toast { position: fixed; right: 1rem; bottom: 1rem; max-width: min(28rem, calc(100vw - 2rem)); padding: .9rem 1rem; background: var(--ink); color: #071612; border-radius: .7rem; box-shadow: 0 14px 40px rgba(0,0,0,.35); transform: translateY(6rem); transition: .2s ease; z-index: 10; } -#toast.show { transform: translateY(0); } -#toast.error { color: white; background: #7e302d; } -@media (max-width: 720px) { - .hero { grid-template-columns: 1fr; } - .hero-actions { grid-template-columns: 1fr 1fr; } - .hero-actions .primary { grid-column: 1 / -1; } - .section-heading { align-items: flex-start; flex-direction: column; } - .inline-actions { width: 100%; flex-wrap: wrap; } - .token-row { align-items: stretch; flex-direction: column; } - .tabs { gap: 1rem; overflow-x: auto; } - .overview { grid-template-columns: repeat(2, 1fr); } - .component-toolbar { align-items: stretch; flex-direction: column; } - .search-field { width: 100%; } +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --bg:#0a1826; --panel:#10253a; --panel-2:#173247; --input-bg:#0a1826; --ink:#eaf2f6; --secondary:#eaf2f6; --muted:#b3c5d0; + --line:#36536a; --strong:#7892a5; --sea:#4aaebe; --sea-dark:#72c6ce; --on-sea:#0a1826; --sea-soft:#173d50; + --success:#78c99b; --success-soft:#143a2b; --amber:#e5bd68; --amber-soft:#3a2f14; --red:#f08a80; --red-soft:#43211f; --on-red:#0a1826; + --hover-bg:rgba(74,174,190,.14); --topbar-bg:rgba(16,37,58,.96); --shadow:0 14px 34px rgba(0,0,0,.35); --focus:#72c6ce; + } } +*{box-sizing:border-box} +html{scroll-behavior:smooth;scroll-padding-top:6rem} +body{margin:0;min-height:100vh;color:var(--ink);background:var(--bg);line-height:1.5} +button,input,select,textarea{font:inherit} +button{min-height:44px;padding:.6rem 1rem;color:var(--ink);background:transparent;border:1px solid var(--strong);border-radius:8px;cursor:pointer} +button:hover{border-color:var(--sea);background:var(--hover-bg)} +button:disabled{color:var(--muted);border-color:var(--line);background:var(--panel-2);opacity:.75;cursor:not-allowed} +button.primary{color:var(--on-sea);font-weight:700;background:var(--sea);border-color:var(--sea)} +button.primary:hover{background:var(--sea-dark);border-color:var(--sea-dark)} +button.primary:disabled{color:var(--muted);background:var(--panel-2);border-color:var(--line)} +button.danger{color:var(--red);border-color:var(--red)} +button.danger.solid{color:var(--on-red);background:var(--red);font-weight:700} +button.danger.solid:hover{filter:brightness(.92)} +button.quiet{border-color:transparent} +button.quiet:hover{border-color:var(--line)} +input,select,textarea{width:100%;min-height:44px;padding:.6rem .75rem;color:var(--ink);background:var(--input-bg);border:1px solid var(--strong);border-radius:8px} +input::placeholder{color:var(--muted);opacity:1} +input[readonly],input:disabled{color:var(--muted);background:var(--panel-2)} +:focus-visible{outline:3px solid var(--focus);outline-offset:2px} +a{color:var(--sea)} +h1,h2,h3,h4{font-family:var(--font-head);margin:0 0 .4rem;color:var(--ink)} +h1{font-size:clamp(1.7rem,4vw,2.4rem);letter-spacing:-.03em;line-height:1.1} +h2{font-size:1.45rem;letter-spacing:-.02em} +h3{font-size:1.1rem} +p{margin:0 0 .8rem;color:var(--muted)} +code,.mono{font-family:var(--font-mono)} +code{display:block;overflow-x:auto;white-space:pre;padding:.7rem .8rem;color:var(--secondary);background:var(--panel-2);border-radius:8px;font-size:.85rem} +code.inline{display:inline;white-space:normal;padding:.1rem .35rem} +label,.label{display:block;margin-bottom:.3rem;font-size:.88rem;font-weight:650} +.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +.eyebrow{margin:0 0 .5rem;color:var(--sea);font-size:.72rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase} +.card{background:var(--panel);border:1px solid var(--line);border-radius:1rem;box-shadow:var(--shadow);padding:clamp(1rem,3vw,1.5rem)} +.muted{color:var(--muted)} +.row{display:flex;gap:.6rem;flex-wrap:wrap;align-items:center} +.small{font-size:.85rem} -/* Chart-room presentation tokens; existing selectors retain behavior. */ -:root { --ink:#0a2341; --muted:#526778; --secondary:#344c61; --panel:#ffffff; --panel-2:#eaf2f6; --line:#d4e0e8; --sea:#006d89; --sea-dark:#00566d; --amber:#8a6218; --red:#b3382e; --bg:#f4f8fa; --success:#1e6f45; font-family:"Public Sans",ui-sans-serif,system-ui,sans-serif; } -body { background:var(--bg); } h1,h2,h3 { font-family:Archivo,ui-sans-serif,system-ui,sans-serif; } .brand-mark { display:block; background:transparent; color:transparent; } -body,.topbar { background:var(--bg); color:var(--ink); }.topbar { background:rgba(255,255,255,.96); }.card,.component,.config-section { background:var(--panel); box-shadow:0 14px 34px rgba(10,35,65,.07); } input,select { background:#fff; color:var(--ink); } input:disabled,select:disabled { background:var(--panel-2); color:var(--muted); } code,.operation pre,.pill { background:var(--panel-2); color:var(--secondary); } button.primary { color:#fff; background:var(--sea); } button.primary:hover { color:#fff; background:var(--sea-dark); } .dot.online { background:var(--success); } .pill.active,.pill.running,.pill.succeeded { color:var(--success); } #toast { background:var(--ink); color:#fff; } -button,input,select { min-height:44px; border-radius:8px; } button:focus-visible,input:focus-visible,select:focus-visible { outline:3px solid #006d89; outline-offset:2px; } -@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms!important; animation-duration:.01ms!important; } } +/* top bar */ +.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:1rem;min-height:4.25rem;padding:.5rem clamp(1rem,4vw,3rem);background:var(--topbar-bg);border-bottom:1px solid var(--line);backdrop-filter:blur(14px)} +.brand{min-height:44px;display:flex;align-items:center;gap:.7rem;min-width:0;color:inherit;text-decoration:none} +.brand img{width:2.4rem;height:2.4rem;border-radius:.55rem;flex:none} +.brand strong{display:block;font-family:var(--font-head);font-size:1.02rem;line-height:1.15} +.brand .ver{display:inline-block;margin-left:.4rem;padding:.05rem .45rem;color:var(--muted);border:1px solid var(--line);border-radius:999px;font:500 .72rem var(--font-mono);vertical-align:middle} +.brand small{display:block;color:var(--muted);font-size:.72rem;letter-spacing:.06em;text-transform:uppercase} +.conn{display:flex;align-items:center;gap:.5rem;font-size:.85rem;color:var(--secondary);white-space:nowrap} +.conn button{min-height:44px} +.dot{width:.6rem;height:.6rem;border-radius:50%;background:var(--success);box-shadow:0 0 0 .22rem var(--success-soft);flex:none} +.conn.off .dot{background:var(--red);box-shadow:0 0 0 .22rem var(--red-soft)} +.tabs{display:flex;gap:.25rem;padding:0;background:var(--bg);border-bottom:1px solid var(--line);overflow-x:auto} +.tabs button{display:inline-flex;align-items:center;min-height:48px;padding:0 1rem;color:var(--muted);font-weight:650;background:transparent;border:0;border-bottom:3px solid transparent;border-radius:0;white-space:nowrap} +.tabs button:hover{color:var(--ink);background:var(--hover-bg)} +.tabs button[aria-selected="true"]{color:var(--ink);border-bottom-color:var(--sea)} +.tabs .badge{margin-left:.4rem;padding:0 .45rem;color:var(--on-sea);background:var(--sea);border-radius:999px;font-size:.7rem} +main{width:min(1180px,calc(100% - 2rem));margin:0 auto;padding:0 0 6rem} +.tab-panel{padding-top:1.5rem} +.pagehead{margin-bottom:1.25rem} + +/* banner */ +.banner{display:flex;gap:.8rem;margin-top:1.25rem;align-items:flex-start;margin-bottom:1.25rem;padding:.9rem 1rem;border:1px solid;border-radius:.8rem} +.banner.ok{color:var(--success);background:var(--success-soft);border-color:var(--success)} +.banner.fail{color:var(--red);background:var(--red-soft);border-color:var(--red)} +.banner.info{color:var(--sea);background:var(--sea-soft);border-color:var(--sea)} +.banner .msg{flex:1;min-width:0;color:var(--ink)} +.banner .msg strong{color:inherit} +.banner .icon{font-weight:800;font-size:1.1rem;line-height:1.5} +.banner details{margin-top:.5rem} +.banner summary,details.disc>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600;color:var(--sea)} +pre.log{margin:.4rem 0 0;padding:.9rem;max-height:16rem;overflow:auto;white-space:pre-wrap;color:var(--secondary);background:var(--panel-2);border-radius:.6rem;font:.82rem/1.55 var(--font-mono)} +.banner .x{min-height:44px;min-width:44px;padding:0;border-color:transparent;color:var(--ink)} + +/* status band */ +.band{display:grid;grid-template-columns:repeat(3,1fr) auto;gap:1px;margin-bottom:1.75rem;background:var(--line);border:1px solid var(--line);border-radius:1rem;overflow:hidden;box-shadow:var(--shadow)} +.band>div{padding:1rem 1.15rem;background:var(--panel)} +.band .k{display:block;margin-bottom:.15rem;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase} +.band .v{font-weight:650;color:var(--ink)} +.band .v.good::before,.band .v.warn::before{content:"";display:inline-block;width:.55rem;height:.55rem;margin-right:.5rem;border-radius:50%;background:var(--success)} +.band .v.warn::before{background:var(--amber)} +.band .sub{display:block;margin-top:.15rem;color:var(--muted);font-size:.84rem;font-weight:400} +.band .act{display:flex;align-items:center;background:var(--panel)} + +/* components */ +.sechead{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1rem} +.toolbar{display:flex;gap:.75rem;align-items:flex-end;flex-wrap:wrap} +.search{width:min(22rem,100%)} +.filters{display:flex;gap:.35rem;flex-wrap:wrap} +.filters button{padding:.4rem .8rem;font-size:.85rem} +.filters button[aria-pressed="true"]{color:var(--sea);border-color:var(--sea);background:var(--sea-soft);font-weight:700} +.grid{align-items:start;display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem} +.comp{position:relative;display:flex;flex-direction:column;gap:.7rem;min-width:0;padding:1.1rem;background:var(--panel);border:1px solid var(--line);border-radius:.9rem;box-shadow:var(--shadow)} +.comp .top{display:flex;align-items:flex-start;justify-content:space-between;gap:.5rem} +.comp h3{margin:0;font-size:1.05rem} +.comp p{margin:0;font-size:.88rem;flex:1} +.status{display:inline-flex;align-items:center;gap:.4rem;padding:.2rem .6rem;border:1px solid var(--line);border-radius:999px;font-size:.78rem;font-weight:650;white-space:nowrap} +.status::before{content:"";width:.5rem;height:.5rem;border-radius:50%;background:currentColor} +.status.running{color:var(--success)}.status.stopped{color:var(--red);border-color:var(--red)}.status.disabled{color:var(--muted);border-style:dashed} +.comp.is-disabled{background:transparent;box-shadow:none;border-style:dashed} +.comp .acts{display:flex;gap:.45rem;align-items:center} +.comp .acts button:not(.more){flex:1} +.comp .more{width:44px;padding:0;flex:none;font-weight:800;letter-spacing:.05em} +.menu{padding:.8rem;background:var(--panel-2);border:1px solid var(--line);border-radius:.7rem} +.menu p{font-size:.82rem;margin:.4rem 0 .6rem} +.menu button{width:100%} +.empty{grid-column:1/-1;padding:2rem;text-align:center;border:1px dashed var(--strong);border-radius:.9rem} + +/* settings */ +.settings{display:grid;grid-template-columns:13rem 1fr;gap:2rem;align-items:start} +.jump{position:sticky;top:5.5rem;display:flex;flex-direction:column;gap:.1rem;font-size:.92rem} +.jump a{display:flex;align-items:center;min-height:44px;padding:0 .8rem;color:var(--muted);text-decoration:none;border-left:3px solid var(--line);border-radius:0 8px 8px 0} +.jump a:hover{color:var(--ink);background:var(--hover-bg)} +.jump a.on{color:var(--ink);border-left-color:var(--sea);font-weight:700;background:var(--sea-soft)} +.jump .grp{margin:.8rem 0 .2rem .8rem;color:var(--muted);font-size:.7rem;font-weight:800;letter-spacing:.12em;text-transform:uppercase} +.sect{margin-bottom:1.25rem;scroll-margin-top:6rem} +.sect>h2,.sect>summary h2{margin:0 0 .25rem;color:var(--sea);font-size:1.25rem} +.sect .lead{margin-bottom:1.1rem;font-size:.92rem} +.fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(15rem,1fr));gap:1.2rem} +.field .help{display:block;margin-top:.35rem;color:var(--muted);font-size:.82rem;line-height:1.45} +.field.wide{grid-column:1/-1} +.field .err{display:none;margin-top:.35rem;color:var(--red);font-size:.84rem;font-weight:650} +.field.invalid .err{display:block} +.field.invalid input{border-color:var(--red);box-shadow:0 0 0 1px var(--red)} +.field.dirty label::after{content:"edited";margin-left:.5rem;padding:.02rem .4rem;color:var(--amber);border:1px solid var(--amber);border-radius:999px;font-size:.68rem;font-weight:700} +.saved{display:inline-flex;align-items:center;gap:.35rem;margin-top:.35rem;color:var(--success);font-size:.82rem;font-weight:650} +.sw{display:flex;gap:.7rem;align-items:flex-start;min-height:44px} +.sw input{position:absolute;opacity:0;width:44px;height:44px;margin:0} +.sw .track{flex:none;position:relative;width:2.8rem;height:1.6rem;margin-top:.1rem;background:var(--panel-2);border:1px solid var(--strong);border-radius:999px;transition:.15s} +.sw .track::after{content:"";position:absolute;top:.15rem;left:.17rem;width:1.1rem;height:1.1rem;background:var(--strong);border-radius:50%;transition:.15s} +.sw input:checked+.track{background:var(--sea);border-color:var(--sea)} +.sw input:checked+.track::after{left:1.37rem;background:var(--on-sea)} +.sw input:focus-visible+.track{outline:3px solid var(--focus);outline-offset:2px} +.sw .txt strong{display:block;font-size:.92rem}.sw .txt span{display:block;color:var(--muted);font-size:.82rem} +details.sect{background:var(--panel);border:1px solid var(--line);border-radius:1rem;box-shadow:var(--shadow)} +details.sect>summary{display:flex;align-items:center;justify-content:space-between;gap:1rem;min-height:56px;padding:.6rem 1.25rem;cursor:pointer;list-style:none} +details.sect>summary::-webkit-details-marker{display:none} +details.sect>summary::after{content:"+";font-size:1.4rem;color:var(--muted)} +details.sect[open]>summary::after{content:"\2212"} +details.sect>.body{padding:0 1.25rem 1.25rem} +.advwrap{margin-top:1.5rem} +.advwrap>h2{font-size:1.1rem;color:var(--muted);margin-bottom:.2rem} +.advwrap>p{font-size:.88rem} +.savebar{position:fixed;left:0;right:0;bottom:0;z-index:30;display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;padding:.7rem clamp(1rem,4vw,3rem);background:var(--topbar-bg);border-top:1px solid var(--strong);box-shadow:0 -10px 30px rgba(0,0,0,.12);backdrop-filter:blur(14px)} +.savebar .state{font-weight:650}.savebar .state.dirty{color:var(--amber)}.savebar .state.err{color:var(--red)} +.savebar .btns{display:flex;gap:.6rem;align-items:center;flex-wrap:wrap} +.savebar .note{font-size:.82rem;color:var(--muted)} + +/* updates & maintenance */ +.stack{display:grid;grid-template-columns:minmax(0,1fr);gap:1.25rem;max-width:52rem}.actions,.tri{grid-template-columns:minmax(0,1fr)}.group,.action .d{min-width:0} +.ver-line{display:flex;align-items:baseline;gap:.8rem;flex-wrap:wrap} +.ver-line .big{font:600 2rem var(--font-mono);color:var(--ink)} +.notes{margin:.4rem 0 1rem;padding:.9rem 1rem .9rem 2rem;background:var(--panel-2);border-radius:.7rem;color:var(--secondary);font-size:.9rem} +.notes li{margin:.2rem 0} +.hint{display:block;margin-top:.4rem;color:var(--muted);font-size:.84rem} +.spin{display:inline-block;width:1rem;height:1rem;margin-right:.5rem;border:2px solid var(--line);border-top-color:var(--sea);border-radius:50%;animation:sp .8s linear infinite;vertical-align:-.15rem} +@keyframes sp{to{transform:rotate(360deg)}} +.group{margin-bottom:2rem} +.group>h2{margin-bottom:.9rem} +.actions{display:grid;gap:.8rem} +.action{display:flex;align-items:center;justify-content:space-between;gap:1.2rem;padding:1rem 1.15rem;background:var(--panel);border:1px solid var(--line);border-radius:.9rem} +.action .d{min-width:0}.action .d strong{display:block}.action .d span{display:block;color:var(--muted);font-size:.88rem} +.action .b{display:flex;gap:.5rem;flex-wrap:wrap;justify-content:flex-end} +.action.col{flex-direction:column;align-items:stretch} +.tri{display:grid;gap:.8rem} +.tri .action{align-items:flex-start} +.tri .action .b{flex:none} + +/* dialog */ +dialog{width:min(30rem,calc(100% - 2rem));padding:0;color:var(--ink);background:var(--panel);border:1px solid var(--strong);border-radius:1rem;box-shadow:0 30px 80px rgba(0,0,0,.45)} +dialog::backdrop{background:rgba(5,12,20,.6)} +dialog form{margin:0;padding:1.4rem} +dialog h2{margin-bottom:.6rem} +dialog ul{margin:.2rem 0 1rem;padding-left:1.2rem;color:var(--secondary)} +dialog li{margin:.25rem 0} +dialog .foot{display:flex;gap:.6rem;justify-content:flex-end;flex-wrap:wrap;margin-top:1.1rem} +.keep{margin:0 0 .5rem;padding:.7rem .85rem;color:var(--secondary);background:var(--sea-soft);border-radius:.6rem;font-size:.9rem} + +/* login */ +.login{max-width:34rem;margin:5vh auto 0} +.login .tokrow{display:flex;gap:.6rem} +.login .tokrow input{flex:1;font-family:var(--font-mono)} +.login .field-err{margin:.5rem 0 0;color:var(--red);font-weight:650} +.login.bad input{border-color:var(--red);box-shadow:0 0 0 1px var(--red)} +.login details.disc{margin-top:1rem;padding-top:.5rem;border-top:1px solid var(--line)} + +@media (max-width:760px){ + .band{grid-template-columns:1fr}.band .act{padding:0}.band .act button{margin:1rem 1.15rem} + .settings{grid-template-columns:1fr;gap:.5rem} + .jump{position:sticky;top:4.25rem;z-index:15;flex-direction:row;overflow-x:auto;margin:0 -1rem;padding:.4rem 1rem;background:var(--topbar-bg);border-bottom:1px solid var(--line);gap:.3rem} + .jump a{flex:none;border:1px solid var(--line);border-radius:999px;padding:0 .9rem} + .jump a.on{border-color:var(--sea)}.jump .grp{display:none} + .action{flex-direction:column;align-items:stretch}.action .b{justify-content:stretch}.action .b button{flex:1} + .topbar{padding:.4rem 1rem}.brand small{display:none} + .conn{font-size:.8rem} + .brand strong{font-size:.92rem}.brand .ver{display:table;margin:.1rem 0 0} + .savebar{padding:.6rem 1rem}.savebar{gap:.3rem}.savebar .btns{width:100%;gap:.4rem}.savebar .btns button{flex:1;padding:.5rem .3rem} + .banner{flex-wrap:wrap} + .sechead{align-items:stretch}.search{width:100%} + main{padding-bottom:9rem} +} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important;scroll-behavior:auto!important}} + +/* production additions */ +[hidden]{display:none!important} +.brand span{min-width:0} +.conn-state{display:inline-flex;align-items:center;gap:.5rem} +.conn.off .dot,.dot.offline{background:var(--red);box-shadow:0 0 0 .22rem var(--red-soft)} +.dot{background:var(--amber);box-shadow:0 0 0 .22rem var(--amber-soft)} +.dot.online{background:var(--success);box-shadow:0 0 0 .22rem var(--success-soft)} +.conn{gap:.6rem} +#notice .banner:first-child,#banner .banner:first-child{margin-top:1.25rem} +.banner.progress{color:var(--sea);background:var(--sea-soft);border-color:var(--sea)} +.banner .msg .detail{display:block;margin-top:.15rem;color:var(--muted);font-size:.9rem} +.small{font-size:.85rem} +.mono{font-family:var(--font-mono)} +.login{padding:clamp(1.25rem,5vw,2.5rem)} +.login-label{margin-top:1.2rem} +.login code+details,.login details.disc{margin-top:1rem} +.after-code{margin:.6rem 0} +.login .field-err{margin:.5rem 0 0} +.login .field-err:empty{display:none} +.show-token{margin-top:.4rem} +.rule{border:0;border-top:1px solid var(--line);margin:1.1rem 0} +.release-result{margin-top:1.1rem} +.release-result .banner{margin-top:0} +.release-result h3{margin:1rem 0 .3rem} +.release-item{margin-top:1rem;padding-top:1rem;border-top:1px solid var(--line)} +.release-item:first-child{margin-top:0;padding-top:0;border-top:0} +.warn-text{margin:.8rem 0 0;color:var(--amber)} +.after-input{margin-top:.8rem} +.card.flat{box-shadow:none} +.card.flat p{margin:.3rem 0 .6rem} +.stack.wide{max-width:56rem} +.last-backup{font-weight:600;color:var(--secondary)!important} +.pagehead p{max-width:46rem} +.band .v{display:block} +.band .v.plain::before{display:none} +.band a{display:inline-flex;align-items:center;min-height:44px} +.band .sub{font-size:.85rem} +.sechead p.small{margin:0} +.filters button .n{margin-left:.2rem;color:var(--muted);font-weight:500} +.filters button[aria-pressed="true"] .n{color:inherit} +.comp .detail{color:var(--amber);font-size:.8rem} +.comp .note{margin:0;color:var(--amber);font-size:.84rem} +.comp .cfg{align-self:flex-start;min-height:44px;padding:0 .2rem;color:var(--sea);background:transparent;border:0;text-decoration:underline;font-size:.88rem} +.comp .cfg:hover{background:var(--hover-bg)} +.menu .stat{margin:0 0 .5rem;color:var(--secondary)} +.status.unknown{color:var(--muted);border-style:dotted} +.field{min-width:0} +.field .err{font-weight:650} +.field.invalid select,.field.invalid input{border-color:var(--red);box-shadow:0 0 0 1px var(--red)} +.field .tag{margin-left:.5rem;padding:.02rem .4rem;color:var(--muted);border:1px solid var(--line);border-radius:999px;font-size:.68rem;font-weight:700} +.field.dirty .sw strong::after{content:"edited";margin-left:.5rem;padding:.02rem .4rem;color:var(--amber);border:1px solid var(--amber);border-radius:999px;font-size:.68rem;font-weight:700} +.field .clear-secret{display:flex;align-items:center;gap:.5rem;min-height:44px;margin:.2rem 0 0;font-weight:500} +.field .clear-secret input{width:1.2rem;min-height:0;height:1.2rem} +.settings{display:grid;grid-template-columns:13rem minmax(0,1fr);gap:2rem;align-items:start} +.settings-body{min-width:0} +.sw .txt{min-width:0} +.sw{position:relative} +.sw input:disabled+.track{opacity:.6} +.savebar .state{flex:none} +.savebar .note{flex:1;min-width:0} +.savebar .btns{justify-content:flex-end} +.sect.card h2,.sect>summary h2{margin-bottom:.25rem} +details.sect>summary:focus-visible{outline:3px solid var(--focus);outline-offset:2px} +.advwrap h2{margin-top:0} +.read-only-note{margin:.2rem 0 1rem} +.disc>summary{margin-top:.2rem} +.disc p,.disc label{margin-top:.6rem} +dialog form{display:block} +dialog p{color:var(--secondary)} +dialog .keep{margin:.2rem 0 .5rem} +#toast{position:fixed;left:50%;bottom:5.5rem;z-index:50;display:none;align-items:center;gap:.8rem;max-width:calc(100% - 2rem);padding:.6rem 1rem;color:#fff;background:#0a2341;border:1px solid #7892a5;border-radius:.7rem;transform:translateX(-50%)} +#toast.show,#toast.error{display:flex} +#toast.error{border-color:var(--red)} +#toast button{min-height:44px;color:inherit;border-color:#7892a5} +@media (prefers-color-scheme: dark){#toast{color:var(--ink);background:var(--panel-2)}} +.spin{margin-right:.5rem} +.icon.spin{margin:0} +@media (max-width:760px){ + .tabs button{flex:1;justify-content:center;padding:0 .4rem;font-size:.9rem} + .settings{grid-template-columns:minmax(0,1fr);gap:.5rem} + .savebar{flex-direction:column;align-items:stretch} + .savebar .btns{width:100%} + .conn button{padding:.4rem .7rem} + .brand strong{font-size:.9rem} + .action .b button{width:100%} + #toast{bottom:10.5rem} + .band .act button{width:calc(100% - 2.3rem)} +} +.linklike{min-height:44px;padding:0;color:var(--sea);background:transparent;border:0;text-decoration:underline} +.linklike:hover{background:transparent} +.action .b button{white-space:nowrap} diff --git a/docs/BOAT_CHAT.md b/docs/BOAT_CHAT.md index 22d11ae..fd8b00e 100644 --- a/docs/BOAT_CHAT.md +++ b/docs/BOAT_CHAT.md @@ -4,19 +4,74 @@ Purpose: first-run access, settings, telemetry refresh, host metrics, and unit a [Back to README](../README.md) · [Documentation index](README.md) -Open `http://HOST:8765`. Chat requires the access token +Open `http://HOST:8765`. Chat requires the access code (`BOAT_CHAT_ACCESS_TOKEN` in `config/boat-chat.env`) and refuses requests without it unless `BOAT_CHAT_ALLOW_OPEN=true` is set. The `local` provider returns gathered context without sending it to an external model. Configure an optional provider in Settings. -Retrieve the settings token locally when needed: +Retrieve the admin code (the settings token) locally when needed: ```bash sudo grep '^BOAT_CHAT_SETTINGS_TOKEN=' \ /opt/vesselstack/config/boat-chat.env ``` -Boat Chat retains this settings credential only for the current browser tab; -closing the tab clears it. +Boat Chat retains the access code and admin code only for the current browser tab; +closing the tab clears them. The web app labels them **Access code** and **Admin code**; +the environment variable names (`BOAT_CHAT_ACCESS_TOKEN`, `BOAT_CHAT_SETTINGS_TOKEN`) are unchanged. + +## A tour of the interface + +![Boat Chat Overview: boat condition, observations, needs attention and suggested actions](images/boat-chat-overview.png) + +*Overview: the boat's condition, how recent the observations are, what needs attention, and +what you might want to do next.* + +The header has a connection status, an **Overview** and **Chat** switch, and a **More** menu. + +- **Overview** opens first. It shows the boat condition, the latest observations with their age, + **Needs attention**, and **What would you like to do?** with **Prepare for an outing**, + **Review my last trip** and **Trips & maintenance**. **System status** expands underneath. + **Refresh** re-reads the boat. +- **Chat** is the question box. Example questions sit above it. **Answer style** offers Concise, + Explain, Diagnose and Checklist. **Ask** sends, **Cancel** stops a running request (requests + time out after 3 minutes), and **Clear** is a two-step action that confirms before it clears + the conversation. + +- **More** holds **Connect** (when an access code is needed), **Boat Hub**, **Prompts** and + **Settings**. **Boat Hub** shows safety, trip, position and maintenance at a glance, and the + trip report downloads as `vesselstack-trip-.txt`. **Prompts** lists what Boat Chat can + do. Under each answer, **Helpful**, **Incomplete** and **Wrong** record feedback, with an + inline note form. +- **Connect** asks for the **Access code**, keeps it in that browser tab only, and + **Disconnect** forgets it. +- **Settings** (opens only once the current values have loaded) shows Models, Budgets, and the + settings for each provider. Under **Advanced** are Telegram and the Server section, which has + the **Access code**, the **Admin code** and a field for a new admin code. If Settings reports + that an access code is needed, choose **Connect** from the message. Saving sends only the + fields you changed; blank secret fields keep their saved value. + +![Boat Chat on a phone in dark mode with the More menu](images/boat-chat-phone-dark.png) + +*On a phone the header collapses to the More menu, and the page follows the device's dark mode.* + +### Access code, admin code and the API + +The **Access code** (`BOAT_CHAT_ACCESS_TOKEN`) gets you in. The **Admin code** +(`BOAT_CHAT_SETTINGS_TOKEN`) is needed to change Settings. Scripts follow the same rules as the +page, exactly: + +| Request | What it needs | +|---|---| +| `POST /api/chat`, `/api/feedback`, `/api/session/clear` | Access code in `X-Boat-Chat-Token` | +| `GET /api/status`, `/api/insights`, `POST /api/maintenance` | Access code in `X-Boat-Chat-Token` | +| `GET` / `HEAD /api/settings` | Access code in `X-Boat-Chat-Token` or `X-Boat-Chat-Access-Token` | +| `POST /api/settings` | The same access code check, then the admin code: with `BOAT_CHAT_SETTINGS_TOKEN` set, `X-Boat-Chat-Token` must carry it from every address; with none set, only loopback (a `localhost` or `127.0.0.1` Host name) may write | + +When the two codes differ, send the access code in `X-Boat-Chat-Access-Token` and the admin code +in `X-Boat-Chat-Token` on the same request. Without the right header the server returns 401 +(access) or 403 (admin). + +## Setup and operations Boat Chat rejects oversized or malformed API requests and sends defensive browser headers while remaining embeddable in the Home Assistant dashboard. @@ -52,7 +107,7 @@ time so slow model calls cannot exhaust the Raspberry Pi. The installer generates `BOAT_CHAT_ACCESS_TOKEN` (random, preserved on re-install) and writes it to `config/boat-chat.env`. Chat, feedback, session, live status, insights, and maintenance endpoints all require it in the -`X-Boat-Chat-Token` header; the web UI asks for it through **Connect** and keeps +`X-Boat-Chat-Token` header; the web UI asks for the access code through **Connect** and keeps it only in session storage, and the Telegram bridge sends it automatically. Find it with: @@ -63,7 +118,7 @@ sudo grep '^BOAT_CHAT_ACCESS_TOKEN=' /opt/vesselstack/config/boat-chat.env An empty token **fails closed**: all of those endpoints return 401. A reviewed, trusted installation can opt out by adding `BOAT_CHAT_ALLOW_OPEN=true` to `config/boat-chat.env`; this key is deliberately not editable from the Settings -page. `BOAT_CHAT_REQUIRE_ACCESS_TOKEN` is retired and no longer disables +page. `BOAT_CHAT_REQUIRE_ACCESS_TOKEN` is retired and does not disable protection. ## Settings and configuration precedence @@ -80,15 +135,24 @@ the same way (`boat-chat/env_config.py`): Saving a value on the Settings page that equals the base value stores no override. Saving a Boat Chat field in the control panel removes that key from the override file so the new value takes effect, and the panel marks any field -that is currently overridden. Settings writes require `BOAT_CHAT_SETTINGS_TOKEN` -from any address; with no settings token they are accepted from loopback only -(private-LAN addresses are no longer trusted). `TELEGRAM_ALLOWED_CHAT_IDS` is a +that is currently overridden. `GET` and `POST /api/settings` require the access code. +Settings writes also require the admin code (`BOAT_CHAT_SETTINGS_TOKEN`) from any +address; with no admin code they are accepted from loopback only (private-LAN +addresses are not trusted). The access code goes in `X-Boat-Chat-Token`, or, when it +differs from the admin code, in the optional `X-Boat-Chat-Access-Token` header while +`X-Boat-Chat-Token` carries the admin code; `GET`, `HEAD` and `POST` all accept that header. +The access code cannot be cleared through the API (blank or `null` returns 400) unless +`BOAT_CHAT_ALLOW_OPEN=true`; set a new one instead. A save that blanks +`BOAT_CHAT_PROVIDER`, `BOAT_CHAT_MODEL`, `BOAT_CHAT_MAX_TOKENS`, `BOAT_CHAT_CONTEXT_CHARS`, +`BOAT_CHAT_HOST` or `BOAT_CHAT_PORT`, or gives an invalid number, returns 400. The +Settings page only opens once the current values have loaded and saves only the fields +you changed. `TELEGRAM_ALLOWED_CHAT_IDS` is a secret setting and is never returned by `GET /api/settings`. Settings updates are serialized and written atomically. Blank secret fields keep the saved value; an explicit JSON `null` stores an empty override so an -inherited credential cannot reappear. Use the current settings token to -authenticate a save and the separate replacement field to rotate it. Tokens +inherited credential cannot reappear. Use the current admin code to +authenticate a save and the separate replacement field to rotate it. The codes remain in this browser tab's session storage. JSON write requests require `Content-Type: application/json`; supplied browser origins must match the request authority. @@ -115,7 +179,7 @@ Threshold regressions run with `python3 -m unittest tests.test_metric_presentati the mocked CLI lifecycle regressions run with `sudo tests/test-lifecycle.sh` on Linux and do not control host services. -The installer enables the schedules for boot; **Install & start** or +The installer enables the schedules for boot; **Reinstall & start** (Control Panel → Maintenance → Setup & repair) or `vesselstackctl start` starts them immediately. Render-only installation keeps previously active schedules active. Backup, restore, and installation stop the timers and running jobs before touching their state. Inspect or run them with: @@ -134,8 +198,8 @@ while missing optional entities remain unknown. ## Optional host metrics -Enable **Host metrics** in the Control Panel's Prometheus settings, save, and -use **Install & start**. Equivalently, set `NODE_EXPORTER_ENABLE=true` in the +Enable **Host metrics** in the Control Panel's Prometheus settings (Settings → Advanced), **Save**, and +use **Reinstall & start** on the Maintenance tab. Equivalently, set `NODE_EXPORTER_ENABLE=true` in the installed vessel configuration and apply it with the reviewed installer. The pinned exporter runs on the private Compose network without publishing port 9100. Its read-only host mounts support CPU, memory, disk, filesystem, @@ -149,7 +213,7 @@ scrape target. Existing Prometheus data, retention, and listener settings are preserved. Offline bundles must be rebuilt with this release so the exporter image is available before enabling it offline. -**Apply configuration** followed by **Start** also applies exporter scrape +**Apply** (Settings tab) followed by **Start all** (Maintenance tab) also applies exporter scrape changes to a running Prometheus instance. The controller records the last successfully applied configuration hash in `config/.prometheus-conf.sha256`; render-only Apply does not advance it. Missing or changed hashes cause only @@ -187,7 +251,7 @@ release packages. Settings values containing a newline, carriage return or NUL byte are rejected (HTTP 400, nothing written), matching the control panel. `vesselstackctl urls` reports the **real** bind state of each service from the -listening sockets (`ss -ltn`), so the host-networked SignalK (always) and Home Assistant +listening sockets (`ss -ltn`), so the host-networked Signal K (always) and Home Assistant (unless limited by `HOMEASSISTANT_BIND_ADDRESS`) show up as listening on every interface although their client URLs say `127.0.0.1`. It prints this machine's reachable addresses (`HOSTNAME.local` and every non-loopback IPv4 address, ignoring Docker bridges and veth links) as @@ -225,7 +289,7 @@ descriptor). `root:` mode 0750: the service user can traverse it but cannot create, rename, replace or symlink anything directly in it. Only the leaf mount roots belong to the accounts that must write them: `influxdb` (uid/gid 1000), -`grafana` (uid 472, gid 0) and `boat-chat` (service user; native SignalK state +`grafana` (uid 472, gid 0) and `boat-chat` (service user; native Signal K state `signalk` likewise; with `SIGNALK_MODE=docker` `signalk` is uid/gid 1000, the `node` user of the signalk-server image, created before `compose up`; a root-owned one left by Docker is chowned to 1000:1000 on rerun, non-recursively @@ -279,6 +343,6 @@ By default, Boat Chat uses the configured `BOAT_TIMEZONE` for query windows and displayed timestamps and `BOAT_UNITS` for presentation. A user may request a grounded display timezone or unit conversion; that changes presentation only, not the application's resolved query window. Source instants, authoritative -unit labels, and canonical cache values are preserved. Apply configuration and -restart the service after changing the timezone. Updated local knowledge is +unit labels, and canonical cache values are preserved. Save and **Apply** the setting +and restart the service after changing the timezone. Updated local knowledge is picked up by the existing telemetry index refresh path. diff --git a/docs/BOM.md b/docs/BOM.md index 675ec88..13e8ec1 100644 --- a/docs/BOM.md +++ b/docs/BOM.md @@ -18,6 +18,7 @@ manufacturer instructions before ordering. | Network | Ethernet cable preferred; Wi-Fi optional | Use Ethernet for initial setup and stable telemetry where practical. | | Backup | Separate USB SSD/flash drive | Must mount outside `VESSELSTACK_DATA`; remove or replicate off-boat periodically. | | Optional AIS | Linux-compatible USB AIS receiver and antenna system | VesselStack supports AIS-catcher only when explicitly enabled. | +| Optional display (preview) | Waveshare ESP32-S3-Touch-LCD-2.8C, one per display | Shows Signal K values from the Pi over MQTT on a 2.8 inch round touch screen. It is separate from the NMEA interface and runs only with `DISPLAYS_ENABLE=true`; see [Instrument displays](DISPLAYS.md). | ## NMEA interface diff --git a/docs/CONTROL_PANEL.md b/docs/CONTROL_PANEL.md index df3a6fd..b25cce6 100644 --- a/docs/CONTROL_PANEL.md +++ b/docs/CONTROL_PANEL.md @@ -1,72 +1,283 @@ # Control Panel -Purpose: how to reach, secure, and use the loopback-first Control Panel. Lab acceptance steps are in [Control Panel lab testing](CONTROL_PANEL_TESTING.md). +Purpose: how to reach, secure, and use the Control Panel, tab by tab, and what to do when it will not connect. Lab acceptance steps are in [Control Panel lab testing](CONTROL_PANEL_TESTING.md). [Back to README](../README.md) · [Documentation index](README.md) -The Control Panel is the browser administration surface for VesselStack. It -shows container and systemd state, starts/stops/restarts individual components, -edits settings by component, runs installer preflight, renders or installs the -saved configuration, provisions InfluxDB history, creates consistent backups, -and applies an already downloaded, reviewed, and root-controlled release. +The Control Panel is the browser page for looking after VesselStack. It shows +whether the stack is running, makes backups, edits settings, checks for and +installs updates, and runs the occasional repair. It stays available when the +rest of the stack is stopped, so you can start it again from the same page. The +`vesselstackctl` command remains the recovery path if the panel itself is +unavailable. -It deliberately stays available when the managed stack is stopped. The -`vesselstackctl` command remains the recovery path if the panel is unavailable. +![Control Panel Home tab: services, backup and software status, a Back up now button, and the component cards](images/control-panel-home.png) -To open the panel directly from a laptop or phone on your trusted network: +*Home: three status cells, one Back up now button, and the components.* + +## Open the panel + +By default the panel answers only on the boat computer itself. From your laptop, +open a secure tunnel (SSH), leave that window open, and browse to the local +address: + +```bash +ssh -L 8780:127.0.0.1:8780 YOUR_USER@YOUR_PI.local +``` + +Then open `http://127.0.0.1:8780`. If you changed the panel port in Settings, +use that number instead of 8780. The tunnel keeps the token off the boat +network and is the recommended way in. + +To open the panel directly from a laptop or phone on a trusted boat network, +preview and then apply only the panel listener: ```bash +sudo vesselstackctl panel-access lan --dry-run sudo vesselstackctl panel-access lan ``` Open the address printed by the command (normally `http://PI_ADDRESS:8780`). -This enables only the panel listener; it does not expose the rest of the stack. -The default remains loopback-only, and `sudo vesselstackctl panel-access local` -restores it. Local-only users can still use a trusted VPN or an SSH tunnel -(`ssh -L 8780:127.0.0.1:8780 boat-admin@HOST`). +This is plain HTTP, so the token crosses that network unencrypted; use it only +on a network you trust. It exposes the panel listener only, not the rest of the +stack, and a strict firewall may still block it. `sudo vesselstackctl +panel-access local` restores the loopback-only default. + +## Sign in -Retrieve the generated token on the Pi and enter it on the unlock screen: +![Control Panel unlock screen with a token field, a Show token switch and a How do I reach this panel section](images/control-panel-login.png) + +*The unlock screen. It explains how to read the token and how to reach the panel.* + +Read the generated token on the Pi: ```bash -sudo grep '^CONTROL_PANEL_TOKEN=' \ - /opt/vesselstack/config/control-panel.env +sudo grep '^CONTROL_PANEL_TOKEN=' /opt/vesselstack/config/control-panel.env ``` -The token is stored in browser session storage, cleared by the **Lock** button -or when the tab closes, and sent only in the `X-VesselStack-Token` request -header. Configuration APIs never return secret values: they report only whether -a secret is configured, and a blank submitted secret preserves its current -value. Use the explicit **Clear stored value** checkbox to remove one. Every -configuration save creates mode-600 rollback copies under -`/opt/vesselstack-data/control-panel/config-backups/`. Operation commands are -fixed allowlisted argument arrays; the panel does not expose an arbitrary shell. -While one operation is running, other operation and component controls are -disabled to prevent overlapping lifecycle changes. - -Clearing an optional integration token disables that credential. Clearing a -required installer-managed password or token causes the installer to generate -a replacement the next time configuration is applied; dependent clients must -then be updated with the new credential. - -Saving settings writes mode-600 configuration atomically but does not restart -services. Review changes, run **Preflight**, then use **Apply configuration** or -**Install & start**. Hardware probes and the existing SignalK connectivity -check still gate installer actions. Listener changes require a shell restart: +Paste the result into **Control Panel token** and press **Unlock**. Either form +works: the whole `CONTROL_PANEL_TOKEN=…` line or just the value after the equals +sign. **Show token** reveals what you pasted. + +The token is kept in this browser tab's session storage, cleared by **Lock** or +when the tab closes, and sent only in the `X-VesselStack-Token` request header. +If you press **Lock** with unsaved Settings edits, the panel asks first, because +locking discards them. + +## The four tabs + +The header shows the installed version and a connection state (Connected, +Disconnected or Locked). The tabs are **Home**, **Settings**, **Updates** and +**Maintenance**. The Updates tab shows a number when an update is known. + +On a phone the tabs and cards stack in one column, and the page follows the device's light or +dark setting. + +![Control Panel on a phone in dark mode](images/control-panel-phone-dark.png) + +*The Control Panel at phone width in dark mode.* + +### Home + +Home is for looking, not for changing. + +- **Services** says "All systems running", or how many components are stopped + and which ones. +- **Backup** shows when the last backup was made and whether the backup drive is + mounted. It warns when there is no backup yet, when the drive is not mounted, + when the folder is not writable, or when backups sit on the computer's own + disk instead of an external drive. +- **Software** shows the installed version, or the newer version found by the + last update check, with a link to the Updates tab. It never checks by itself. +- **Back up now** is the one action on this page. It runs `vesselstackctl backup`. + +Below the status row, every component has a card with its plain name, a status +word (Running, Stopped, Disabled or Unknown) and what it does. Running +components offer **Restart** and **Stop**; stopped ones offer **Start**. +**Configure** jumps to that component's section in Settings. A **⋯** menu on a +core component holds **Disable**, which stops it, removes its container and +keeps it off, including after a restart or **Start all**. **Enable** brings it +back. Optional workers that are not core show **Turn on in Settings** instead. +The search box and the All, Running, Stopped and Disabled filters narrow the list. +Stopping, restarting and disabling a component ask in a dialog first. + +### Settings + +![Control Panel Settings tab with a jump list, sections, and the Save and Apply bar](images/control-panel-settings.png) + +*Settings: a jump list, one section per topic, Advanced sections collapsed, and one save bar.* + +Nothing changes on the boat until you press **Save** and then **Apply**. + +- A jump list at the top goes to Vessel, Network & access, Boat Chat, Signal K & + NMEA 2000, and Displays. Under **Advanced** it lists Storage & history, + Services & ports, AIS receiver, Home Assistant and Installation. Advanced + sections start collapsed; Installation is read-only here. +- Every field has plain help text. Mistakes show inline, next to the field. The + server checks the timezone against the system's timezone list (a valid IANA + name such as `Europe/Lisbon`; some legacy aliases like `US/Eastern` may be + rejected on systems that moved them to `tzdata-legacy`), boat length (above 0, + up to 500 m), beam (above 0, up to 100 m) and CAN speed (whole number, 10000 + to 1000000). Only fields you edit are checked, and a save with any problem + is rejected with a message per field, so nothing is half-saved. +- Secrets are write-only. The panel reports only whether one is set; leave a + secret blank to keep its value, or use the explicit **Clear stored value** + checkbox. Clearing an optional integration token disables that credential; + clearing a required installer-managed password or token makes the installer + generate a replacement the next time settings are applied, and dependent + clients must then be updated. +- The save bar at the bottom shows how many changes are waiting. **Discard** + resets the form to the saved values. **Save** writes the configuration + atomically with mode 600 and keeps a rollback copy under + `/opt/vesselstack-data/control-panel/config-backups/`; it does not restart + anything. **Apply** then renders and installs the saved settings (see + [Backup, update, apply and reinstall](#backup-update-apply-and-reinstall)). +- The Telegram and telemetry-indexer switches take effect when settings are + applied. Configure the Telegram bot token and allowed chat IDs before enabling + the worker. Disabled workers cannot be started from the component cards. +- Changing the panel's own listener or port takes effect when the panel + restarts: use **Maintenance → Restart Control Panel**. + +### Updates + +![Control Panel Updates tab showing the installed version and a Check for updates button](images/control-panel-updates.png) + +*Updates: nothing is contacted until you press Check for updates.* + +The tab shows the installed version and, if the running panel differs from it, +a note to restart the Control Panel. **Check for updates** is the only thing +that contacts `github.com`; nothing about your boat is sent. The result is +either "You're up to date" or the newer release with a link to its release +notes on GitHub and a **Back up & install** button. + +**Include early test releases** adds GitHub prereleases to the check. They are +for testing; leave it off on a boat in regular use. The 0.5.x panels called the +same switch "Include lab prereleases"; an owner updating from 0.5.x uses that older name. + +**Back up & install** asks in a dialog, then runs the update in a detached +worker so it survives the panel restarting. It: + +1. downloads the release archive and its checksum file from GitHub and checks + the size, the SHA-256 checksum and the digest GitHub reports; +2. unpacks it and runs the installer's checks (`--dry-run`); +3. takes a backup with `vesselstackctl backup --require-mounted`, so the update + stops here if the backup drive is not mounted; +4. runs the new installer with `--config … --start`; +5. restarts the Control Panel and waits for it to report the new version. + +The panel and the services may be interrupted for a few minutes. The page +reconnects by itself, and the banner confirms the new version. The panel checks +the archive against its published checksum; verifying the signed release tag is +a separate step you can run yourself (see +[Updating](UPDATING.md#verify-a-release-tag)). + +**Advanced: install a reviewed release folder** is for a release you already +downloaded, checked and unpacked on the Pi under `/var/lib/vesselstack/releases`. +**Back up & update from folder** runs `vesselstackctl update FOLDER`, which +takes its own backup first. + +![Control Panel Updates tab with a newer release listed and a Back up and install button](images/control-panel-update-available.png) + +*After a check, a newer release appears with its notes and a Back up & install button.* + +### Maintenance + +![Control Panel Maintenance tab grouped into Backup, Start and stop, Setup and repair, History storage and Panel](images/control-panel-maintenance.png) + +*Maintenance: occasional jobs, each described before you run it.* + +| Group | Button | What it does | Command behind it | +|---|---|---|---| +| Backup | **Back up now** | Saves settings and data to the backup folder. Services pause while the copy is made, which can take a few minutes. | `vesselstackctl backup` | +| Start & stop | **Start all** | Starts every enabled service that is stopped. | `vesselstackctl start` | +| | **Restart all** | Stops and starts every service; live data pauses for under a minute. | `vesselstackctl restart` | +| | **Stop all** | Turns off every service. The Control Panel stays available. | `vesselstackctl stop` | +| Setup & repair | **Check setup** | Checks saved settings and this Pi; changes nothing. | `install.sh --config … --dry-run` | +| | **Reinstall & start** | Re-applies saved settings and restarts every service; live data pauses for about a minute. Unsaved Settings edits are not included. | `install.sh --config … --start` | +| | **Go to Settings** | Applying a change is done with **Apply** on the Settings tab. | none | +| History storage | **Provision history** | Creates the places where history is kept and the small job that builds one-minute averages. Safe to run again. | `installer/scripts/bootstrap-influx.sh` | +| Panel | **Restart Control Panel** | Restarts only the panel. Boat services keep running and the page reconnects in a few seconds. | detached `systemctl restart` of the panel service | + +Restoring from a backup is deliberately not on this page. Restore replaces +current data, so do it from the boat computer: ```bash -sudo systemctl restart vesselstack-control-panel.service +sudo vesselstackctl verify-backup BACKUP_FILE +sudo vesselstackctl restore BACKUP_FILE --yes ``` -The panel intentionally skips restarting itself while an operation request is -in flight. After applying a release that changes Control Panel code, run the -same restart command to load the new backend. +Operation commands are fixed allowlisted argument arrays; the panel does not +expose an arbitrary shell. While one operation runs, the other buttons are +disabled so lifecycle changes cannot overlap. + +## Results and confirmations + +The result of the latest operation stays in a banner under the tabs until you +dismiss it. Success shows the time and, for a backup, where the file was saved; +failure shows the reason and what to try next. **Details** opens the command +output. + +![Control Panel Home with a green banner reporting that a backup finished and where it was saved](images/control-panel-backup-result.png) -The Telegram and telemetry-indexer toggles take effect after **Install & -start** (or another installer run). Configure the Telegram bot token and allowed -chat IDs in the Telegram section before enabling its worker. Disabled workers -are shown as disabled and cannot be started accidentally from the component -controls. Operators can -inspect these workers with: +*The result banner after Back up now.* + +Anything that stops or interrupts something asks first, in a dialog that lists +what is affected and has a plain **Cancel**. + +![Control Panel confirmation dialog for Stop all services listing what will go dark](images/control-panel-stop-dialog.png) + +*The Stop all dialog lists what will go dark and says the panel stays available.* + +## Backup, update, apply and reinstall + +| Action | Takes a backup | Changes software | Applies saved settings | Interrupts | +|---|---|---|---|---| +| **Back up now** | It is the backup | No | No | Services pause during the copy | +| **Apply** (Settings) | No | No | Renders and installs them; containers are not recreated | The displays bridge, if running, is restarted; other services keep running until restarted | +| **Reinstall & start** | No | No | Yes | Every service restarts, about a minute | +| **Back up & install** (Updates) | Yes, first, drive must be mounted | Yes, to the chosen release | Yes | Panel and services, a few minutes | + +**Apply** runs `install.sh --config …` against your saved settings without +`--start`. That is the installer's render-only mode: it writes the rendered +configuration but does not recreate containers or restart Boat Chat, Telegram or +the Control Panel; it restarts only the displays bridge, if it is running. To make +a running service use the new values, restart that component from its Home card, +or use **Restart all** or **Reinstall & start**. The Apply dialog says the same: +saved settings are written into the service files, running services keep running +and take the changes at their next restart, and the displays bridge restarts now. +Hardware probes and the Signal K connectivity +check still gate installer actions. + +## When the panel cannot connect + +- **Disconnected.** The header says Disconnected and the page shows "Can't reach + the boat computer". Controls are paused. The panel retries five times and then + waits for **Retry now**. Check that the SSH tunnel window is still open and + that the Pi is powered, then press **Retry now**. If the Pi was updating or + restarting, it reconnects by itself and reloads when the panel has restarted. + Unsaved Settings edits are kept and you are asked to save or discard them + first. +- **Your session ended.** After a restart or a token change the panel returns + to the unlock screen and says so. Paste the token again; unsaved edits are + kept. +- **That token wasn't accepted.** Re-read the token on the Pi. A leftover space + or an old token from before a reinstall is the usual cause. +- **Couldn't reach the Control Panel.** At the unlock screen this means the + tunnel is closed or the panel is not running. Restart it from the Pi: + + ```bash + sudo systemctl restart vesselstack-control-panel.service + sudo vesselstackctl logs control + ``` +- **"A newer Control Panel is installed than the one running."** Press + **Restart Control Panel** on the Maintenance tab. + +The panel does not restart itself while an operation is running. After applying +a release that changes Control Panel code, restart it the same way. + +## Telegram, indexer and host service + +Inspect the optional workers with: ```bash sudo vesselstackctl logs telegram @@ -74,20 +285,29 @@ sudo vesselstackctl logs indexer systemctl list-timers vesselstack-telemetry-indexer.timer ``` -The service runs as root because Docker, systemd, installation, backup, and -hardware operations require host privileges. Keep the token private, retain +The service runs as root because Docker, systemd, installation, backup and +hardware operations require host privileges. Keep the token private, keep loopback binding unless a trusted authenticated access layer is in place, and -never expose port `8780` directly to the internet. To disable the panel without +never expose port `8780` to the internet. To disable the panel without affecting telemetry services: ```bash sudo systemctl disable --now vesselstack-control-panel.service ``` -Re-enable it with `sudo systemctl enable --now -vesselstack-control-panel.service`. CLI configuration and lifecycle commands -continue to work while it is disabled. Optional workers and schedules follow the -same guard: Control Panel start/restart is rejected until its enable flag is set. -Offline preflight requires the node-exporter image only when -`NODE_EXPORTER_ENABLE=true`; the default-disabled profile does not add it to the -required image set. +Re-enable it with `sudo systemctl enable --now vesselstack-control-panel.service`. +CLI configuration and lifecycle commands continue to work while it is disabled. +Optional workers and schedules follow the same guard: a start or restart from the +panel is rejected until the worker's enable flag is set. Offline setup checks +require the node-exporter image only when `NODE_EXPORTER_ENABLE=true`. + +## Troubleshooting + +| Symptom | Likely cause and fix | +|---|---| +| Back up now fails with a mount message | The backup drive is not mounted. Mount it, then try again; the Home tab shows the drive state. | +| Home says backups are on the computer's own disk | The backup folder is not on an external drive. Set **Backup folder** under Settings → Advanced → Storage & history to a path under `/media` or `/mnt`. | +| Save is rejected with field messages | Fix the marked fields (unknown timezone, number out of range) and save again. | +| Check for updates fails | The Pi has no route to `github.com`. Check its internet connection; offline boats use [the offline path](UPDATING.md). | +| A component shows Stopped with extra detail | The detail is the raw state, for example restarting. Open its log with `sudo vesselstackctl logs COMPONENT`. | +| A disabled component says it is still running | Press **Retry disabling**. | diff --git a/docs/CONTROL_PANEL_TESTING.md b/docs/CONTROL_PANEL_TESTING.md index 7f4ba7e..08b1633 100644 --- a/docs/CONTROL_PANEL_TESTING.md +++ b/docs/CONTROL_PANEL_TESTING.md @@ -1,6 +1,6 @@ # Control Panel component lifecycle lab test -This is the release 0.5.7 lab recipe. It is a bounded acceptance test, not +This is the release 0.6.0 lab recipe. It is a bounded acceptance test, not evidence of a boat or physical Raspberry Pi test. Use a scratch lab Pi or an isolated VM with Docker and a mounted data disk. Keep real vessel identity, positions, MMSIs, tokens, logs, and backups out of this repository. @@ -8,34 +8,37 @@ positions, MMSIs, tokens, logs, and backups out of this repository. ## Fetch and verify the pinned prerelease Download the pinned bootstrap and the CI-generated checksum from the same -`v0.5.7` GitHub prerelease. Verify before reading or running it: +`v0.6.0` GitHub release. Verify before reading or running it: ```bash -curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.5.7/get-vesselstack.sh -curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.5.7/get-vesselstack.sh.sha256 +curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh +curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh.sha256 sha256sum -c get-vesselstack.sh.sha256 less get-vesselstack.sh sudo bash get-vesselstack.sh --help ``` -The release is an annotated unsigned lab tag, not a signed stable release. The -checksum catches a corrupt or truncated asset; it does not replace review of -the release provenance. +0.6.0 release status: `v0.6.0` is an SSH-signed tag, published first as a pre-release and +marked Latest after the maintainer's physical Raspberry Pi install passes (see the CHANGELOG). +Check the tag with `scripts/verify-release-tag.sh v0.6.0`. The checksum catches a corrupt or +truncated asset; it does not replace that signature check. ## Exercise the browser updater -Open the authenticated panel and use **Check for updates**. Confirm the catalog -is `jclima/VesselStack`, stable releases are the default, and **Include lab -prereleases** is opt-in. Because the installed `0.5.7` release is current, it -may correctly report **No newer release**; do not downgrade, retag, or force the -same version. Exercise **Back up & install** only when a newer public lab -release is available. A fresh `0.5.7` install is a CLI install; an existing -`0.5.5` or `0.5.6` install may use the panel upgrade path only after enabling -**Include lab prereleases**. CI-controlled fixtures exercise the install +Open the authenticated panel's **Updates** tab and press **Check for updates**. Before +you press it, confirm the tab says nothing has been contacted. Confirm the catalog +is `jclima/VesselStack`, stable releases are the default, and **Include early test +releases** is opt-in. When the installed `0.6.0` release is current, the panel +correctly reports "You're up to date"; do not downgrade, retag, or force the +same version. Exercise **Back up & install** only when a newer public release +is available. A fresh `0.6.0` install is a CLI install; an existing `0.5.5`, +`0.5.6` or `0.5.7` install may use the panel upgrade path, after enabling +**Include early test releases** (named **Include lab prereleases** in the 0.5.x panel) if +0.6.0 is published as a prerelease. CI-controlled fixtures exercise the install orchestration, and the actual systemd restart is covered by the backend verifier. Full physical-Pi upgrade proof remains pending physical testing. -Mount the configured external backup device first. Confirm the backup path is -shown before confirmation and that the operation log records download, +Mount the configured external backup device first. Confirm the dialog and the Home tab +show the backup state, and that the operation log records download, SHA-256 verification, safe extraction, preflight, backup verification, install, detached updater restart of the Control Panel, reconnect, and final version/boot verification. @@ -91,6 +94,26 @@ This does not make an unmounted backup safe, and the exception must never be used to guess at a real boat's backup destination. Stop if manually checked capacity is low or if any device/data path is unclear. +## Exercise the interface + +With the stack running, walk each tab once and record anything that differs: + +1. Unlock with the pasted `CONTROL_PANEL_TOKEN=...` line, then lock and unlock with the + bare value. A wrong token shows "That token wasn't accepted". +2. **Home** shows Services, Backup and Software cells and one **Back up now** button. + Press it: a banner reports the result and the saved path, and **Details** shows the + log. Unmount the backup drive and repeat: the banner fails with a mount message and the + Backup cell says the drive is not mounted. +3. **Stop all** (Maintenance) opens a dialog listing what goes dark; **Cancel** changes + nothing. Confirm it, check the panel stays up, then **Start all**. +4. **Settings**: enter an unknown timezone and an out-of-range number. **Save** is + rejected with an inline message per field and nothing is written. Fix them, **Save** + (nothing restarts), then **Apply**. +5. **Maintenance → Restart Control Panel**: the header shows Disconnected, then reconnects + by itself, and boat services keep running. Close the SSH tunnel to see Disconnected + and **Retry now**. +6. Resize to phone width and switch to dark mode; every tab stays usable. + ## Exercise the catalog Run `sudo vesselstackctl panel-access lan` on the Pi and open its printed address @@ -98,19 +121,19 @@ from your laptop on the same trusted network. Retrieve the unlock token with the printed command. No SSH tunnel is needed. Confirm `panel-access` shows the chosen host/port and that the other service bindings are unchanged. Use `sudo vesselstackctl panel-access local` to undo this after testing if desired. -Do not hardcode a preview port. In the Components view: +Do not hardcode a preview port. On the Home tab's component grid: -1. Confirm the component membership summary is read-only and that the CSV - membership field cannot be edited manually. -2. Remove Grafana. Confirm the operation stops/removes its container while its +1. Confirm core components show a **⋯** menu with **Disable**, and that optional + components show **Turn on in Settings** instead. +2. Disable Grafana from its **⋯** menu. Confirm the operation stops/removes its container while its data directory and dashboards remain present. Do not run `rm -v`, purge commands, or unrelated control-system cleanup. -3. Restart the stack and run `sudo vesselstackctl doctor`; the removed - membership must remain removed. +3. Use **Restart all** and run `sudo vesselstackctl doctor`; the disabled + component must remain disabled. 4. Run the normal update path from the pinned release directory. Confirm the catalog membership remains unchanged across the update and that existing user data is retained. -5. Add Grafana again from the Control Panel, or with +5. Press **Enable** on the Grafana card, or run `sudo vesselstackctl component add grafana`. Confirm Grafana is provisioned and its retained data is visible again. 6. Repeat one add/remove check for another core component. If the component diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 43f4d3c..8b22993 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,6 +1,6 @@ -# Deployment runbook: VesselStack 0.5.7 to a home-lab Pi and the boat Pi +# Deployment runbook: VesselStack 0.6.0 to a home-lab Pi and the boat Pi -This is an operator runbook for taking release 0.5.7 through two targets: +This is an operator runbook for taking release 0.6.0 through two targets: 1. **Home lab**: Raspberry Pi 4B, internet-connected, used for staging, a 48 h+ soak and rehearsals (CAN replay through `vcan0`). @@ -23,13 +23,15 @@ the scripts in this repository; where a thing does not exist the text says so. Related: [INSTALLATION.md](INSTALLATION.md) (hardware and first install), [README.md](../README.md) (reference for every variable and command). -Release status: `v0.5.7` is the authorized lab prerelease and is deliberately -not latest; `v0.4.4` remains the latest stable release. Physical Raspberry Pi -testing remains pending. This exception uses an annotated unsigned tag because -no signing key is available. The earlier -`v0.5.1` tag remains untouched; its release pipeline was blocked by the bridge -startup-status race. Do not retag or promote any 0.5.x lab prerelease tag as stable; a future -stable release needs a new signed tag after the real-Pi gates pass. +0.6.0 release status: `v0.6.0` is an SSH-signed tag (verified by the release workflow against +`.github/allowed_signers`), published first as a pre-release and marked Latest after the +maintainer's physical Raspberry Pi install passes (recorded in the CHANGELOG). Physical +Raspberry Pi acceptance is a separate gate (section 7). + +History: `v0.5.7` was published as an unsigned lab prerelease that was not marked latest, and +`v0.4.4` stayed the release GitHub marks Latest through the 0.5.x series. The `v0.5.1` tag is +untouched; its release pipeline was blocked by the bridge startup-status race. Published tags +are never recreated, moved or promoted by retagging. ## Simple path: one command @@ -40,10 +42,10 @@ carrying bundles, rollback and the acceptance checklist. **Home lab (internet-connected Pi).** On the Pi: ```bash -curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.5.7/get-vesselstack.sh | sudo bash +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash ``` -(`.../releases/latest/download/get-vesselstack.sh` works only once a release is marked latest.) +(`.../releases/latest/download/get-vesselstack.sh` points to whichever release GitHub marks Latest, which may not be 0.6.0; use the pinned URL above.) Do it over plain SSH (`ssh -t YOUR_USER@LAB_PI`): no screen, browser or tunnel is needed. It installs prerequisites, asks before installing Docker, verifies the release sha256 (compare it with the value recorded in section 2), asks the configuration @@ -59,18 +61,18 @@ read it, then `sudo bash get-vesselstack.sh`. Without questions (scripts, no ter browser wizard instead of the terminal questions. **Boat (offline Pi).** The boat has no internet, so carry the files in (section 4): -`get-vesselstack.sh`, `vesselstack-0.5.7.tar.gz` with its `.sha256`, and the +`get-vesselstack.sh`, `vesselstack-0.6.0.tar.gz` with its `.sha256`, and the offline bundle (copy the bundle root-owned, `chmod -R go-w`). Install Docker and the apt prerequisites while the Pi still has internet (the script cannot install packages offline and tells you what is missing), then: -For a first offline install, review the managed SignalK preload and displays +For a first offline install, review the managed Signal K preload and displays bridge prerequisites in section 3.5.3; a bundle alone cannot provide a first offline plugin or bridge-virtualenv installation. ```bash sudo bash get-vesselstack.sh --offline --bundle /path/to/offline-bundle \ - --tarball /path/to/vesselstack-0.5.7.tar.gz + --tarball /path/to/vesselstack-0.6.0.tar.gz ``` It verifies the tarball checksum and the bundle (`prepare-offline-bundle.sh @@ -125,10 +127,63 @@ fetches the pinned `origin` into a root-owned mirror and requires the upstream branch tip to have a valid commit signature **or** a validly signed tag pointing at it. It needs network access to `origin`, so it is a home-lab/connected-machine flow. **`install.sh` and `vesselstackctl update` do not verify signatures** of the -tarball or directory they are given. On the boat, this `v0.5.7` lab recipe uses -the sha256 recorded at build time (section 2) and the recorded commit; future -signed releases also require the signed tag to be verified on the maintainer -machine. +tarball or directory they are given. On the boat, this recipe uses +the sha256 recorded at build time (section 2) and the recorded commit. Release tags are +SSH-signed, and the release workflow refuses any tag that `scripts/verify-release-tag.sh` does +not accept against `.github/allowed_signers` (signature, the tag object's own name, and the exact +commit the workflow builds); owners can run the same check on a clone, passing the commit recorded +in section 2 as the optional third argument. + +### Signing key in 1Password (recommended) + +Keeping the signing key in 1Password means the private key never leaves 1Password, +and each signature needs the owner's approval (biometric or password prompt). + +1. In 1Password, create an **SSH Key** item (Ed25519) and enable the 1Password SSH agent + (Settings, Developer, "Use the SSH agent"). +2. Configure git for this repository only, so other repositories are unaffected. + `` is the one-line public key shown on the 1Password item + (`ssh-ed25519 AAAA...`): + + ```bash + git config --local gpg.format ssh + git config --local user.signingkey "key::" + git config --local gpg.ssh.program /Applications/1Password.app/Contents/MacOS/op-ssh-sign + git config --local tag.gpgSign true + ``` + + On Linux the program is `/opt/1Password/op-ssh-sign`. +3. Create an allowed-signers file so `git verify-tag` works locally. The principal must + equal your verified `user.email`: + + ```bash + printf '%s namespaces="git" %s\n' "" "" > allowed_signers + git config --local gpg.ssh.allowedSignersFile "$PWD/allowed_signers" + ``` + + Install the same file on each Pi as described below. +4. Add the **public** key to GitHub as a **Signing Key** (Settings, SSH and GPG keys, New + SSH key, key type "Signing Key"). The tagger email must be a verified email on the + account for the tag to show the Verified badge. +5. Point terminals at the 1Password agent, because `ssh-add` and `gh` do not read the + `IdentityAgent` line in `~/.ssh/config`: + + ```bash + export SSH_AUTH_SOCK="$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" # macOS + ``` + +Then `git tag -s vX.Y.Z -m "VesselStack X.Y.Z"` prompts for approval in 1Password, and +`scripts/verify-release-tag.sh vX.Y.Z` checks it against the committed +`.github/allowed_signers` (the project's allowed-signers file, principal `jc@linuxzone.org`; +the step 3 file above is for the on-Pi updater). + +**Key rotation.** Add the new public key line to `.github/allowed_signers` in a pull request to +`main`, get it reviewed and merged, and only then create the next tag with the new key. Keep +the old line only as long as old tags must verify; the release workflow reads the file at the +tagged commit, so it never trusts a key that is not on `main`. The file-based instructions below remain as the +alternative when 1Password is not available. + +### Signing key as a file (alternative) On the **maintainer machine** (private key never leaves it): @@ -179,23 +234,24 @@ hatch; never set it on the boat. The boat has no route to `origin`, so `vesselstack-update` is not the boat's update path; leave it unconfigured there unless you later add a connection you trust. -Once `v0.5.7` is published, do not recreate or move the tag. On the maintainer +Once `v0.6.0` is published, do not recreate or move the tag. On the maintainer machine, verify that the published tag points at the intended merged commit; do not force-fetch or overwrite a published tag: ```bash git fetch origin main --tags -git show --no-patch --decorate v0.5.7 -git rev-parse v0.5.7^{commit} -git merge-base --is-ancestor v0.5.7^{commit} origin/main +git show --no-patch --decorate v0.6.0 +git rev-parse v0.6.0^{commit} +git merge-base --is-ancestor v0.6.0^{commit} origin/main ``` -For this explicitly authorized lab prerelease, `v0.5.7` will be an annotated, -unsigned tag: no SSH/GPG signing key is available. Do not claim -`git verify-tag` success, and do not use this exception for a stable release. -For a future release, follow the tag-creation and signing gates in -[DEVELOPMENT.md](DEVELOPMENT.md#release-checklist); future stable versions -require a cryptographically signed tag. +Then verify the signature with `scripts/verify-release-tag.sh v0.6.0` and claim success only +for a tag that passes it. When you have the commit recorded from the build output in section 2 +(`commit `, kept in your notes), bind the tag to it: +`scripts/verify-release-tag.sh v0.6.0 .github/allowed_signers "$RECORDED_COMMIT"` (full SHA; +abbreviations are refused). Do not take the value from `git rev-parse v0.6.0^{commit}`, which +would only compare the tag with itself. Follow the tag-creation and signing gates in +[DEVELOPMENT.md](DEVELOPMENT.md#release-checklist). ## 2. Cut and build @@ -210,29 +266,29 @@ From a clean clone, so no stray files can matter (`build-package.sh` packages ```bash git clone https://github.com/jclima/VesselStack.git vs-release && cd vs-release -git checkout v0.5.7 -cat VERSION # 0.5.7 +git checkout v0.6.0 +cat VERSION # 0.6.0 VESSELSTACK_BUILD_REQUIRE_CLEAN=true ./build-package.sh ``` -It writes `generated/vesselstack-0.5.7.tar.gz` and -`generated/vesselstack-0.5.7.tar.gz.sha256`, and prints the sha256 and +It writes `generated/vesselstack-0.6.0.tar.gz` and +`generated/vesselstack-0.6.0.tar.gz.sha256`, and prints the sha256 and `commit `. **Record both values** in your release notes (out of band from the tarball): they are the boat's acceptance check. ```bash -git rev-parse v0.5.7^{commit} # must equal the printed commit -tar -tzf generated/vesselstack-0.5.7.tar.gz | head -20 -tar -xzOf generated/vesselstack-0.5.7.tar.gz vesselstack-0.5.7/COMMIT -tar -xzOf generated/vesselstack-0.5.7.tar.gz vesselstack-0.5.7/BUILD_INFO -tar -tzf generated/vesselstack-0.5.7.tar.gz | grep -E '\.env$|boat_facts|\.venv|memory/|data/' \ +git rev-parse v0.6.0^{commit} # must equal the printed commit +tar -tzf generated/vesselstack-0.6.0.tar.gz | head -20 +tar -xzOf generated/vesselstack-0.6.0.tar.gz vesselstack-0.6.0/COMMIT +tar -xzOf generated/vesselstack-0.6.0.tar.gz vesselstack-0.6.0/BUILD_INFO +tar -tzf generated/vesselstack-0.6.0.tar.gz | grep -E '\.env$|boat_facts|\.venv|memory/|data/' \ || echo "no private artifacts" python3 scripts/check-public-tree.py ``` (`vesselstack.example.env` is expected in the listing; a real `*.env` is not.) `scripts/update-requirements-lock.sh` is for maintainers when -`boat-chat/requirements.txt` changes; for an unchanged 0.5.7 the lock ships in +`boat-chat/requirements.txt` changes; for an unchanged 0.6.0 the lock ships in the tarball and you do not run it. ## 3. Home lab (Pi 4B, connected) @@ -242,14 +298,14 @@ the tarball and you do not run it. Copy the tarball to the lab Pi, check it, unpack it root-owned: ```bash -scp generated/vesselstack-0.5.7.tar.gz* YOUR_USER@LAB_PI:~/ +scp generated/vesselstack-0.6.0.tar.gz* YOUR_USER@LAB_PI:~/ # on LAB_PI: -sha256sum -c vesselstack-0.5.7.tar.gz.sha256 # [read-only] +sha256sum -c vesselstack-0.6.0.tar.gz.sha256 # [read-only] sudo install -d -m 0755 /var/lib/vesselstack/releases # [MUTATES HOST] -sudo tar -xzf vesselstack-0.5.7.tar.gz -C /var/lib/vesselstack/releases --no-same-owner -sudo chown -R root:root /var/lib/vesselstack/releases/vesselstack-0.5.7 -sudo chmod -R go-w /var/lib/vesselstack/releases/vesselstack-0.5.7 -cd /var/lib/vesselstack/releases/vesselstack-0.5.7 +sudo tar -xzf vesselstack-0.6.0.tar.gz -C /var/lib/vesselstack/releases --no-same-owner +sudo chown -R root:root /var/lib/vesselstack/releases/vesselstack-0.6.0 +sudo chmod -R go-w /var/lib/vesselstack/releases/vesselstack-0.6.0 +cd /var/lib/vesselstack/releases/vesselstack-0.6.0 ``` Configuration: either the terminal wizard (`python3 vesselstack-wizard.py --cli --output ~/vesselstack-lab.env`, over plain @@ -287,7 +343,7 @@ sudo ./install.sh --config ~/vesselstack-lab.env # [MUTATES HOST] re sudo ./install.sh --config ~/vesselstack-lab.env --start # [MUTATES HOST] pulls images, starts stack sudo vesselstackctl doctor sudo vesselstackctl urls -cat /opt/vesselstack/config/installed-version # 0.5.7 +cat /opt/vesselstack/config/installed-version # 0.6.0 ``` ### 3.2 Replay a private CAN log @@ -304,7 +360,7 @@ canplayer -I /path/to/private-candump.log vcan0=can0 # add -l i to loop fo ip -statistics link show vcan0 # RX packets should climb ``` -Point SignalK's NMEA 2000/SocketCAN connection at `vcan0`. With +Point Signal K's NMEA 2000/SocketCAN connection at `vcan0`. With `VESSELSTACK_CAN_CREATE_VCAN=true`, the rendered `vesselstack-socketcan.service` loads the helper that creates the virtual interface and brings it up; physical `can*` interfaces must already exist. @@ -345,11 +401,11 @@ values, and record the `doctor` result and data-directory size each day. 1. **Update from the previous version.** On a spare lab install (or before the soak), install the prior tag the normal way (`git checkout v0.3.0` in a clean - clone, `sudo ./install.sh --config ... --start`), then update with 0.5.7 from + clone, `sudo ./install.sh --config ... --start`), then update with 0.6.0 from the unpacked release directory: ```bash - sudo vesselstackctl update /var/lib/vesselstack/releases/vesselstack-0.5.7 # [MUTATES HOST] + sudo vesselstackctl update /var/lib/vesselstack/releases/vesselstack-0.6.0 # [MUTATES HOST] ``` It runs `install.sh --dry-run`, takes a verified backup into @@ -387,14 +443,14 @@ values, and record the `doctor` result and data-directory size each day. sudo chown -R root:root offline-bundle && sudo chmod -R go-w offline-bundle sudo scripts/prepare-offline-bundle.sh --load /path/to/offline-bundle # docker load sudo VESSELSTACK_OFFLINE=true VESSELSTACK_WHEELHOUSE=/path/to/offline-bundle/wheelhouse \ - vesselstackctl update /var/lib/vesselstack/releases/vesselstack-0.5.7 + vesselstackctl update /var/lib/vesselstack/releases/vesselstack-0.6.0 ``` The bundle path and **every parent** must be root-owned and not - group/world-writable, or root refuses it. Managed Docker SignalK with + group/world-writable, or root refuses it. Managed Docker Signal K with `SIGNALK_VESSELTWIN_PRELOAD=true` requires a valid preload plugin install prepared while online; for a fresh offline install, set it to `false` or - deliberately pre-provision the plugin state. Native SignalK installation is + deliberately pre-provision the plugin state. Native Signal K installation is not supported offline. `DISPLAYS_ENABLE=true` likewise requires its bridge virtualenv to have been provisioned online first; set it to `false` for a fresh offline install. @@ -415,14 +471,14 @@ Assemble one directory on the connected machine; transfer it as a unit: | File | Source | | --- | --- | -| `vesselstack-0.5.7.tar.gz` | `./build-package.sh` | -| `vesselstack-0.5.7.tar.gz.sha256` | same run | +| `vesselstack-0.6.0.tar.gz` | `./build-package.sh` | +| `vesselstack-0.6.0.tar.gz.sha256` | same run | | `offline-bundle/` (`wheelhouse/`, `images/`, `BUNDLE_INFO`, `MANIFEST.sha256`) | `scripts/prepare-offline-bundle.sh --output ./offline-bundle` | | Release notes with the recorded sha256 + commit | your notes, **kept separately** | The bundle holds no configuration, credentials or vessel data, and `BUNDLE_INFO` pins the release and the lock hash. Rebuild it whenever the release -(or `requirements.lock`) changes. SignalK `native` mode installs from npm and is +(or `requirements.lock`) changes. Signal K `native` mode installs from npm and is **not** covered by the bundle; use `SIGNALK_MODE="docker"` or `existing`. Transfer over the boat LAN from the laptop (SSH keys, no passwords; use the @@ -463,19 +519,19 @@ backup, the recorded sha256 and a console/Ethernet path ready. your notes, not with the `.sha256` file that travelled with it): ```bash - sha256sum ~/carry/vesselstack-0.5.7.tar.gz # must equal the recorded value + sha256sum ~/carry/vesselstack-0.6.0.tar.gz # must equal the recorded value ``` 3. **Stage root-owned [MUTATES HOST].** ```bash sudo install -d -m 0755 /var/lib/vesselstack/releases - sudo tar -xzf ~/carry/vesselstack-0.5.7.tar.gz -C /var/lib/vesselstack/releases --no-same-owner + sudo tar -xzf ~/carry/vesselstack-0.6.0.tar.gz -C /var/lib/vesselstack/releases --no-same-owner sudo cp -a ~/carry/offline-bundle /var/lib/vesselstack/releases/ - sudo chown -R root:root /var/lib/vesselstack/releases/vesselstack-0.5.7 /var/lib/vesselstack/releases/offline-bundle - sudo chmod -R go-w /var/lib/vesselstack/releases/vesselstack-0.5.7 /var/lib/vesselstack/releases/offline-bundle - cat /var/lib/vesselstack/releases/vesselstack-0.5.7/COMMIT # matches recorded commit - sudo /var/lib/vesselstack/releases/vesselstack-0.5.7/scripts/prepare-offline-bundle.sh \ + sudo chown -R root:root /var/lib/vesselstack/releases/vesselstack-0.6.0 /var/lib/vesselstack/releases/offline-bundle + sudo chmod -R go-w /var/lib/vesselstack/releases/vesselstack-0.6.0 /var/lib/vesselstack/releases/offline-bundle + cat /var/lib/vesselstack/releases/vesselstack-0.6.0/COMMIT # matches recorded commit + sudo /var/lib/vesselstack/releases/vesselstack-0.6.0/scripts/prepare-offline-bundle.sh \ --verify /var/lib/vesselstack/releases/offline-bundle # [read-only] ``` @@ -484,16 +540,16 @@ backup, the recorded sha256 and a console/Ethernet path ready. boat uses: ```bash - sudo /var/lib/vesselstack/releases/vesselstack-0.5.7/scripts/prepare-offline-bundle.sh \ + sudo /var/lib/vesselstack/releases/vesselstack-0.6.0/scripts/prepare-offline-bundle.sh \ --load /var/lib/vesselstack/releases/offline-bundle # verifies, then docker load sudo VESSELSTACK_OFFLINE=true VESSELSTACK_WHEELHOUSE=/var/lib/vesselstack/releases/offline-bundle/wheelhouse \ - vesselstackctl update /var/lib/vesselstack/releases/vesselstack-0.5.7 + vesselstackctl update /var/lib/vesselstack/releases/vesselstack-0.6.0 ``` 5. **Verify.** ```bash - cat /opt/vesselstack/config/installed-version # 0.5.7 + cat /opt/vesselstack/config/installed-version # 0.6.0 sudo vesselstackctl doctor # RESULT healthy sudo reboot # after reconnecting: @@ -562,12 +618,12 @@ secrets. `vesselstack-update --rollback BACKUP_DIRECTORY` is only for code-only ## 7. Acceptance checklist -- [ ] Lab prerelease `v0.5.7` annotated tag is intentionally unsigned; CI asset sha256 and commit recorded. +- [ ] Release `v0.6.0`: tag signature verified (`scripts/verify-release-tag.sh v0.6.0 .github/allowed_signers "$RECORDED_COMMIT"`) and Latest status recorded in the CHANGELOG; CI asset sha256 and commit recorded. - [ ] Lab: 48 h+ soak with daily `doctor` healthy; replay data visible in Grafana. - [ ] Lab: update from 0.3.0, backup/verify/restore, and offline install rehearsed. - [ ] Boat: sha256 of the carried tarball equals the recorded value. - [ ] Boat: verified backup exists on separate media and a copy is off the Pi. -- [ ] Boat: `installed-version` is `0.5.7`; `doctor` healthy before and after reboot. +- [ ] Boat: `installed-version` is `0.6.0`; `doctor` healthy before and after reboot. - [ ] Boat: `can0` RX counter increases, error/dropped counters flat. - [ ] Boat: tokens are per-host; no lab secrets copied; control panel reached by tunnel only. @@ -601,13 +657,15 @@ machine only. - The `vcan` systemd unit (`vesselstack-socketcan.service` with `VESSELSTACK_CAN_CREATE_VCAN=true`) is rendered/tested but has not been exercised across a real boot. -- **Real SSH signature verification** against a signed tag has only been tested - with fixtures; do the first real `git verify-tag` and `vesselstack-update` - against the allowed-signers file on the lab Pi. +- **Real SSH signature verification** on the Pi (`vesselstack-update` against its + allowed-signers file) has only been tested with fixtures; do the first real run on the lab + Pi. The release workflow's tag check (`scripts/verify-release-tag.sh`) is covered by + `tests/test-verify-release-tag.sh` with throwaway keys; its first real run is the `v0.6.0` + tag push. - The **arm64 offline bundle** (wheels for `manylinux*_aarch64`, `linux/arm64` images) has not been built and installed on a real Pi; section 3.5.3 is that rehearsal. Image tarballs are manifest-checked but not digest-pinned. - Scoped read-only InfluxDB tokens for Boat Chat and Grafana are **deferred** - (see README and CHANGELOG); they are not part of 0.5.7. + (see README and CHANGELOG); they are not part of 0.6.0. - The tarball is integrity-checked by sha256 only on the boat (no signature check in `install.sh`/`vesselstackctl update`). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5f43f8e..fb9e3d4 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -34,9 +34,9 @@ Every change must update README.md as required by AGENTS.md. ## Release checklist -The quick-start URLs (`.../releases/latest/download/get-vesselstack.sh` and -`.../releases/download/vX.Y.Z/get-vesselstack.sh`) only work if the GitHub release -carries exactly these files. Nothing publishes a release for you: the tag-triggered +The quick-start URLs (the pinned `.../releases/download/vX.Y.Z/get-vesselstack.sh` and the +secondary `.../releases/latest/download/get-vesselstack.sh`, which follows whichever release +GitHub marks Latest) only work if the GitHub release carries exactly these files. Nothing publishes a release for you: the tag-triggered workflow only creates a **draft**, and the CI-built draft is the authoritative release. Do not build and upload files by hand. @@ -48,19 +48,28 @@ Do not build and upload files by hand. bootstrap, build-package, Compose config, image manifests, Python, service integration). Merge it to `main`. 3. Tag the merged `main` commit (signed tag, see [DEPLOYMENT.md](DEPLOYMENT.md) - section 1) and push the tag. The workflow refuses a tag whose commit is not on - `main`, or that differs from `VERSION`. The explicitly approved `v0.5.7` - lab-prerelease exception is an annotated unsigned tag because no SSH/GPG key - is available; it is not a stable promotion and must not be treated as signed. - The earlier `v0.5.1` tag remains untouched and was not published because its - release pipeline caught the bridge startup-status race. + section 1; the recommended 1Password-held signing key is the first subsection there) + and push the tag. The workflow refuses a tag whose commit is not on + `main`, that differs from `VERSION`, or that is not an annotated tag SSH-signed by a key in + `.github/allowed_signers` (checked with `scripts/verify-release-tag.sh`). Verify locally + first: `scripts/verify-release-tag.sh vX.Y.Z`; the workflow also passes the built commit + (`scripts/verify-release-tag.sh TAG .github/allowed_signers "$GITHUB_SHA"`), requires the signed + tag object's own name to equal `TAG`, and refuses a tag that does not point directly at that + commit. Locally, pass the commit you expect as the third argument. To rotate or add the release key, change + `.github/allowed_signers` in a pull request to `main` and merge it before creating the tag; + the workflow reads the copy at the tagged commit. The `v0.5.7` lab prerelease was an + annotated unsigned tag because no SSH/GPG key was available; it is not signed and would now + be refused. The `v0.5.1` tag is untouched and was not published because its release pipeline + caught the bridge startup-status race. `v0.6.0` is an SSH-signed tag, published first as a + pre-release and marked Latest after the maintainer's physical Raspberry Pi install passes + (see the CHANGELOG). 4. `.github/workflows/release.yml` then (a) reruns the whole CI workflow on the tagged commit, (b) builds with `VESSELSTACK_BUILD_REQUIRE_CLEAN=true` in a read-only job and uploads the files as workflow artifacts, and (c) in a separate job with write access creates a **draft** release (`--draft --latest=false`) with the four files and both sha256 values in the notes. It skips creation if a release - for the tag already exists. It does not verify the tag signature; do that locally - before pushing the tag. + for the tag already exists. The build job verifies the tag signature (step 3) before building, + so a bad tag fails there and no draft is created. 5. Take the sha256 values from the draft's notes (or the workflow log). Download the draft's assets and test **those exact files** on the home-lab Pi (see [DEPLOYMENT.md](DEPLOYMENT.md)): `sha256sum -c`, then the install and update @@ -71,9 +80,9 @@ Do not build and upload files by hand. verified, `/etc/apt/sources.list.d/docker.sources` written, `docker-ce` and `docker-compose-plugin` installed, `docker info` answering; check `dpkg --print-architecture` says `arm64`), an upgrade from the previous release, and an offline - upgrade. The test suites use stubs and cannot cover this. For the explicitly - authorized `0.5.7` lab prerelease, publication may precede physical-Pi - acceptance; record that acceptance is pending. Complete the pinned installer + upgrade. The test suites use stubs and cannot cover this. For a prerelease, + publication may precede physical-Pi acceptance; record that acceptance is pending + (it was for `0.5.7`). Complete the pinned installer and component lifecycle recipe in [CONTROL_PANEL_TESTING.md](CONTROL_PANEL_TESTING.md) before any stable promotion. 7. Publish the draft as a **pre-release** (or otherwise not latest) first, so @@ -180,6 +189,84 @@ No scheduler or webhook automatically deploys changes. Git pulls alone cannot activate arbitrary infrastructure edits. Routine application updates are one command; release/schema/dependency changes still need separate review. +## Wizard internals + +Reference for the installer's terminal and browser wizards. Owners normally need only the +[quick start](../README.md#quick-start-one-command-on-the-raspberry-pi). + +### Terminal wizard in the guided installer + +The guided installer asks the configuration questions **in the terminal** (the terminal wizard, +`vesselstack-wizard.py --cli`, the default). It reads your answers from the terminal +(`/dev/tty`, so the piped `curl | sudo bash` form works), validates every answer +immediately with the same rules as the browser wizard (`DEFAULTS`, `validate()`, +`render()`; stricter checks only where `install.sh` enforces the same limits, such as +the boat name characters), and offers sections for the boat (name, type, units, timezone +detected from the system, optional and private MMSI, call sign, length and beam), +Signal K (docker, existing or native; docker recommended), NMEA 2000 / CAN +(`SOCKETCAN_ENABLE`, interface, bit rate and the home-lab virtual-CAN option +`VESSELSTACK_CAN_CREATE_VCAN`), AIS, Home Assistant (URL, optional hidden token), Boat +Chat provider (default `local`) and Telegram, the firewall (untrusted interface, enable, +strict mode, allowed ports), raw-data retention (`INFLUXDB_RAW_RETENTION`; empty means +30 days on a new install and no change to an existing database) and who can reach the +dashboards (`local`, `dashboards` or `all`; see "Reaching VesselStack from other devices"). +Ports, container and bucket names, folders, the service account and the generated +secrets sit behind "Advanced settings? [y/N]". Passwords and tokens default to being +generated during installation, are read without echo when you do type one and are +never printed or logged. It detects the architecture, Docker, `can0` and the network +interface names to inform defaults and hints; it never turns hardware on by itself. +The browser wizard asks the same "Who should be able to reach the dashboards?" question +(`local`, `dashboards` default, `all`) on its network step. The page sends only the +preset name (`LAN_ACCESS`); the server validates it (an unknown name is refused with HTTP 400, +nothing is written) and expands it with the terminal wizard's `LAN_PRESETS` mapping +into `VESSELSTACK_WEB_BIND_ADDRESS`, `VESSELSTACK_DATA_BIND_ADDRESS`, `BOAT_CHAT_HOST` and `HOMEASSISTANT_BIND_ADDRESS`; +the preset wins over explicit bind values, `LAN_ACCESS` is never written to the env file +and the independent Control Panel access choice is preserved. Choosing `all` shows the warning that Prometheus +has no login and recommends the firewall. The loopback, Host/Origin, URL-fragment token +and content-type protections are unchanged. +The terminal wizard has no network surface (no web server, no token, no URL), so it +runs as root directly, in a private root-only `0700` directory; its output is then +treated exactly like the browser wizard's: copied once into the root-only staging +directory, linted, re-validated as root with `vesselstack-wizard.py --validate FILE` +(known settings only, allowed characters, enum and boolean values) and installed +root-only, mode 600. `--config FILE` gets the same treatment (opened without following +symlinks, a regular file of at most 64 KiB, copied once, only the staged copy linted, +validated and used); with `--set` the staged copy is the base the values are applied +to. Optional browser wizard: add `--web-wizard`; it is unchanged (loopback only on a +random free port, `--wizard-port N`, run as an unprivileged user or the temporary +`vesselstack-wizard` system account, a `#token=` address printed once, open on the Pi +or through the printed `ssh -L` tunnel) and cannot be combined with `--yes`-style +configuration options (`--config`, `--set`, `--defaults`). Limitation: the Docker line +in a wizard's preflight reflects what that wizard sees; `install.sh` performs the +authoritative checks. + +### Running the wizards by hand + +The terminal wizard (`--cli`) asks in the terminal and writes `vesselstack.env`; add +`--defaults` and `--set KEY=VALUE` to configure without questions, `--base FILE` to start from an +existing file. For the browser wizard open the address it prints (`http://127.0.0.1:8088/#token=...`), or use the +SSH tunnel documented in the guide and open the same address on your workstation. +The wizard accepts `--port N`, `--output FILE` (where to write the env file) and +`--exit-after-save` (stop after the first successful save; the one-line installer +uses it) and `--validate FILE` (check an existing env file with the wizard's rules, +exit 0 if valid, 1 otherwise, no server started; `VESSELSTACK_CAN_CREATE_VCAN` is a +known wizard setting, default `false`). Nothing is persisted except the temporary account, which is removed on exit (`sudo userdel +vesselstack-wizard` if a run was killed with SIGKILL). It prints a per-run address of the form `http://127.0.0.1:PORT/#token=...`; open +that exact address. The token is carried in the URL fragment (never sent to the +server, never present in any served file); `wizard/app.js` reads it, removes it +from the address bar with `history.replaceState` and sends it as `X-Wizard-Token` +with every API call (`/api/system` and `/api/save` refuse requests without it). +The wizard needs no root privileges; run it as a normal user. Requests are refused unless the `Host` +header is `127.0.0.1`/`localhost`/`[::1]` on the bound port, any `Origin` is the +same loopback origin, the token matches, and saves use `Content-Type: +application/json` (this blocks cross-site form posts and DNS rebinding). The env +file is created mode 600 from the start and never through a symlink. Open the +printed URL itself (including the `#token=` part); a bookmarked older address stops working when the wizard restarts. The dependency-free wizard binds only to loopback, shows relevant host +prerequisites, and writes a complete private `vesselstack.env`. It does not +change boot firmware, firewall rules, marine wiring, or start services. Then run +`sudo ./install.sh --config vesselstack.env --dry-run` and `--start` as described +in the [README](../README.md#5-run-preflight). + ## Developer validation On Linux, keep temporary test roots protected and remove them after the run: @@ -209,9 +296,10 @@ info-level findings) on the shell scripts it lists, and they currently pass with no findings. The style `A && B || handler` is avoided (SC2015); use `if ! { A && B; }; then ... fi` instead. Run local checks with `shellcheck -S info` (and default severity) over -`install.sh`, `build-package.sh`, `scripts/*` and `tests/*.sh`, since CI does; a -recent ShellCheck (0.11.0) and the Debian bookworm one (0.9.0) both report no -findings. +`install.sh`, `build-package.sh`, `scripts/*` and `tests/*.sh`, since CI does. +Check with ShellCheck 0.9.0 (the Ubuntu and Debian bookworm package, which CI +installs): ShellCheck 0.11.0 does not report SC2015 for `A && B || continue`, so +a clean local 0.11 run can still fail CI. Root-controlled test fixtures (`tests/test-offline-install.sh`, `tests/test-update-launcher.sh`) are created under `/usr/local/share/vs-test`, @@ -261,6 +349,7 @@ docker compose --env-file vesselstack.example.env \ python3 -m unittest discover -s tests -p 'test_*.py' -v python3 displays/displayctl.py validate python3 -m unittest discover -s tests -p 'test_control_panel.py' -v +node tests/test_control_panel_ui.cjs # Control Panel interface (tabs, dialogs, banner, validation) ``` Build a sanitized release: @@ -315,7 +404,7 @@ statement about the current lab prerelease. 1. **Complete:** InfluxDB schema names and runtime Boat Chat branding are configurable, with legacy defaults retained only for 0.x upgrades. -2. **Complete:** Existing, pinned native, and pinned Docker SignalK modes are +2. **Complete:** Existing, pinned native, and pinned Docker Signal K modes are included. 3. **Complete:** Opt-in SocketCAN and AIS-catcher modules include hardware probes and stay disabled by default. @@ -347,8 +436,8 @@ refused. This fixes values with spaces (such as `AIS_CATCHER_ARGS`) being passed to Compose as one argument. The installed `vesselstackctl` defaults to the installation's `VESSELSTACK_ROOT`, so a custom root works without exporting it. Boat Chat and Control Panel token checks no longer fail on non-ASCII header -text, `GET`/`HEAD /api/settings` now need the access token (the Settings page -sends the token saved by **Connect**), unauthenticated loopback settings writes +text, `GET`/`HEAD`/`POST /api/settings` now need the access code (the Settings page +sends the code saved by **Connect**; see [Boat Chat setup](BOAT_CHAT.md)), unauthenticated loopback settings writes need a `localhost`/loopback or configured `BOAT_CHAT_HOST` `Host` name (DNS rebinding defense; configure `BOAT_CHAT_SETTINGS_TOKEN` to change settings from other hosts), and power tracking splits intervals at the zero crossing so @@ -559,6 +648,6 @@ Use `sudo vesselstackctl component remove ID` to persist a removal and run `sudo vesselstackctl component add ID` to persist inclusion and start it. Adding Mosquitto refreshes its managed access files; adding InfluxDB runs its bootstrap when available. Do not edit the membership field in the Control Panel directly; -use its Components controls. CLI and Control Panel lifecycle operations should +use the Disable (⋯ menu) and Enable controls on the Home tab's component cards. CLI and Control Panel lifecycle operations should be run one at a time because external CLI requests are not cross-process serialized. diff --git a/docs/DISPLAYS.md b/docs/DISPLAYS.md index cf35ea7..2be46fd 100644 --- a/docs/DISPLAYS.md +++ b/docs/DISPLAYS.md @@ -5,7 +5,7 @@ Purpose: reference for the optional ESP32 touch-screen instrument displays: brid [Back to README](../README.md) · [Documentation index](README.md) VesselStack is gaining configurable live-instrument displays: small ESP32 touch -screens that show SignalK values (speed, depth, wind angle, battery voltage and +screens that show Signal K values (speed, depth, wind angle, battery voltage and so on) on pages you define. The first board is the Waveshare ESP32-S3-Touch-LCD-2.8C, planned to run ESPHome with LVGL. The board registry and page layouts are data, so more boards and layouts can be added later. @@ -14,13 +14,13 @@ and page layouts are data, so more boards and layouts can be added later. the MQTT topic/payload and freshness logic, the board registry, the MQTT account/ACL provisioning (`displayctl.py sync-mqtt`), the **device lifecycle commands** (`vesselstackctl display list|add|remove|rotate|wifi|status`), the -**SignalK-to-MQTT bridge service** and the **ESPHome project generator** +**Signal K-to-MQTT bridge service** and the **ESPHome project generator** (`vesselstackctl display render|export|build|flash`, see [Firmware: generate, build and flash](#firmware-generate-build-and-flash)) exist today (`displays/`). With `DISPLAYS_ENABLE=true` the bridge publishes your configured instruments to Mosquitto. The Waveshare 2.8C has been verified on real hardware: bring-up (colours, orientation, round safe area and touch coordinates) needed no knob -changes, and the path SignalK, bridge, MQTT, screen worked end to end on a +changes, and the path Signal K, bridge, MQTT, screen worked end to end on a Raspberry Pi with a firmware built on the Pi and flashed over USB serial. Not yet verified on hardware: OTA updates, long-term stability and `.local` (mDNS) name resolution. Building and flashing on the Pi is optional @@ -52,7 +52,7 @@ environment already exists. ## Displays bridge service `vesselstack-displays-bridge.service` (`displays/bridge.py`) subscribes to the -SignalK websocket stream (`vessels.self` only, only the paths in +Signal K websocket stream (`vessels.self` only, only the paths in `displays.json`), converts and rounds values with the same tested logic as the topic schema below, and publishes them retained to Mosquitto. It is opt-in and adds no listening port. @@ -60,8 +60,8 @@ adds no listening port. Enable it: 1. Set `DISPLAYS_ENABLE=true` in `/opt/vesselstack/config/vesselstack.env` (or - the Displays section of the Control Panel). If SignalK security is on, also - set `DISPLAYS_SIGNALK_TOKEN` to a SignalK access token (read access is + the Displays section of the Control Panel). If Signal K security is on, also + set `DISPLAYS_SIGNALK_TOKEN` to a Signal K access token (read access is enough). 2. Run `sudo ./install.sh --config /opt/vesselstack/config/vesselstack.env --start` (or Control Panel Apply). On the first run the installer seeds @@ -97,7 +97,7 @@ restart loop). Boat Chat's environment is created as before and owned by the service user. The installer also writes `config/displays-bridge.env` (`root:root`, mode 0600; systemd reads it as PID 1 before dropping to the service account, so the service user never needs read access; it contains the bridge MQTT password -and the optional SignalK token), renders and enables the unit, and starts it with +and the optional Signal K token), renders and enables the unit, and starts it with `--start`. A render-only run (Control Panel Apply) runs `systemctl try-restart` on the bridge so a running bridge picks up the rewritten environment; a stopped bridge is only started by `--start` or `vesselstackctl start`. The unit runs as the unprivileged service account with the same @@ -116,14 +116,14 @@ warns, without failing, when `displays.json` defines `ha` items but Behaviour: on start it publishes retained `vesselstack/v1/bridge/status` `online` (and `offline` on a clean stop; Mosquitto publishes the retained last will `offline` if the bridge dies), reports `vesselstack/v1/bridge/signalk` -`connected` or `disconnected`, and reconnects to SignalK with exponential -backoff (1 s up to 30 s with jitter). While SignalK is unreachable the clock +`connected` or `disconnected`, and reconnects to Signal K with exponential +backoff (1 s up to 30 s with jitter). While Signal K is unreachable the clock keeps running, so instruments turn `stale` after their `stale_after_s`; they read `missing` until the first value arrives. Treat `bridge/status` as the authority: if the bridge dies hard, `bridge/signalk` keeps its last retained -value. A single SignalK message the bridge cannot process is logged (at most once a +value. A single Signal K message the bridge cannot process is logged (at most once a minute, with a count of the ones suppressed) and dropped; it does not cost the -SignalK connection. The MQTT client queues at most 1000 messages while the +Signal K connection. The MQTT client queues at most 1000 messages while the broker is down, bounding memory. An invalid `displays.json` or settings (exit status 2) or a missing Python dependency (exit status 3) make the bridge exit with a clear journal message and stay stopped (`RestartPreventExitStatus=2 3`); @@ -160,7 +160,7 @@ Troubleshooting: (`curl -fsS "$SIGNALK_URL/signalk"`); an HTTP 401 in the log means set `DISPLAYS_SIGNALK_TOKEN`. - Every instrument is `stale` or `missing` while the bridge is online and - connected: the SignalK `path` in `displays.json` is not being produced, or the + connected: the Signal K `path` in `displays.json` is not being produced, or the token cannot read it. - `bridge/homeassistant` is `disabled` and Home Assistant items show `--`: `DISPLAYS_HA_TOKEN` is empty or Home Assistant rejected it; see @@ -205,7 +205,7 @@ sudo vesselstackctl display remove helm --yes | `build ID` | Optional. Renders, then compiles the firmware on this machine in a one-shot container of the pinned ESPHome image and stores it with a manifest; see [Building and flashing on the Pi](#building-and-flashing-on-the-pi). Root. | | `flash ID (--serial DEVICE \| --ota HOST) [--previous] [--force]` | Optional. Uploads the stored firmware (never compiles) over USB serial or Wi-Fi; `--previous` rolls back; refuses firmware built with credentials that are no longer current unless `--force`. Root. | | `wifi [--open] [--clear]` | Reads the SSID and password from the terminal (password hidden) or, when stdin is not a terminal, from two lines on stdin (for automation); they are never accepted as command-line arguments. SSID: 1-32 bytes of printable UTF-8. Password: 8-63 printable ASCII characters; an empty password (open network) is accepted only with `--open`. Stored once for all displays as `"wifi": {"ssid", "password"}` in `displays.secrets.json` (mode 0600, inside `config/`, so **included in `vesselstackctl backup`**; keep archives private). `--clear` removes it and also deletes `secrets.yaml` from every already rendered tree of every display id under `build/displays/` (`ID/project`, `ID/project.new`, `ID/project.prev`, an old-layout `ID/secrets.yaml`, and the old siblings `ID.new/` and `ID.prev/`), each under that display's build lock without following symlinks (a display whose build is running is skipped with a warning); it does this even when no Wi-Fi credentials were stored, so a rerun after a failed or partial clear still cleans up; a changed password prints a notice instead. In both cases projects rendered and firmware built earlier still contain the old Wi-Fi credentials until `render`/`build` runs again (the firmware cannot be scrubbed; `flash` refuses it without `--force`). Nothing is echoed. | -| `status` | Reads the retained topics `vesselstack/v1/bridge/status`, `vesselstack/v1/bridge/signalk` and `vesselstack/v1/displays/vsdisp-ID/status` through the running Mosquitto container and prints the bridge state, the SignalK link state and each display's last retained state (`online`/`offline` as published by the bridge or device; `unknown`/`never seen` means no retained message exists, so the bridge or device has not connected since the broker's data was last cleared). It exits 1 if the broker container is not running. | +| `status` | Reads the retained topics `vesselstack/v1/bridge/status`, `vesselstack/v1/bridge/signalk` and `vesselstack/v1/displays/vsdisp-ID/status` through the running Mosquitto container and prints the bridge state, the Signal K link state and each display's last retained state (`online`/`offline` as published by the bridge or device; `unknown`/`never seen` means no retained message exists, so the bridge or device has not connected since the broker's data was last cleared). It exits 1 if the broker container is not running. | Behaviour that applies to the changing commands (`add`, `remove`, `rotate`, `wifi`): @@ -550,7 +550,7 @@ It is JSON with `schema` (must be `1`), `mqtt_host` (hostname or IP literal), `ha` (see [Home Assistant items](#home-assistant-items)), optional `night_schedule` and `displays`: -- An instrument (`^[a-z][a-z0-9_]{0,31}$`) has a SignalK `path` for +- An instrument (`^[a-z][a-z0-9_]{0,31}$`) has a Signal K `path` for `vessels.self` (numeric scalar values only), `label` (1-16 characters), `convert` (one named conversion), `unit` (up to 8 characters), `precision` (0-3), `stale_after_s` (1-3600 and at least 2 x `heartbeat_s`) and optional @@ -594,7 +594,7 @@ usage, file or JSON parse errors. An instrument may carry optional `thresholds` so its tile changes colour with the value. All numbers are in **display units** (after `convert`, for example -knots or feet, not SignalK SI units) and must be finite JSON numbers (booleans +knots or feet, not Signal K SI units) and must be finite JSON numbers (booleans are rejected): ```json @@ -647,7 +647,7 @@ flash the previous firmware (`display flash ID --previous ...`). ### Notifications -A SignalK notification (an alarm or warning such as the anchor alarm or a low +A Signal K notification (an alarm or warning such as the anchor alarm or a low battery notification) can be shown as a coloured status cell on a `status-grid` page. Add a top-level `notifications` object to `displays.json`: @@ -659,7 +659,7 @@ page. Add a top-level `notifications` object to `displays.json`: } ``` -- `path` must start with `notifications.` (own vessel, SignalK path syntax), +- `path` must start with `notifications.` (own vessel, Signal K path syntax), `label` is 1-16 printable characters in the bundled font (checked like instrument labels), `absent` is `ok` (default) or `unknown` and `alarm` is a boolean (default true) that decides whether the notification counts in the @@ -671,24 +671,24 @@ page. Add a top-level `notifications` object to `displays.json`: notification (value `null`) follows `absent`. Any other state string, or a value that is not such an object, is `unknown` and shows grey `--`. The notification `message` text is never read, published or shown. -- `absent`: SignalK only reports a notification while it exists, so silence +- `absent`: Signal K only reports a notification while it exists, so silence means either "nothing is wrong" or "the source is not running". `absent: "ok"` treats a notification that has not been raised, or has been cleared, as - healthy (right for alarms SignalK itself generates, such as the anchor alarm); + healthy (right for alarms Signal K itself generates, such as the anchor alarm); `absent: "unknown"` shows grey `--` instead (right when a missing notification could just as well mean the monitoring plugin is not running). Either way, - while SignalK is disconnected every notification is `stale` and grey, and + while Signal K is disconnected every notification is `stale` and grey, and nothing is ever green on a stale or unknown state. -- After SignalK (re)connects, notifications that nobody has reported on stay +- After Signal K (re)connects, notifications that nobody has reported on stay `unknown` for 3 s so that an alarm that is still active is shown red as soon - as SignalK re-sends it instead of flashing green first. + as Signal K re-sends it instead of flashing green first. Topic: each notification is published retained to `vesselstack/v1/notifications/` as compact JSON, `{"v":null,"u":"","st":"ok","sev":"alarm","ts":"2026-01-01T00:00:00.000Z","age":0.4}` (`v` is always `null` and `u` empty; `sev` is always present). `st` is `ok` -while SignalK is connected, `stale` once it disconnects (everything heard is -forgotten and SignalK re-sends active notifications on reconnect) and `missing` +while Signal K is connected, `stale` once it disconnects (everything heard is +forgotten and Signal K re-sends active notifications on reconnect) and `missing` before the first connection. Items are republished every `heartbeat_s`; the display blanks a cell (grey `--`) after 3 x `heartbeat_s` without a message, when the bridge is offline, or when MQTT is disconnected. The bridge subscribes @@ -711,7 +711,7 @@ screen with a flashing banner. true. A stale or never-heard item counts as `unknown`. - `na`, `nw`, `nu` are the numbers of items that are `alarm`, `warn` and `unknown`. `max` is the highest of `alarm` > `warn` > `unknown` > `ok`: - `unknown` ranks **above** `ok`, so a dead SignalK feed or stale sensor is never + `unknown` ranks **above** `ok`, so a dead Signal K feed or stale sensor is never reported as all clear. With no counted item at all the topic is not published. - `ids` lists the alarm ids first, then the warn ids, each group in @@ -819,14 +819,14 @@ three 10 px dots, the page title (the `!literal` page `title`, drawn in the smal font, up to 200 px wide) sits in a second row at y 40, and one 8 px dot per page with the current page highlighted sits at y 414, below the 340 x 340 content area (y 70-410). Nothing overlaps the content area or the alarm banner. The -dots, left to right, are SignalK (bridge reports `connected`), the bridge +dots, left to right, are Signal K (bridge reports `connected`), the bridge (`bridge/status` `online`) and MQTT (the display's own connection): green is -good, red is bad and grey is unknown. The SignalK dot is retained data, so it is +good, red is bad and grey is unknown. The Signal K dot is retained data, so it is only ever green while the bridge is online and MQTT is connected; a stopped bridge shows grey, never a stale green. When `displays.json` has any `ha` item a fourth dot (Home Assistant, 14 px to the right of the MQTT dot) follows the retained `bridge/homeassistant`: green `connected`, red `disconnected`, grey `disabled` or not -yet known; like the SignalK dot it is only coloured while the bridge is online. Without +yet known; like the Signal K dot it is only coloured while the bridge is online. Without `ha` items nothing is generated for it. Page titles are checked against the bundled font like labels (`validate` and `display render` report a glyph the font lacks), but only when `header` is on. @@ -974,12 +974,12 @@ use single`. arcs and indicator colours switch to the red/amber night palette through the same guarded repaint as the tiles (no blue; the neutral colour becomes dim amber). -- **SignalK tank paths.** SignalK publishes tanks as +- **Signal K tank paths.** Signal K publishes tanks as `tanks...currentLevel`, a **ratio from 0 to 1**, for example `tanks.fuel.0.currentLevel`, `tanks.freshWater.0.currentLevel`, `tanks.blackWater.0.currentLevel` (also `wasteWater`, `lubrication`, `liveWell`, `baitWell`, `ballast`, `gas`). Use `"convert": "ratio->%"`, `"unit": "%"`, - `"min": 0`, `"max": 100`. To find the paths on your boat open the SignalK + `"min": 0`, `"max": 100`. To find the paths on your boat open the Signal K admin UI (Data Browser, filter `tanks`) or read `http://:3000/signalk/v1/api/vessels/self/tanks`; the `` is whatever your tank sensor plugin or N2K gateway reports (`0`, `1`, ... or a name). If @@ -1027,12 +1027,12 @@ want to see the solar line). The layout is round only (`electrical needs a round board; use grid-2x2` on a rectangular board). It is never chosen as the default page of `display add`. -- **SignalK paths.** `electrical.batteries..voltage` (volts, `convert` +- **Signal K paths.** `electrical.batteries..voltage` (volts, `convert` `none`, `unit` `V`, `precision` 2), `electrical.batteries..capacity.stateOfCharge` (a **ratio 0 to 1**: `convert` `ratio->%`, `unit` `%`, `min` 0, `max` 100) and `electrical.batteries..current` (amperes, `none`, `unit` `A`, `precision` 1). `` is what your battery monitor plugin or N2K gateway reports (`house`, - `0`, ...); check the Data Browser in the SignalK admin UI (filter + `0`, ...); check the Data Browser in the Signal K admin UI (filter `electrical`). Solar controllers usually publish `electrical.solar..current`. For a shore pill see below. - **SoC ring.** The ring is the `gauge-arc` ring of the SoC instrument: the @@ -1048,7 +1048,7 @@ default page of `display add`. and the header and page-dot areas. The voltage uses the 56 px digits (the font `grid-2x2` uses; a display with an `electrical` page gets it). Fonts, glyph sets and the other layouts are otherwise unchanged. Estimated from font sizes, not measured on the panel. -- **Current and sign convention.** SignalK reports the battery current as +- **Current and sign convention.** Signal K reports the battery current as positive while **charging** and negative while discharging; the value is shown with a minus sign when negative. The word next to it is `CHG` above +0.05 A, `DSG` below -0.05 A and empty inside that band (and when stale). It is built @@ -1058,7 +1058,7 @@ default page of `display add`. so the number, `CHG`/`DSG`, any `thresholds` and `alarm` all use the corrected sign. `negate` works on any instrument. - **Shore pill.** With a **notification** (for example - `notifications.electrical.shorePower`, which you create with a SignalK rule or + `notifications.electrical.shorePower`, which you create with a Signal K rule or plugin; the path in the example is a placeholder) the pill is coloured by severity and shows `OK`, `WARN`, `ALARM` or `--` exactly like a `status-grid` cell; use `"absent": "unknown"` unless a missing notification really means @@ -1096,10 +1096,10 @@ row spacing and the 56 px digits against the ring. ### Home Assistant items Home Assistant (HA) entities, such as a bilge pump switch, a high water sensor, the -shore power state or a summary sensor you built in HA, can be shown next to SignalK +shore power state or a summary sensor you built in HA, can be shown next to Signal K data. The bridge keeps one websocket open to HA, reads only the entities named in `ha`, and publishes a severity and a small index per item, never the state text. This -is an optional extra: SignalK instruments and notifications work whether or not HA is +is an optional extra: Signal K instruments and notifications work whether or not HA is reachable. It has been verified against a fake HA server in the integration tests, not yet against a real Home Assistant. @@ -1126,11 +1126,11 @@ Then set it, with the URL if HA is not on the Pi: `https://`, or keep HA on the Pi. `wss://` verifies the certificate against the system trust store; there is no custom-CA option. For a self-signed HA, point the bridge at HA on loopback or add the CA to the Pi's system store. -- **Redirects are refused.** The HA (and SignalK) websocket upgrade does not follow HTTP +- **Redirects are refused.** The HA (and Signal K) websocket upgrade does not follow HTTP redirects (websockets 17.3 would follow them, even to another host, which would hand the token to that host); point `HOME_ASSISTANT_URL` at the final address. - Re-run the installer (or Control Panel Apply). The token is written only to the - root-only 0600 `config/displays-bridge.env` (included in backups, like the SignalK + root-only 0600 `config/displays-bridge.env` (included in backups, like the Signal K token) and sent once in the websocket `auth` message; the bridge never logs it, and removes it from any log text. With no token the bridge logs one warning, does not connect, and the items show `--`. @@ -1301,7 +1301,7 @@ point): ### Board registry schema 2 -`displays/boards/registry.json` is now `schema` 2, where each layout is an +`displays/boards/registry.json` uses `schema` 2, where each layout is an object `{"capacity", "min_items", "shapes": ["round","rect"], "types": ["instrument","notification","ha"]}` (the allowed item types). `schema` 1 registries (a layout is just an integer capacity) are still accepted and mean @@ -1324,8 +1324,8 @@ layout that takes only instruments for the default page (`tank-bars`, Values are published retained to `vesselstack/v1/instruments/` as compact JSON: `{"v":6.4,"u":"kn","st":"ok","ts":"2026-01-01T00:00:00.000Z","age":0.4}`. `v` is converted, then rounded to `precision`, and is `null` unless `st` is -`ok`. `ts` is the SignalK timestamp (or `null`); `age` is seconds since the -bridge received the value, measured on the bridge clock, not the SignalK +`ok`. `ts` is the Signal K timestamp (or `null`); `age` is seconds since the +bridge received the value, measured on the bridge clock, not the Signal K timestamp. Other reserved topics are `vesselstack/v1/bridge/status`, `vesselstack/v1/bridge/signalk`, `vesselstack/v1/bridge/clock` (see [Touch, header, night mode and dimming](#touch-header-night-mode-and-dimming)) and @@ -1350,20 +1350,20 @@ Notifications use their own retained topics, `vesselstack/v1/notifications/` - `missing`: no value has been received since the bridge started. - A changed value is published at most once per `min_interval_ms`; unchanged values are not republished except by the `heartbeat_s` heartbeat, which also - refreshes `age`. Null, non-numeric, non-finite and boolean SignalK values are + refreshes `age`. Null, non-numeric, non-finite and boolean Signal K values are ignored, as are values whose magnitude exceeds 1e15 after conversion. ## Supported boards | Board | Screen | Layouts | Status | | --- | --- | --- | --- | -| Waveshare ESP32-S3-Touch-LCD-2.8C (`waveshare-esp32-s3-touch-lcd-2.8c`) | 480x480 round, 70 px safe inset, 340x340 content area, header and footer bands (`ring`) | `single`, `grid-2x2`, `status-grid`, `tank-bars`, `gauge-arc`, `electrical` | Config validated and compiles with ESPHome 2026.10.0 (`esphome config` in CI); builds on a laptop or, optionally, on the Pi (`display build`, `display flash`; verified on real hardware: colours, orientation, safe area, touch and SignalK to screen end to end after `display build` and `display flash --serial`; OTA, long-term stability and `.local` resolution not yet verified; see the [bring-up checklist](#hardware-bring-up-checklist-waveshare-esp32-s3-touch-lcd-28c)) | +| Waveshare ESP32-S3-Touch-LCD-2.8C (`waveshare-esp32-s3-touch-lcd-2.8c`) | 480x480 round, 70 px safe inset, 340x340 content area, header and footer bands (`ring`) | `single`, `grid-2x2`, `status-grid`, `tank-bars`, `gauge-arc`, `electrical` | Config validated and compiles with ESPHome 2026.10.0 (`esphome config` in CI); builds on a laptop or, optionally, on the Pi (`display build`, `display flash`; verified on real hardware: colours, orientation, safe area, touch and Signal K to screen end to end after `display build` and `display flash --serial`; OTA, long-term stability and `.local` resolution not yet verified; see the [bring-up checklist](#hardware-bring-up-checklist-waveshare-esp32-s3-touch-lcd-28c)) | `list-6` is defined but not offered on the round board: `validate_registry` rejects any board whose `layouts` lists a layout the firmware generator cannot render (`schema.SUPPORTED_LAYOUTS`, shared with `render.py`). **Adding a board versus adding a layout.** A new board is data only: an entry in `displays/boards/registry.json` that lists already supported layouts and its ESPHome package. A new layout is code: teach `displays/render.py` to draw it and add its id to `SUPPORTED_LAYOUTS` in `displays/schema.py`, otherwise the registry is refused. -**Rounding.** Instrument and Home Assistant numeric values use the same rounding (half away from zero, never `-0.0`, an integer at `precision` 0, a value beyond 1e15 is treated as missing). A heading (`rad->deg_360`) that rounds up to 360 is shown as 0. A numeric Home Assistant item at `precision` 0 is now published as an integer (`3`, not `3.0`) in the MQTT payload; rollback is `git revert`. +**Rounding.** Instrument and Home Assistant numeric values use the same rounding (half away from zero, never `-0.0`, an integer at `precision` 0, a value beyond 1e15 is treated as missing). A heading (`rad->deg_360`) that rounds up to 360 is shown as 0. A numeric Home Assistant item at `precision` 0 is published as an integer (`3`, not `3.0`) in the MQTT payload. ## Planned limitations @@ -1372,8 +1372,8 @@ Notifications use their own retained topics, `vesselstack/v1/notifications/` network. - With the VesselStack firewall enabled, displays must be on a trusted interface that can reach the Raspberry Pi's MQTT port. -- The build path is exporting from the Pi and compiling on a laptop; building - and flashing on the Pi (a Compose profile) is planned and not available yet. +- Building on a laptop is the primary documented path; building and flashing on the + Pi (`display build`, `display flash`) is optional. Only the `single`, `grid-2x2`, `status-grid`, `tank-bars`, `gauge-arc` and `electrical` layouts are generated. - The runtime `displays.json`, `displays.secrets.json`, ESPHome `secrets.yaml`, `.esphome/` and `displays/build/` output, `$VESSELSTACK_ROOT/build/` and firmware diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 8128269..6b25338 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -41,13 +41,13 @@ installer. `-t` gives the session a terminal so the installer can ask questions: ```bash ssh -t YOUR_USER@raspberrypi.local -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh | sudo bash +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash ``` -The `latest/download` URL follows the latest stable release. The lab prerelease -`v0.5.7` is not selected by that URL; use its explicit pinned URL or -`--version 0.5.7` only when following the lab and offline-release instructions -in [DEPLOYMENT.md](DEPLOYMENT.md). +The URL pins release `v0.6.0`, so it installs that release whatever GitHub marks as Latest +(`--version 0.6.0` selects the same release). The form `.../releases/latest/download/get-vesselstack.sh` +points to whichever release GitHub marks Latest, which is not necessarily the newest one. For a +lab Pi and an offline boat, follow [DEPLOYMENT.md](DEPLOYMENT.md). The script checks the host first. It asks before installing missing Debian packages or Docker Engine and Compose v2 from Docker's official apt repository, @@ -62,9 +62,42 @@ and the optional Boat Chat provider are configured there. Boat Chat defaults to a local provider. A remote provider is optional and may require internet and provider credentials. -![Example VesselStack preflight review](images/wizard-preflight.png) +### The optional browser wizard -*Example of a preflight review; your host checks and values may differ.* +`--web-wizard` (or `python3 vesselstack-wizard.py` on its own) serves a browser version of the +same questions on loopback only. It prints a per-run address of the form +`http://127.0.0.1:PORT/#token=...`; open that exact address on the Pi, or from your laptop +through `ssh -L PORT:127.0.0.1:PORT YOUR_USER@YOUR_PI.local`. It has seven steps: +**Preflight**, **Hardware**, **Vessel**, **Storage**, **Signal K**, **Network** and **Review**. +**Back** keeps what you entered, and **Continue** stays blocked until the page has its address +token. + +![Browser wizard Preflight step showing the host, memory, Docker Compose and can0 checks](images/wizard-preflight.png) + +*Step 1, Preflight: host architecture and memory, Docker Compose, network interfaces and +whether `can0` is present. The checks are for information only.* + +Steps 2 to 4 are a hardware checklist for your reference (not saved), the boat name, type, +timezone and units (US customary or metric; knots and nautical miles are always kept), and the +service account and paths. **Signal K** chooses a managed or existing server and, if you enable +NMEA 2000, the CAN interface: + +![Browser wizard Signal K and NMEA 2000 step with the CAN interface warning](images/wizard-nmea.png) + +*Step 5, Signal K and NMEA 2000: enable SocketCAN only after the vendor overlay is installed +and `can0` appears.* + +**Network** asks who can reach the dashboards and who can open the Control Panel, with an optional +firewall for Wi-Fi. **Review** writes the file with mode 600; credentials, including the Boat +Chat access code, are generated later, during installation. + +![Browser wizard final page: Configuration saved, with the commands to run next](images/wizard-done.png) + +*The last page after saving: nothing is installed yet. Run the `--dry-run` command, then the +`--start` command, then `sudo vesselstackctl doctor`. If the wizard has stopped by itself, that is +expected.* + +The terminal wizard asks the same things; its wording and values may differ slightly. ## How to access it @@ -73,7 +106,9 @@ and Boat Chat. Open those addresses from a computer on the same trusted boat network. It also prints a command to read the Boat Chat access token locally; keep that token private and never commit `config/*.env`. -The control panel is administration-only and binds to loopback by default. On +## First sign-in to the Control Panel + +The Control Panel is administration-only and binds to loopback by default. On the Pi, retrieve its token locally: ```bash @@ -87,11 +122,18 @@ SSH tunnel from your own computer: ssh -L 8780:127.0.0.1:8780 YOUR_USER@PI ``` -Then open `http://127.0.0.1:8780` and enter the token. Do not expose port 8780 directly to the -internet. Exact ports, LAN presets, firewall behavior, token storage, and -rollback are documented in the [main README reference](../README.md#quick-start-one-command-on-the-raspberry-pi). +Then open `http://127.0.0.1:8780`, paste the token (or the whole `CONTROL_PANEL_TOKEN=…` +line) into **Control Panel token** and press **Unlock**. If you changed the panel port in +Settings, use that number. Do not expose port 8780 directly to the internet. + +![Control Panel unlock screen](images/control-panel-login.png) + +A first look at each tab is in the [Control Panel guide](CONTROL_PANEL.md). Exact ports, LAN presets, firewall behavior, token storage, and +rollback are documented in [Network access and security](NETWORK.md) and the +[Control Panel guide](CONTROL_PANEL.md). -For a deliberate trusted-LAN setup, preview and apply only the panel listener: +The SSH tunnel is the recommended default. Direct panel access from the boat network is an +opt-in choice: for a deliberate trusted-LAN setup, preview and apply only the panel listener: `sudo vesselstackctl panel-access lan --dry-run`, then `sudo vesselstackctl panel-access lan`. This is unencrypted HTTP on all interfaces; a strict firewall may still block it. Roll back with @@ -108,7 +150,7 @@ the offline installer cannot fetch them. Before leaving the lab, verify that the matching offline bundle contains every required Python dependency and container image. -Follow [DEPLOYMENT.md](DEPLOYMENT.md) for the signed release workflow, offline +Follow [DEPLOYMENT.md](DEPLOYMENT.md) for the pinned release workflow, offline bundle verification, dock-side update, and rollback. Internet is not required for the local Signal K/history/dashboard stack once its images and packages are present. It may still be required by an explicitly selected remote Boat @@ -119,7 +161,7 @@ For a fresh offline managed Docker install, the default installed while online. If the plugin is absent, set `SIGNALK_VESSELTWIN_PRELOAD=false` in the carried configuration before installing offline; you can preload it later while online, and enabling, -pairing, and readings remain separate owner choices. Native SignalK is +pairing, and readings remain separate owner choices. Native Signal K is unsupported offline. If `DISPLAYS_ENABLE=true`, provision the displays bridge virtual environment on that Pi while it is online, or set `DISPLAYS_ENABLE=false`. See the @@ -135,7 +177,7 @@ publish vessel positions, MMSIs, call signs, device identifiers, telemetry databases, backups, logs, or environment files. An optional VesselTwin connection uses the `signalk-vesseltwin` plugin, pairing, -and explicit reading opt-ins. Managed Docker/native SignalK installs preload the +and explicit reading opt-ins. Managed Docker/native Signal K installs preload the pinned plugin package by default; that preload does not enable the plugin, pair the account, or authorize readings. Consult the [VesselTwin integration guide](VESSELTWIN.md) @@ -144,11 +186,12 @@ logbook records. ## If something goes wrong -Start with the read-only checks in [Troubleshooting](README.md#troubleshooting): +Start with the read-only checks in [Troubleshooting](../README.md#troubleshooting): `sudo vesselstackctl status`, `sudo vesselstackctl doctor`, and `sudo vesselstackctl urls`, then inspect the relevant service logs. Stop before changing CAN or boot settings if `can0` is missing. For release failures, use the deployment backup and rollback path; do not delete `/opt/vesselstack-data` while investigating. For deeper manual setup, every variable, port, service, security note, and -rollback command remains in the [full README reference](../README.md#quick-start-one-command-on-the-raspberry-pi). +rollback command remains in the [full README reference](../README.md#4-configure-the-vessel) and +[Network access and security](NETWORK.md). diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 9e0d76a..4e3365c 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -63,7 +63,7 @@ terminal. The Pi needs no screen, keyboard, or browser, and nothing has to be opened or tunnelled: ```bash -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh | sudo bash +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash ``` The terminal questions come in short sections (boat, Signal K, NMEA 2000 / CAN, @@ -72,13 +72,14 @@ Each has a plain-language explanation and a default in `[brackets]`: Enter accepts it, `?` explains, and `b` goes back one question. A review screen (never showing secrets) lets you save, edit a section, or quit. Passwords and tokens are generated for you; MMSI, call sign and boat size are optional and -stay on the Pi. A short example: +stay on the Pi. Units offers 1 US customary (feet, degrees F, psi, US gallons; the default) and +2 metric; speeds stay in knots and distances in nautical miles either way. A short example: ```text [1/9] Your boat Boat name [My Boat]: Sea Wolf Boat type [Motor vessel]: - Units - choose 1-3 [metric]: 3 + Units - choose 1-2 [us_customary]: 2 Timezone [Europe/Oslo]: Add optional vessel details (MMSI, call sign, length, beam)? [y/N]: ... @@ -99,10 +100,17 @@ firewall may block it); restore the default with change. The command requires the existing root-owned config files and a non-empty panel token, and restarts only the control-panel service. +**First sign-in.** Run the tunnel command above on your own computer, open +`http://127.0.0.1:8780`, and paste the token from +`sudo grep '^CONTROL_PANEL_TOKEN=' /opt/vesselstack/config/control-panel.env` into **Control +Panel token** (the whole `CONTROL_PANEL_TOKEN=…` line works too), then press **Unlock**. If you +changed the panel port in Settings, use that number. See the +[Control Panel guide](CONTROL_PANEL.md) for a tour of the four tabs. + **Without questions** (scripts, repeatable installs, or when no terminal is available): ```bash -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh \ +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh \ | sudo bash -s -- --yes --set BOAT_NAME=Foo --set SOCKETCAN_ENABLE=true ``` @@ -114,9 +122,11 @@ not upgrade an installed system or remove Docker conflicts). `--config FILE` use installer stops before changing anything and lists these options. `--web-wizard` is the optional browser wizard (loopback only; needs a browser on the Pi or an `ssh -L` tunnel). -The `latest` URL works only once a release carrying `get-vesselstack.sh` is marked latest. -Pinned to one release (works for any published release, including a pre-release): `.../releases/download/v0.5.7/get-vesselstack.sh`, or add -`--version 0.5.7` (`| sudo bash -s -- --version 0.5.7`). It installs the Debian +The pinned URL above works for any published release, including a prerelease; add +`--version 0.6.0` (`| sudo bash -s -- --version 0.6.0`) or set `VESSELSTACK_VERSION=0.6.0` for +the same effect. The form `https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh` +points to whichever release GitHub marks Latest, which is not necessarily the newest release +(every 0.5.x release was a prerelease), so it is a secondary option. The script installs the Debian prerequisites, asks before installing Docker Engine and Compose v2 from Docker's official apt repository (see the box above), downloads and checksum-verifies the release, asks the configuration questions in the terminal, then runs the installer dry run, the @@ -132,7 +142,9 @@ page, or pass `--sha256 HASH`. To read the script first (recommended for the fir install): ```bash -curl -fsSLO https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh +curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh +curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh.sha256 +sha256sum -c get-vesselstack.sh.sha256 less get-vesselstack.sh sudo bash get-vesselstack.sh ``` @@ -239,7 +251,7 @@ Skip sections 5 and 6 if you used the one-line installer. ```bash git clone https://github.com/jclima/VesselStack.git cd VesselStack -git checkout v0.5.7 +git checkout v0.6.0 python3 vesselstack-wizard.py --cli # questions in the terminal, works over plain SSH # python3 vesselstack-wizard.py # the optional browser wizard (below) ``` @@ -253,12 +265,25 @@ ssh -L 8088:127.0.0.1:8088 YOUR_USER@YOUR_PI ``` Then open the same printed address (with its `#token=...`) locally. The assistant inventories the host, -walks through the BOM and deployment choices, and writes `vesselstack.env` with -mode 600. Stop it with Ctrl-C when finished. +walks seven steps (**Preflight**, **Hardware**, **Vessel**, **Storage**, **Signal K**, +**Network**, **Review**) and writes `vesselstack.env` with mode 600. The last page shows the +commands to run next; if the wizard has already stopped by itself, that is expected, and +otherwise stop it with Ctrl-C. + +![Optional browser wizard, step 1 Preflight: host, memory, Docker Compose and can0 checks](images/wizard-preflight.png) + +*Step 1 checks the host for information only; nothing is changed.* + +![Optional browser wizard, step 5 Signal K and NMEA 2000, with the CAN interface warning](images/wizard-nmea.png) + +*Step 5: enable SocketCAN only after the vendor overlay is installed and `can0` appears.* + +![Optional browser wizard, last page: Configuration saved, with the install commands to run next](images/wizard-done.png) -![Wizard system preflight](images/wizard-preflight.png) +*After saving, the page lists `sudo ./install.sh --config FILE --dry-run`, then `--start`, then +`sudo vesselstackctl doctor`. Nothing is installed yet.* -![Wizard NMEA configuration](images/wizard-nmea.png) +These screenshots show the optional browser wizard (`--web-wizard`). The terminal questions are the default path, and wording may differ. ## 6. Preflight, install, and start @@ -320,7 +345,7 @@ which has no login). Unattended: `--set LAN_ACCESS=dashboards`. Change it later LAN presets leave the Control Panel setting unchanged; it remains loopback by default unless you explicitly use `vesselstackctl panel-access lan`. `local` also limits Home Assistant (8123) to the Pi through a delimited `http:` block the installer manages in its `configuration.yaml` (it never -touches an `http:` section you wrote yourself and prints the manual step instead). SignalK (3000) +touches an `http:` section you wrote yourself and prints the manual step instead). Signal K (3000) has no bind-address setting, so no preset restricts it; only the firewall's strict mode can (see [Network access and security](NETWORK.md)). @@ -344,16 +369,16 @@ which never built the index, or when the build failed. Fix it with doctor`. 0.4.3 and later build the index during installation, on upgrade and on `vesselstackctl start`. Details: [Boat Chat setup](BOAT_CHAT.md), "Memory index". -## Managed SignalK and VesselTwin preload +## Managed Signal K and VesselTwin preload -Managed Docker/native SignalK preloads pinned `signalk-vesseltwin` 0.2.3 when +Managed Docker/native Signal K preloads pinned `signalk-vesseltwin` 0.2.3 when `SIGNALK_VESSELTWIN_PRELOAD=true`. Preload, enablement, pairing, and reading permissions are separate steps; see [VESSELTWIN.md](VESSELTWIN.md). Offline Docker installs reuse a valid existing plugin and fail before mutation when it -is missing; native SignalK is unsupported offline. Setting the flag false skips +is missing; native Signal K is unsupported offline. Setting the flag false skips future preloads and never removes an existing plugin. -## Troubleshooting: SignalK not running / EACCES on package.json +## Troubleshooting: Signal K not running / EACCES on package.json Prometheus metrics live in `/prometheus` (owned by uid/gid 65534, created by the installer, included in backups) and are kept for `PROMETHEUS_RETENTION` (default `30d`). @@ -365,13 +390,13 @@ With `SIGNALK_MODE=docker`, if the `vesselstack-signalk` container keeps restart and `sudo docker logs vesselstack-signalk` shows `EACCES: permission denied, open '/home/node/.signalk/package.json'`, the data directory `/signalk` is owned by root instead of uid/gid 1000 (the `node` -user in the SignalK image). Release 0.4.3 and later create and repair it on every +user in the Signal K image). Release 0.4.3 and later create and repair it on every installer run (an empty or `root:root` directory only; a populated directory with symlinks, hard links, special files or another owner is left untouched with instructions), and `sudo vesselstackctl doctor` reports it. Start with the read-only doctor output -and inspect the exact configured SignalK data directory and ownership. Resolve +and inspect the exact configured Signal K data directory and ownership. Resolve the indicated cause, then rerun the supported installer repair. Do not -blanket-chown the SignalK tree or restart the container while ownership and +blanket-chown the Signal K tree or restart the container while ownership and symlink state are still unknown. ## Rollback and recovery diff --git a/docs/MQTT.md b/docs/MQTT.md index eaaf0fa..0d96697 100644 --- a/docs/MQTT.md +++ b/docs/MQTT.md @@ -59,7 +59,7 @@ cannot interleave with a concurrent `displayctl add`. With `DISPLAYS_ENABLE=false` (the default) only the administrator account (`MQTT_USERNAME`) exists: it has `readwrite #` and `read $SYS/#`, so Home -Assistant, SignalK and other existing clients that use that account keep +Assistant, Signal K and other existing clients that use that account keep working. With `DISPLAYS_ENABLE=true` these accounts are added: | Account | Read | Write | @@ -110,7 +110,7 @@ Operational notes: broker without a restart. - `install.sh --dry-run` (the Control Panel "Preflight", and the check that `vesselstackctl update` runs before it takes a backup) never writes files, - pulls images, takes the lock or signals containers, but it now runs + pulls images, takes the lock or signals containers, but it runs `displayctl.py preflight`, which rejects up front everything the sync would reject: an administrator `MQTT_PASSWORD` containing `:` or leading/trailing whitespace (a generated one is always fine), invalid account names, a bridge @@ -124,7 +124,7 @@ Operational notes: run repeats the same checks and pulls a missing image *before* it changes any file, then syncs the MQTT access files **before** it installs the new `compose.yml` and `config/mosquitto/mosquitto.conf`, so a failure leaves the - previous broker definition in place. Apply configuration and install may + previous broker definition in place. **Apply** and **Reinstall & start** may pull the pinned Mosquitto image on first use and send `SIGHUP` to a running broker. - Upgrade impact: the Mosquitto container is recreated (a brief MQTT @@ -136,7 +136,7 @@ Operational notes: `config/mosquitto.conf` and reads the legacy `passwords` file in `$VESSELSTACK_DATA/mosquitto`, so existing clients keep working, but the new ACL, the bridge account and the display accounts do **not** exist on that - broker. A render-only run (Control Panel "Apply configuration", or + broker. A render-only run (Control Panel **Apply**, or `install.sh --config ...` without `--start`) writes the new files but never recreates containers; it prints a `NOTICE` when the running broker predates the new definition (and when the displays bridge is enabled but not running) @@ -188,7 +188,7 @@ Operational notes: survive the new render plus `docker restart`, and only the container recreated from the real `compose.yml` mounts enforces the ACL. - `tests/test-displays-envfile-systemd.sh` boots systemd in a privileged - Debian container and checks that a SignalK token containing dollar, backtick, quote, backslash, + Debian container and checks that a Signal K token containing dollar, backtick, quote, backslash, hash, semicolon and percent characters and spaces reaches the service environment unchanged through `EnvironmentFile=` (systemd does not expand `$` or `%` there), and that `systemd-analyze verify` accepts the rendered unit. If the container cannot diff --git a/docs/NETWORK.md b/docs/NETWORK.md index 1c7d3ca..73fb09a 100644 --- a/docs/NETWORK.md +++ b/docs/NETWORK.md @@ -25,7 +25,7 @@ unchanged. `all` also opens Prometheus, which has **no login**, plus InfluxDB (token) and MQTT (password) to every device on the network; turn the firewall on and prefer Ethernet or a VPN as the way in. -**What is enforced and what is not (host-networked containers).** SignalK (3000) and Home +**What is enforced and what is not (host-networked containers).** Signal K (3000) and Home Assistant (8123) run with `network_mode: host`, so Compose port bindings cannot restrict them. - **Home Assistant: enforced by `local`.** Home Assistant binds the address in @@ -59,7 +59,7 @@ Assistant (8123) run with `network_mode: host`, so Compose port bindings cannot discovery or HomeKit integrations you add) are outside it. Boat Chat, the telemetry indexer and the installer already reach it on `http://127.0.0.1:8123`, and Home Assistant reaches MQTT and InfluxDB on loopback, so nothing else depends on the LAN address. -- **SignalK: NOT restricted by any preset (documented limitation).** Verified against the +- **Signal K: NOT restricted by any preset (documented limitation).** Verified against the pinned image (`signalk-server` v2.27.0): the server starts its HTTP/WebSocket listener with `server.listen(port)` and logs `signalk-server running at 0.0.0.0:3000`; there is no environment variable, `settings.json` key or command-line option for the bind address (only @@ -67,7 +67,7 @@ Assistant (8123) run with `network_mode: host`, so Compose port bindings cannot restrict the port but breaks SocketCAN and local NMEA feeds, so it is not done. The only VesselStack control is the firewall's strict mode (`VESSELSTACK_FIREWALL_STRICT=true`), which drops port 3000 on the untrusted interface only; it is not enabled by a preset. On - a trusted network, or with the firewall off, SignalK stays reachable by other devices + a trusted network, or with the firewall off, Signal K stays reachable by other devices even under `local`. `sudo vesselstackctl urls` shows how every service really listens. ```bash @@ -110,7 +110,7 @@ curl -sS -m 5 -o /dev/null -w 'influx %{http_code}\n' http://PI:8086/health || e ## Network security -Compose now publishes InfluxDB `8086`, Prometheus `9090`, and MQTT `1883` on +Compose publishes InfluxDB `8086`, Prometheus `9090`, and MQTT `1883` on `127.0.0.1` by default (`VESSELSTACK_DATA_BIND_ADDRESS`). Grafana `43000` and Heimdall `80/443` remain reachable on every interface by default (`VESSELSTACK_WEB_BIND_ADDRESS=0.0.0.0`) because the documented dashboards are @@ -121,7 +121,7 @@ Assistant reaches MQTT on loopback. AIS ports are unchanged. At minimum: - restrict Grafana `43000` and Heimdall to trusted management networks, and set `VESSELSTACK_DATA_BIND_ADDRESS` to a trusted LAN/VPN IPv4 address only if external devices must publish to MQTT or InfluxDB; -- keep Home Assistant, SignalK, and Boat Chat off the public internet unless +- keep Home Assistant, Signal K, and Boat Chat off the public internet unless protected by an authenticated reverse proxy; - keep the root-privileged Control Panel loopback-only or behind a trusted VPN and authenticated proxy; @@ -140,7 +140,7 @@ VESSELSTACK_UNTRUSTED_INTERFACE="wlan0" Preflight requires that interface to exist. The policy blocks MQTT, InfluxDB, Prometheus, and Grafana administrative/data ports arriving on that interface, -including Docker-forwarded traffic, while leaving Home Assistant, SignalK, +including Docker-forwarded traffic, while leaving Home Assistant, Signal K, Boat Chat, AIS viewer, HTTP/HTTPS, loopback, VPN, and outbound traffic intact. Review the policy without root or any change using `vesselstack-firewall print` (renders the exact `iptables-restore` payloads). @@ -163,7 +163,7 @@ VESSELSTACK_FIREWALL_ALLOW_UDP="" On the untrusted interface strict mode drops everything except replies to connections the boat started, ICMP/ICMPv6, DHCP client replies and the -allow-lists. That covers host-networked SignalK `3000`, AIS, Boat Chat `8765` +allow-lists. That covers host-networked Signal K `3000`, AIS, Boat Chat `8765` (INPUT) and published container ports such as Heimdall `80/443` and Grafana (`DOCKER-USER`, matched on the published port). The allow-list therefore opens ports on both paths; the default `22` only lets SSH in. Keep Ethernet, a VPN or diff --git a/docs/README.md b/docs/README.md index 04978db..cdd28a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,14 +21,17 @@ Boat Chat provider that sends data elsewhere. | Install on an existing Pi | [Getting started](GETTING_STARTED.md) → [Installation](INSTALLATION.md) | | Stage releases in a home lab | [Deployment](DEPLOYMENT.md) | | Carry an update to an offline boat | [Deployment: offline path](DEPLOYMENT.md#simple-path-one-command) | -| Understand every setting and command | [README reference](../README.md#quick-start-one-command-on-the-raspberry-pi) | -| Use or secure the Control Panel | [Control Panel](CONTROL_PANEL.md) | -| Set up Boat Chat access, units and timezone | [Boat Chat setup](BOAT_CHAT.md) | +| Understand every setting and command | [README settings reference](../README.md#4-configure-the-vessel) | +| Use or secure the Control Panel (tab-by-tab tour, backups, updates, recovery) | [Control Panel](CONTROL_PANEL.md) | +| Test the Control Panel on a lab Pi | [Control Panel lab testing](CONTROL_PANEL_TESTING.md) | +| Use Boat Chat, its access and admin codes, units and timezone | [Boat Chat setup](BOAT_CHAT.md), providers in [Boat Chat providers](../boat-chat/README.md) | | Manage MQTT accounts and access lists | [MQTT access control](MQTT.md) | | Reach dashboards from other devices | [Network access and security](NETWORK.md) | | Update, roll back, or install offline | [Updating and offline install](UPDATING.md) | +| Verify a signed release tag | [Updating: verify a release tag](UPDATING.md#verify-a-release-tag) | +| Sign and publish a release (1Password key) | [Deployment: release signing](DEPLOYMENT.md#1-release-signing-with-an-ssh-signing-key) | | Set up ESP32 instrument displays (preview) | [Instrument displays](DISPLAYS.md) | -| Configure Boat Chat providers and units | [Boat Chat setup](../boat-chat/README.md) | +| Configure Boat Chat providers | [Boat Chat providers](../boat-chat/README.md) | | Review power sources and instrument-reading coverage | [Power monitoring](../boat-chat/POWER_MONITORING.md) | | Connect selected readings to VesselTwin | [Optional VesselTwin connection](VESSELTWIN.md) | | Develop, test, or cut a release | [Development](DEVELOPMENT.md) | @@ -46,13 +49,13 @@ should be reached through an SSH tunnel: ssh -L 8780:127.0.0.1:8780 YOUR_USER@PI ``` -To deliberately allow panel access from a trusted LAN, preview then run -`sudo vesselstackctl panel-access lan`; this changes only the panel bind and +The tunnel is the recommended default. Panel access from a trusted LAN is opt-in: preview +then run `sudo vesselstackctl panel-access lan`; this changes only the panel bind and uses unencrypted HTTP on all interfaces. A strict firewall may still block it. Restore the tunnel-only default with `sudo vesselstackctl panel-access local`. An optional VesselTwin connection uses the `signalk-vesseltwin` plugin, pairing, -and explicit reading opt-ins. Managed Docker/native SignalK installs preload the +and explicit reading opt-ins. Managed Docker/native Signal K installs preload the pinned 0.2.3 plugin by default; existing mode is untouched and uses the upstream install path. VesselStack never enables, pairs, or authorizes readings for you. See the [VesselTwin integration guide](VESSELTWIN.md) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 2749280..05cc981 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -4,7 +4,46 @@ Purpose: update a running VesselStack with a reviewed release, roll back, and in [Back to README](../README.md) · [Documentation index](README.md) -Back up configuration and data, then use a reviewed release: +Back up first (see [Backups](../README.md#backups)); updating and rolling back are summarised +in the README under [Updating](../README.md#updating) and +[Upgrading to 0.6.0](../README.md#upgrading-to-060). + +## From the pinned installer + +Re-run the pinned release installer over `ssh -t`. It finds the installed system and offers +`vesselstackctl update `, which runs preflight, makes a verified backup and then +installs and starts the release: + +```bash +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash +``` + +The command itself needs the unpacked release directory, so plain `vesselstackctl update` +with no argument fails. To run it by hand once a release is unpacked (the installer unpacks to +`/opt/vesselstack-release/`): + +```bash +sudo vesselstackctl update /opt/vesselstack-release/0.6.0 +``` + +## From the Control Panel + +Open the **Updates** tab and press **Check for updates**. This is the only thing that +contacts `github.com`; nothing about your boat is sent. A newer release is listed with a +link to its notes and a **Back up & install** button. Releases that GitHub publishes as +prereleases appear only after you turn on **Include early test releases** (0.5.x panels +call the same switch "Include lab prereleases"). **Back up & install** downloads the +archive, checks its size and SHA-256 checksum, runs the installer's `--dry-run` check, +takes a backup with `--require-mounted` (so the update stops if the external backup drive +under `/media` or `/mnt` is not mounted), installs, restarts the Control Panel and waits +for it to report the new version. The page reconnects by itself. See +[Control Panel](CONTROL_PANEL.md#updates). + +If the Control Panel is unavailable, use the pinned installer above. + +## From a source checkout + +Use a reviewed tag from a git clone, with the dry run first: ```bash cd VesselStack @@ -21,85 +60,47 @@ sudo ./install.sh \ Review Compose and release notes before applying container changes. Prefer the installed configuration as input. If you do re-run with a file that still says -`GENERATE` (for example a fresh copy of the example), the installer now keeps +`GENERATE` (for example a fresh copy of the example), the installer keeps the already-installed InfluxDB, Grafana, MQTT, Boat Chat and control-panel secrets instead of generating new ones. A non-`GENERATE` value you set explicitly still wins. -Once a reviewed release directory is downloaded, the lifecycle command can -perform preflight, a verified pre-update backup, installation, and startup: +Once a reviewed release directory is downloaded, the lifecycle command performs +preflight, a verified pre-update backup, installation, and startup: ```bash -sudo vesselstackctl update /path/to/VesselStack-release -sudo VESSELSTACK_OFFLINE=true VESSELSTACK_WHEELHOUSE=/path/to/offline-bundle/wheelhouse \ - vesselstackctl update /path/to/VesselStack-release # no network +sudo vesselstackctl update /path/to/reviewed-release ``` -For Control Panel updates, stage the reviewed release beneath its restricted -root first. The panel rejects symlinks, missing release metadata, paths outside -this directory, and any entry anywhere in the release tree (`scripts/`, -`boat-chat/`, `templates/`, `systemd/`, ...) that is a symlink, owned by a user -other than root/the panel account, or group/world-writable: +Before it rewrites the installation the installer checks every value it will write to +`vesselstack.env`, `boat-chat.env` and `control-panel.env`, then builds the Python +environment in a staging directory and checks the upgrade path. A failed or +offline-without-bundle run stops with "Nothing was changed" and leaves the previous +installation, `vesselstack.env`, units and version marker as they were. A value the +installer refuses to write (an apostrophe combined with a backslash, double quote, dollar +sign or backtick, or a multi-line value) is caught by `--dry-run` itself, so `vesselstackctl +update` and the Control Panel updater stop before the backup, naming only the setting's key; +see [Upgrading to 0.6.0](../README.md#upgrading-to-060). The env files are then written +atomically (temporary file, then rename), so a failed run never leaves one truncated. When +the code, units and `compose.yml` have been replaced, the staged virtualenv is swapped +in and `installed-version` is written last. + +## Rollback + +Downgrades are refused. Rolling back a completed or half-finished upgrade is a restore of +the pre-update backup that `vesselstackctl update` takes automatically: ```bash -sudo install -d -m 0755 /var/lib/vesselstack/releases -sudo cp -a /path/to/vesselstack-x.y.z /var/lib/vesselstack/releases/ -sudo chown -R root:root /var/lib/vesselstack/releases/vesselstack-x.y.z -sudo chmod -R go-w /var/lib/vesselstack/releases/vesselstack-x.y.z +sudo vesselstackctl verify-backup /path/to/vesselstack-TIMESTAMP.tar.gz +sudo vesselstackctl restore /path/to/vesselstack-TIMESTAMP.tar.gz --yes ``` -Then enter `/var/lib/vesselstack/releases/vesselstack-x.y.z` in the panel. -Shell-initiated `vesselstackctl update` remains available for a trusted operator -and is not limited to the panel staging directory. - -The installer records the configuration schema in -`/opt/vesselstack/config/installed-version`. Any upgrade from 0.0.1 or newer to a -newer release is accepted: when a release needs no data change only the version -marker moves, and releases that do need one ship an executable -`scripts/migrations/.sh` that runs once, in version order. -The first release (0.0.1, formerly 1.0.0) migrates pre-release "0.1" layouts and -retains legacy InfluxDB names when the new bucket settings are absent. - -**Version renumbering (2026-10-25).** The project is pre-1.0, so the former -1.x releases were renumbered: 1.0.0 is now 0.0.1, 1.0.1 is 0.0.2, and 1.1.0, -1.2.0, 1.3.0, 1.4.0, 1.4.1 are 0.1.0, 0.2.0, 0.3.0, 0.4.0, 0.4.1 (see the -CHANGELOG for the table). Former `v1.x` tags and releases were removed. A machine -that installed a former 1.x release has `1.x.y` in `installed-version`, which -would look like a downgrade to `0.4.1`. `migrate.sh` and `get-vesselstack.sh` -therefore treat an installed `1.M.P` as `0.M.P` (1.0.0 and 1.0.1 as 0.0.1 and -0.0.2) when the target is a 0.x release, so `vesselstackctl update` and -`get-vesselstack.sh` upgrade it normally and rewrite the marker. If an older -`migrate.sh` refuses it anyway, run `sudo vesselstackctl backup`, then either -write the matching new number into `/opt/vesselstack/config/installed-version` -(for example `echo 0.4.1 | sudo tee /opt/vesselstack/config/installed-version` -for a 1.4.1 install; the configuration format is unchanged by the renumbering) or -reinstall 0.4.1 and `sudo vesselstackctl restore` the backup. To roll back the -renumbering itself, restore that backup on the previous release. - -**Downgrades are not supported.** Configuration and data may already use the -newer schema, so `install.sh` (including `--dry-run`) and `migrate.sh` refuse to -install an older release over a newer one and change nothing. To go back, restore -a backup taken before the upgrade (`sudo vesselstackctl restore ...`, see -Backups) or install the newer release again. Unknown or malformed version -markers are refused instead of guessed at. - -Install order is transactional. Preflight (`--dry-run` runs the same checks) -validates the upgrade path, the Compose template and, offline, the wheelhouse and -local images **before** any installed file is touched. The Python environment is -then built in a staging virtualenv next to the install; only if that succeeds are -the environment, units and code rewritten, the staged virtualenv swapped in, and -`installed-version` written last. A failed or offline-without-bundle run -therefore leaves the previous installation, `vesselstack.env`, units and version -marker exactly as they were (the installer says "Nothing was changed"). -Rollback of a completed upgrade is a restore of the pre-update backup that -`vesselstackctl update` takes automatically. - ## Offline install (no internet on the boat) Prepare a bundle on the home lab or any connected machine, carry it to the boat, then install without any network access. The guided installer does the checks, `--verify`, `--load` and install for you: -`sudo bash get-vesselstack.sh --offline --bundle /path/to/offline-bundle --tarball /path/to/vesselstack-0.5.7.tar.gz` +`sudo bash get-vesselstack.sh --offline --bundle /path/to/offline-bundle --tarball /path/to/vesselstack-0.6.0.tar.gz` (copy `get-vesselstack.sh`, the tarball with its `.sha256` and the bundle on the carry media; the bundle must be root-owned and not group/world-writable, and packages and Docker must already be installed). The manual steps follow: @@ -144,11 +145,11 @@ root-owned and not group/world-writable. `install.sh --offline` also requires `VESSELSTACK_WHEELHOUSE` to be the `wheelhouse/` directory of such a bundle, then runs `pip install --no-index --find-links $VESSELSTACK_WHEELHOUSE --only-binary=:all: --require-hashes -r boat-chat/requirements.lock`, so even a -bundle forged consistently cannot install a wheel the signed release did not +bundle forged consistently cannot install a wheel the pinned release did not pin. Containers start with `docker compose up -d --pull never`. If an image is missing the installer lists it with the `docker load` hint: `--start` fails before changing anything, while a plain render only warns. Offline mode never -contacts a registry or PyPI; SignalK/InfluxDB version bumps still need a new +contacts a registry or PyPI; Signal K/InfluxDB version bumps still need a new bundle. Image tarballs are covered by the manifest and the root-controlled path check but, unlike wheels, are not pinned by digest in the release (Compose images are tag-pinned). @@ -175,6 +176,28 @@ versions would read it as the directory, so the guided installer and the docs us variable) passes `--offline` to both the preflight and the install of that release; any value other than `true`/`false` in `VESSELSTACK_OFFLINE` is rejected. Without the flag or -variable the behaviour is unchanged apart from the hash-pinned lock. Rollback: -restore the pre-update backup; to return to the previous behaviour revert this -change (the lock file is inert for older releases). +variable the install runs online, with the hash-pinned lock. Rollback: restore the +pre-update backup (the lock file is inert for older releases). + +## Verify a release tag + +A checksum shows a download is intact; it is not signed provenance. Release tags are +SSH-signed annotated tags, and `scripts/verify-release-tag.sh` checks one against the +release key listed in `.github/allowed_signers`. It needs a clone that contains the tag +and `ssh-keygen` (part of OpenSSH): + +```bash +git fetch --tags +scripts/verify-release-tag.sh v0.6.0 +``` + +The script takes `TAG [ALLOWED_SIGNERS_FILE [EXPECTED_COMMIT]]`. It checks that the tag is +annotated, that its own name equals `TAG`, that it points directly at a commit, that the +signature is Good from an allowed key and that the signer is the release principal. Pass a +full 40 or 64 character commit SHA as the third argument to also bind the tag to the commit +you recorded when the release was built (use `""` as the second argument to keep the default +signers file). It exits non-zero, with an error on stderr, for an unsigned, lightweight or +wrongly signed tag. Older `v0.5.x` tags are unsigned and fail by design. The Control Panel +updater and `vesselstackctl update` check the archive checksum only; they do not run this +script. How maintainers sign a release with a key held in 1Password is in +[Deployment: release signing](DEPLOYMENT.md#1-release-signing-with-an-ssh-signing-key). diff --git a/docs/VESSELTWIN.md b/docs/VESSELTWIN.md index 10c12da..85ba579 100644 --- a/docs/VESSELTWIN.md +++ b/docs/VESSELTWIN.md @@ -36,7 +36,7 @@ Check the account and rollout first: your account. - Sign up or check your account at the [VesselTwin home page](https://www.vesseltwin.io/). Use the home page rather than bookmarking a direct signup route. -- In VesselTwin, open **Settings > Integrations > SignalK**. If that entry is not +- In VesselTwin, open **Settings > Integrations > Signal K**. If that entry is not available there, stop and ask about access before installing the plugin. - The upstream plugin currently requires **Node.js 20.3 or newer** and a **Signal K 2.x** server. Those are upstream requirements, not a promise that @@ -176,7 +176,7 @@ VesselTwin. Remove or revoke the boat connection there as a separate step. | What you see | What to check | |---|---| | The Appstore has no VesselTwin entry | Confirm Signal K and Node requirements, then follow the [upstream install instructions](https://github.com/jclima/signalk-vesseltwin#install) for the actual managed configuration directory. | -| Pairing is unavailable or account access is denied | Recheck **Settings > Integrations > SignalK** and the account plan/rollout. A paid plan does not by itself prove access is enabled. | +| Pairing is unavailable or account access is denied | Recheck **Settings > Integrations > Signal K** and the account plan/rollout. A paid plan does not by itself prove access is enabled. | | It says paired, but no readings arrive | Confirm the master switch, the desired category switches, and the instance filters. Pairing alone sends nothing. | | A new engine, battery, or tank is missing | Check that Signal K is publishing a current value under a selected instance id, then save the plugin settings and wait for the next value. | | Values arrive but are not in the record | Open **Sensor channels** in VesselTwin, map each channel to equipment, and **Confirm** it. | diff --git a/docs/images/boat-chat-overview.png b/docs/images/boat-chat-overview.png new file mode 100644 index 0000000..0f77c2c Binary files /dev/null and b/docs/images/boat-chat-overview.png differ diff --git a/docs/images/boat-chat-phone-dark.png b/docs/images/boat-chat-phone-dark.png new file mode 100644 index 0000000..0bd4d49 Binary files /dev/null and b/docs/images/boat-chat-phone-dark.png differ diff --git a/docs/images/control-panel-backup-result.png b/docs/images/control-panel-backup-result.png new file mode 100644 index 0000000..ac56d1c Binary files /dev/null and b/docs/images/control-panel-backup-result.png differ diff --git a/docs/images/control-panel-home.png b/docs/images/control-panel-home.png new file mode 100644 index 0000000..85f5b77 Binary files /dev/null and b/docs/images/control-panel-home.png differ diff --git a/docs/images/control-panel-login.png b/docs/images/control-panel-login.png new file mode 100644 index 0000000..508caaf Binary files /dev/null and b/docs/images/control-panel-login.png differ diff --git a/docs/images/control-panel-maintenance.png b/docs/images/control-panel-maintenance.png new file mode 100644 index 0000000..21888cd Binary files /dev/null and b/docs/images/control-panel-maintenance.png differ diff --git a/docs/images/control-panel-phone-dark.png b/docs/images/control-panel-phone-dark.png new file mode 100644 index 0000000..86c4417 Binary files /dev/null and b/docs/images/control-panel-phone-dark.png differ diff --git a/docs/images/control-panel-settings.png b/docs/images/control-panel-settings.png new file mode 100644 index 0000000..7f89017 Binary files /dev/null and b/docs/images/control-panel-settings.png differ diff --git a/docs/images/control-panel-stop-dialog.png b/docs/images/control-panel-stop-dialog.png new file mode 100644 index 0000000..a09a25c Binary files /dev/null and b/docs/images/control-panel-stop-dialog.png differ diff --git a/docs/images/control-panel-update-available.png b/docs/images/control-panel-update-available.png new file mode 100644 index 0000000..22ec6d8 Binary files /dev/null and b/docs/images/control-panel-update-available.png differ diff --git a/docs/images/control-panel-updates.png b/docs/images/control-panel-updates.png new file mode 100644 index 0000000..c8fe5f9 Binary files /dev/null and b/docs/images/control-panel-updates.png differ diff --git a/docs/images/wizard-done.png b/docs/images/wizard-done.png new file mode 100644 index 0000000..f735c3e Binary files /dev/null and b/docs/images/wizard-done.png differ diff --git a/docs/images/wizard-nmea.png b/docs/images/wizard-nmea.png index 4c50ee2..972e44a 100644 Binary files a/docs/images/wizard-nmea.png and b/docs/images/wizard-nmea.png differ diff --git a/docs/images/wizard-preflight.png b/docs/images/wizard-preflight.png index f1c0272..ea79fdc 100644 Binary files a/docs/images/wizard-preflight.png and b/docs/images/wizard-preflight.png differ diff --git a/install.sh b/install.sh index 5f26912..b4c74be 100755 --- a/install.sh +++ b/install.sh @@ -381,11 +381,226 @@ if [ "$OFFLINE" = true ]; then echo "WARNING: load the images before running with --start or starting services." >&2 fi fi +# Environment-file values. One predicate decides what cannot be written safely so +# the preflight below and env_line can never disagree. +# env-line-begin +vesselstack_env_keys() { + cat <<'EOF' +BOAT_NAME +BOAT_TYPE +BOAT_MMSI +BOAT_CALLSIGN +BOAT_LOA_M +BOAT_BEAM_M +BOAT_TIMEZONE +BOAT_UNITS +VESSELSTACK_USER +VESSELSTACK_UID +VESSELSTACK_GID +VESSELSTACK_ROOT +VESSELSTACK_DATA +VESSELSTACK_BACKUP +SIGNALK_MODE +SIGNALK_VERSION +SIGNALK_URL +SIGNALK_VESSELTWIN_PRELOAD +SOCKETCAN_ENABLE +SOCKETCAN_INTERFACE +SOCKETCAN_BITRATE +VESSELSTACK_CAN_CREATE_VCAN +AIS_ENABLE +AIS_IMAGE +AIS_DEVICE +AIS_CATCHER_ARGS +AIS_WEB_PORT +AIS_TCP_PORT +VESSELSTACK_DISABLED_COMPONENTS +VESSELSTACK_UNTRUSTED_INTERFACE +VESSELSTACK_FIREWALL_ENABLE +VESSELSTACK_FIREWALL_STRICT +VESSELSTACK_FIREWALL_ALLOW_TCP +VESSELSTACK_FIREWALL_ALLOW_UDP +CONTROL_PANEL_HOST +CONTROL_PANEL_PORT +TELEGRAM_ENABLE +TELEMETRY_INDEXER_ENABLE +NODE_EXPORTER_ENABLE +DISPLAYS_ENABLE +DISPLAYS_BRIDGE_MQTT_USERNAME +DISPLAYS_SIGNALK_TOKEN +DISPLAYS_HA_TOKEN +HOME_ASSISTANT_URL +HOME_ASSISTANT_TOKEN +INFLUXDB_URL +INFLUXDB_PORT +INFLUXDB_CONTAINER_NAME +INFLUXDB_ORG +INFLUXDB_USERNAME +INFLUXDB_RAW_BUCKET +INFLUXDB_HISTORY_BUCKET +INFLUXDB_HOME_ASSISTANT_BUCKET +INFLUXDB_AIS_BUCKET +INFLUXDB_RAW_RETENTION +INFLUXDB_PASSWORD +INFLUXDB_TOKEN +GRAFANA_ADMIN_PASSWORD +GRAFANA_PORT +HEIMDALL_PORT +HEIMDALL_HTTPS_PORT +PROMETHEUS_PORT +PROMETHEUS_RETENTION +MQTT_USERNAME +MQTT_PASSWORD +MQTT_PORT +VESSELSTACK_DATA_BIND_ADDRESS +VESSELSTACK_WEB_BIND_ADDRESS +HOMEASSISTANT_BIND_ADDRESS +EOF +} +# Keys written to boat-chat.env and control-panel.env beyond the list above. +extra_env_keys() { + printf '%s\n' BOAT_CHAT_PROVIDER BOAT_CHAT_HOST BOAT_CHAT_PORT \ + BOAT_CHAT_SETTINGS_TOKEN BOAT_CHAT_ACCESS_TOKEN CONTROL_PANEL_TOKEN +} +# env_value_problem VALUE: prints the reason and returns 0 when VALUE cannot be +# written. Compose cannot parse the '\'' idiom, so a value with a single quote is +# double-quoted, which is only portable when it has no \ " $ or backtick. +env_value_problem() { + local value=$1 + case $value in + *$'\n'*|*$'\r'*) echo "a multi-line value"; return 0 ;; + esac + if [[ $value == *\'* && $value == *[\\\"\$\`]* ]]; then + echo "a single quote combined with a backslash, double quote, dollar sign or backtick" + return 0 + fi + return 1 +} +# env_line KEY VALUE: one env-file line that bash (`source`), systemd +# EnvironmentFile, Python shlex and Docker Compose all read back identically. +# Plain values stay bare; others are POSIX single-quoted; a value with a single +# quote is double-quoted (see env_value_problem for what is refused). +env_line() { + local key=$1 value=$2 reason + if reason=$(env_value_problem "$value"); then + echo "Refusing value for $key: $reason" >&2 + exit 1 + fi + if [ -n "$value" ] && [[ $value =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then + printf '%s=%s\n' "$key" "$value" + elif [[ $value != *\'* ]]; then + printf "%s='%s'\n" "$key" "$value" + else + printf '%s="%s"\n' "$key" "$value" + fi +} +# env-line-end + +# Env files are written to a 0600 temp file beside the target and renamed into +# place only when every line was written, so a failure never leaves a truncated file. +ENV_TEMPS=() +new_env_temp() { + NEW_ENV_TEMP=$(mktemp "$VESSELSTACK_ROOT/config/.$1.XXXXXX") + ENV_TEMPS+=("$NEW_ENV_TEMP") +} +cleanup_env_temps() { + [ "${#ENV_TEMPS[@]}" -eq 0 ] || rm -f -- "${ENV_TEMPS[@]}" + return 0 +} + +# Read-only check of every value that will reach vesselstack.env, boat-chat.env +# and control-panel.env, run before anything is changed and before the dry run +# exits. Reports KEY names and reasons only, never values. +env_values_preflight() { + local key bad=0 reason + while IFS= read -r key; do + if reason=$(env_value_problem "${!key:-}"); then + echo " $key: $reason" >&2 + bad=1 + fi + done < <(vesselstack_env_keys; extra_env_keys) + if reason=$(env_value_problem "$(<"$SCRIPT_DIR/VERSION")"); then + echo " VERSION: $reason" >&2 + bad=1 + fi + if [ "$bad" -ne 0 ]; then + echo "Refusing to continue: the settings above cannot be written safely to the environment files." >&2 + echo "Edit them in $CONFIG_FILE (values cannot combine a single quote with a backslash, double quote, dollar sign or backtick, and cannot span lines), then run again. Nothing was changed. See README.md, \"Upgrading to 0.6.0\"." >&2 + exit 1 + fi +} +generate_secret() { + local current=$1 + if [ -z "$current" ] || [ "$current" = GENERATE ]; then + openssl rand -hex 32 + else + printf '%s' "$current" + fi +} + +# Preserve EVERY generated secret across re-renders. When the input config says +# GENERATE (or leaves a secret empty, e.g. a fresh copy of the example file used +# to upgrade an installed system) the value already installed wins. InfluxDB and +# Grafana only read their initial credentials on first start and Mosquitto's +# password file is keyed to MQTT_PASSWORD, so regenerating any of them would +# silently desynchronise the config from the running services. +installed_env="$VESSELSTACK_ROOT/config/vesselstack.env" +installed_value() { + # Prints the installed value of $1 from the root-owned, mode-600 config. + [ -r "$installed_env" ] || return 0 + ( + set +u + unset "$1" + # shellcheck disable=SC1090 + source "$installed_env" >/dev/null 2>&1 || exit 0 + value=${!1:-} + [ "$value" = GENERATE ] || printf '%s' "$value" + ) +} +preserve_secret() { + # $2 optionally names a fallback app env file. Those files are writable by + # the service user, so only plain hex-like values are accepted from them and + # they are never evaluated. + local key=$1 file=${2:-} current=${!1:-GENERATE} kept= + if [ -n "$current" ] && [ "$current" != GENERATE ]; then + return 0 + fi + kept=$(installed_value "$key") + if [ -z "$kept" ] && [ -n "$file" ] && [ -r "$file" ]; then + kept=$(sed -n "s/^${key}=//p" "$file" | tail -n 1) + [[ "$kept" =~ ^[A-Za-z0-9._-]+$ ]] || kept= + [ "$kept" != GENERATE ] || kept= + fi + if [ -n "$kept" ]; then + printf -v "$key" '%s' "$kept" + fi +} +preserve_secret INFLUXDB_PASSWORD +preserve_secret INFLUXDB_TOKEN +preserve_secret GRAFANA_ADMIN_PASSWORD +preserve_secret MQTT_PASSWORD +preserve_secret BOAT_CHAT_SETTINGS_TOKEN "$VESSELSTACK_ROOT/config/boat-chat.env" +preserve_secret BOAT_CHAT_ACCESS_TOKEN "$VESSELSTACK_ROOT/config/boat-chat.env" +preserve_secret CONTROL_PANEL_TOKEN "$VESSELSTACK_ROOT/config/control-panel.env" + +INFLUXDB_PASSWORD=$(generate_secret "${INFLUXDB_PASSWORD:-GENERATE}") +INFLUXDB_TOKEN=$(generate_secret "${INFLUXDB_TOKEN:-GENERATE}") +GRAFANA_ADMIN_PASSWORD=$(generate_secret "${GRAFANA_ADMIN_PASSWORD:-GENERATE}") +BOAT_CHAT_SETTINGS_TOKEN=$(generate_secret "${BOAT_CHAT_SETTINGS_TOKEN:-GENERATE}") +BOAT_CHAT_ACCESS_TOKEN=$(generate_secret "${BOAT_CHAT_ACCESS_TOKEN:-GENERATE}") +CONTROL_PANEL_TOKEN=$(generate_secret "${CONTROL_PANEL_TOKEN:-GENERATE}") +MQTT_PASSWORD=$(generate_secret "${MQTT_PASSWORD:-GENERATE}") + +# Secrets are resolved (preserved or generated) before the preflight so the +# check covers the values that will actually be written; read-only, so it is +# also safe ahead of the dry run. +env_values_preflight + PLUGIN_STAGE= if [ "$SIGNALK_VESSELTWIN_PRELOAD" = true ] && [ "$SIGNALK_MODE" != existing ] && [ "$OFFLINE" = false ] && [ "$DRY_RUN" -eq 0 ]; then PLUGIN_STAGE=$(mktemp -d "${TMPDIR:-/tmp}/vesselstack-signalk-plugin-stage.XXXXXX") chmod 0700 "$PLUGIN_STAGE" - trap 'rm -rf -- "$PLUGIN_STAGE"' EXIT + trap 'rm -rf -- "$PLUGIN_STAGE"; cleanup_env_temps' EXIT python3 "$SCRIPT_DIR/scripts/preload-signalk-vesseltwin.py" --mode "$SIGNALK_MODE" --data "$VESSELSTACK_DATA" \ --stage "$PLUGIN_STAGE" --prepare fi @@ -416,6 +631,23 @@ if [ "$DRY_RUN" -eq 1 ]; then fi [ "$(id -u)" -eq 0 ] || { echo "Installation requires root" >&2; exit 1; } + +# Remove temp files left in config/ by an earlier run that was killed between +# creating and renaming an env file. Only the exact names new_env_temp creates +# (.vesselstack.env.XXXXXX, .boat-chat.env.XXXXXX, .control-panel.env.XXXXXX with +# six alphanumeric characters); nothing else is touched. +remove_stale_env_temps() { + local dir="$VESSELSTACK_ROOT/config" name file + [ -d "$dir" ] || return 0 + for name in vesselstack.env boat-chat.env control-panel.env; do + for file in "$dir/.$name."??????; do + if [ ! -f "$file" ] || [ -L "$file" ]; then continue; fi + [[ ${file##*/."$name".} =~ ^[A-Za-z0-9]{6}$ ]] || continue + rm -f -- "$file" + done + done +} +remove_stale_env_temps # Same checks as the dry run; a missing Mosquitto image is pulled now, before # any file is changed, so an offline boat fails here with nothing touched. displays_preflight --pull-image @@ -427,7 +659,7 @@ install -d -m 0755 "$SYSTEMD_DIR" "$SBIN_DIR" # nothing: no env, units, code or installed-version are touched. install -d -m 0755 "$VESSELSTACK_ROOT" STAGING_DIR=$(mktemp -d "$VESSELSTACK_ROOT/.venv-staging.XXXXXX") -trap 'rm -rf -- "$STAGING_DIR" ${PLUGIN_STAGE:+"$PLUGIN_STAGE"}' EXIT +trap 'rm -rf -- "$STAGING_DIR" ${PLUGIN_STAGE:+"$PLUGIN_STAGE"}; cleanup_env_temps' EXIT STAGING_VENV="$STAGING_DIR/venv" pip_args=(install --disable-pip-version-check --only-binary=:all: --require-hashes) if [ "$OFFLINE" = true ]; then @@ -450,68 +682,6 @@ if [ "$DISPLAYS_ENABLE" = true ]; then "$VESSELSTACK_ROOT/displays/.venv" "$SCRIPT_DIR/displays/requirements.txt" fi -generate_secret() { - local current=$1 - if [ -z "$current" ] || [ "$current" = GENERATE ]; then - openssl rand -hex 32 - else - printf '%s' "$current" - fi -} - -# Preserve EVERY generated secret across re-renders. When the input config says -# GENERATE (or leaves a secret empty, e.g. a fresh copy of the example file used -# to upgrade an installed system) the value already installed wins. InfluxDB and -# Grafana only read their initial credentials on first start and Mosquitto's -# password file is keyed to MQTT_PASSWORD, so regenerating any of them would -# silently desynchronise the config from the running services. -installed_env="$VESSELSTACK_ROOT/config/vesselstack.env" -installed_value() { - # Prints the installed value of $1 from the root-owned, mode-600 config. - [ -r "$installed_env" ] || return 0 - ( - set +u - unset "$1" - # shellcheck disable=SC1090 - source "$installed_env" >/dev/null 2>&1 || exit 0 - value=${!1:-} - [ "$value" = GENERATE ] || printf '%s' "$value" - ) -} -preserve_secret() { - # $2 optionally names a fallback app env file. Those files are writable by - # the service user, so only plain hex-like values are accepted from them and - # they are never evaluated. - local key=$1 file=${2:-} current=${!1:-GENERATE} kept= - if [ -n "$current" ] && [ "$current" != GENERATE ]; then - return 0 - fi - kept=$(installed_value "$key") - if [ -z "$kept" ] && [ -n "$file" ] && [ -r "$file" ]; then - kept=$(sed -n "s/^${key}=//p" "$file" | tail -n 1) - [[ "$kept" =~ ^[A-Za-z0-9._-]+$ ]] || kept= - [ "$kept" != GENERATE ] || kept= - fi - if [ -n "$kept" ]; then - printf -v "$key" '%s' "$kept" - fi -} -preserve_secret INFLUXDB_PASSWORD -preserve_secret INFLUXDB_TOKEN -preserve_secret GRAFANA_ADMIN_PASSWORD -preserve_secret MQTT_PASSWORD -preserve_secret BOAT_CHAT_SETTINGS_TOKEN "$VESSELSTACK_ROOT/config/boat-chat.env" -preserve_secret BOAT_CHAT_ACCESS_TOKEN "$VESSELSTACK_ROOT/config/boat-chat.env" -preserve_secret CONTROL_PANEL_TOKEN "$VESSELSTACK_ROOT/config/control-panel.env" - -INFLUXDB_PASSWORD=$(generate_secret "${INFLUXDB_PASSWORD:-GENERATE}") -INFLUXDB_TOKEN=$(generate_secret "${INFLUXDB_TOKEN:-GENERATE}") -GRAFANA_ADMIN_PASSWORD=$(generate_secret "${GRAFANA_ADMIN_PASSWORD:-GENERATE}") -BOAT_CHAT_SETTINGS_TOKEN=$(generate_secret "${BOAT_CHAT_SETTINGS_TOKEN:-GENERATE}") -BOAT_CHAT_ACCESS_TOKEN=$(generate_secret "${BOAT_CHAT_ACCESS_TOKEN:-GENERATE}") -CONTROL_PANEL_TOKEN=$(generate_secret "${CONTROL_PANEL_TOKEN:-GENERATE}") -MQTT_PASSWORD=$(generate_secret "${MQTT_PASSWORD:-GENERATE}") - # Ownership model for VESSELSTACK_DATA: the directory itself and every managed # intermediate component is ROOT-owned and not writable by the service user, so the # service user can never rename, replace or symlink an entry that root later @@ -714,6 +884,7 @@ restore_telemetry_on_exit() { echo "Installer staging cleanup failed" >&2 if [ "$rc" -eq 0 ]; then rc=1; fi fi + cleanup_env_temps if [ "$rc" -ne 0 ] || [ "$START" -eq 0 ]; then if [ "$telemetry_sample_was_active" -eq 1 ]; then systemctl start vesselstack-telemetry-sample.timer 2>/dev/null || true; fi if [ "$telemetry_indexer_was_active" -eq 1 ]; then systemctl start vesselstack-telemetry-indexer.timer 2>/dev/null || true; fi @@ -901,108 +1072,15 @@ install -m 0644 "$SCRIPT_DIR/systemd/vesselstack-signalk.service.in" \ install -m 0644 "$SCRIPT_DIR/systemd/vesselstack-socketcan.service.in" \ "$VESSELSTACK_ROOT/systemd/vesselstack-socketcan.service.in" -# env_line KEY VALUE: one env-file line that bash (`source`), systemd -# EnvironmentFile, Python shlex and Docker Compose all read back identically. -# Plain values stay bare; others are POSIX single-quoted. Compose cannot parse the -# '\'' idiom, so a value with a single quote is double-quoted instead, which is -# only portable when it has no \ " $ or backtick (such values are rejected). -# env-line-begin -env_line() { - local key=$1 value=$2 - case $value in - *$'\n'*|*$'\r'*) echo "Refusing multi-line value for $key" >&2; exit 1 ;; - esac - if [ -n "$value" ] && [[ $value =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then - printf '%s=%s\n' "$key" "$value" - elif [[ $value != *\'* ]]; then - printf "%s='%s'\n" "$key" "$value" - elif [[ $value == *[\\\"\$\`]* ]]; then - echo "Refusing value for $key: a single quote cannot be combined with backslash, double quote, dollar or backtick" >&2 - exit 1 - else - printf '%s="%s"\n' "$key" "$value" - fi -} -# env-line-end - -install -m 0600 /dev/null "$VESSELSTACK_ROOT/config/vesselstack.env" +new_env_temp vesselstack.env; vs_env_tmp=$NEW_ENV_TEMP while IFS= read -r key; do - env_line "$key" "${!key:-}" >> "$VESSELSTACK_ROOT/config/vesselstack.env" -done <<'EOF' -BOAT_NAME -BOAT_TYPE -BOAT_MMSI -BOAT_CALLSIGN -BOAT_LOA_M -BOAT_BEAM_M -BOAT_TIMEZONE -BOAT_UNITS -VESSELSTACK_USER -VESSELSTACK_UID -VESSELSTACK_GID -VESSELSTACK_ROOT -VESSELSTACK_DATA -VESSELSTACK_BACKUP -SIGNALK_MODE -SIGNALK_VERSION -SIGNALK_URL -SIGNALK_VESSELTWIN_PRELOAD -SOCKETCAN_ENABLE -SOCKETCAN_INTERFACE -SOCKETCAN_BITRATE -VESSELSTACK_CAN_CREATE_VCAN -AIS_ENABLE -AIS_IMAGE -AIS_DEVICE -AIS_CATCHER_ARGS -AIS_WEB_PORT -AIS_TCP_PORT -VESSELSTACK_DISABLED_COMPONENTS -VESSELSTACK_UNTRUSTED_INTERFACE -VESSELSTACK_FIREWALL_ENABLE -VESSELSTACK_FIREWALL_STRICT -VESSELSTACK_FIREWALL_ALLOW_TCP -VESSELSTACK_FIREWALL_ALLOW_UDP -CONTROL_PANEL_HOST -CONTROL_PANEL_PORT -TELEGRAM_ENABLE -TELEMETRY_INDEXER_ENABLE -NODE_EXPORTER_ENABLE -DISPLAYS_ENABLE -DISPLAYS_BRIDGE_MQTT_USERNAME -DISPLAYS_SIGNALK_TOKEN -DISPLAYS_HA_TOKEN -HOME_ASSISTANT_URL -HOME_ASSISTANT_TOKEN -INFLUXDB_URL -INFLUXDB_PORT -INFLUXDB_CONTAINER_NAME -INFLUXDB_ORG -INFLUXDB_USERNAME -INFLUXDB_RAW_BUCKET -INFLUXDB_HISTORY_BUCKET -INFLUXDB_HOME_ASSISTANT_BUCKET -INFLUXDB_AIS_BUCKET -INFLUXDB_RAW_RETENTION -INFLUXDB_PASSWORD -INFLUXDB_TOKEN -GRAFANA_ADMIN_PASSWORD -GRAFANA_PORT -HEIMDALL_PORT -HEIMDALL_HTTPS_PORT -PROMETHEUS_PORT -PROMETHEUS_RETENTION -MQTT_USERNAME -MQTT_PASSWORD -MQTT_PORT -VESSELSTACK_DATA_BIND_ADDRESS -VESSELSTACK_WEB_BIND_ADDRESS -HOMEASSISTANT_BIND_ADDRESS -EOF + env_line "$key" "${!key:-}" >> "$vs_env_tmp" +done < <(vesselstack_env_keys) +mv -f -- "$vs_env_tmp" "$VESSELSTACK_ROOT/config/vesselstack.env" # Retain provider credentials and optional Boat Chat settings across installer # re-renders while replacing the integration values owned by VesselStack. -boat_chat_temp=$(mktemp) +new_env_temp boat-chat.env; boat_chat_temp=$NEW_ENV_TEMP if [ -r "$VESSELSTACK_ROOT/config/boat-chat.env" ]; then cp "$VESSELSTACK_ROOT/config/boat-chat.env" "$boat_chat_temp" fi @@ -1049,16 +1127,16 @@ done env_line INFLUXDB_HOME_ASSISTANT_BUCKET "$INFLUXDB_HOME_ASSISTANT_BUCKET" env_line INFLUXDB_AIS_BUCKET "$INFLUXDB_AIS_BUCKET" } >> "$boat_chat_temp" -install -m 0600 "$boat_chat_temp" "$VESSELSTACK_ROOT/config/boat-chat.env" -rm -f -- "$boat_chat_temp" -chown "$VESSELSTACK_USER:$VESSELSTACK_USER" "$VESSELSTACK_ROOT/config/boat-chat.env" +chown "$VESSELSTACK_USER:$VESSELSTACK_USER" "$boat_chat_temp" +mv -f -- "$boat_chat_temp" "$VESSELSTACK_ROOT/config/boat-chat.env" -install -m 0600 /dev/null "$VESSELSTACK_ROOT/config/control-panel.env" +new_env_temp control-panel.env; control_panel_temp=$NEW_ENV_TEMP { env_line CONTROL_PANEL_HOST "$CONTROL_PANEL_HOST" env_line CONTROL_PANEL_PORT "$CONTROL_PANEL_PORT" env_line CONTROL_PANEL_TOKEN "$CONTROL_PANEL_TOKEN" -} >> "$VESSELSTACK_ROOT/config/control-panel.env" +} >> "$control_panel_temp" +mv -f -- "$control_panel_temp" "$VESSELSTACK_ROOT/config/control-panel.env" # Vessel identity is generated into a root-owned temporary file in the (root-owned) # application directory and renamed into place; the service user can only read it. diff --git a/scripts/get-vesselstack.sh b/scripts/get-vesselstack.sh index 01be9fd..b9aca42 100755 --- a/scripts/get-vesselstack.sh +++ b/scripts/get-vesselstack.sh @@ -2,7 +2,7 @@ # VesselStack guided installer (bootstrap). # # curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh | sudo bash -# curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.5.7/get-vesselstack.sh | sudo bash +# curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash # # Cautious alternative: download it, read it, then run it. # curl -fsSLO https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh @@ -24,7 +24,7 @@ set -euo pipefail umask 022 # Baked from VERSION by build-package.sh (and checked by tests/test-distribution.sh). -DEFAULT_VERSION="0.5.7" +DEFAULT_VERSION="0.6.0" GITHUB_RELEASES="https://github.com/jclima/VesselStack/releases/download" APT_PACKAGES=(ca-certificates curl jq openssl python3 python3-venv rsync tar gzip) diff --git a/scripts/verify-release-tag.sh b/scripts/verify-release-tag.sh new file mode 100755 index 0000000..8b5474f --- /dev/null +++ b/scripts/verify-release-tag.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Verify that a release tag is an annotated tag SSH-signed by an allowed release key. +# +# Usage: scripts/verify-release-tag.sh TAG [ALLOWED_SIGNERS_FILE [EXPECTED_COMMIT]] +# +# ALLOWED_SIGNERS_FILE defaults to .github/allowed_signers at the repository root; pass "" +# to keep the default while giving EXPECTED_COMMIT. +# EXPECTED_COMMIT, when given, must be a full commit SHA (40 or 64 hex characters, no +# abbreviations or names) and must be the commit the tag points at. The release workflow +# passes the commit it checked out and builds, so the signature approves exactly that commit. +# +# Checks, in order: the ref resolves to one tag object (resolved once; every later check +# inspects that object, never the ref again); it is an annotated tag; its own "tag" header +# equals TAG (so a signed v0.6.1 cannot be pushed as refs/tags/v0.6.0); it points directly at +# a commit (not a tree, blob or another tag); the SSH signature is Good from an allowed key; +# the signer is the release principal; and the commit equals EXPECTED_COMMIT when given. +# Run it inside a clone that contains the tag. Exit status 0 means a Good signature from a +# principal listed in the file; anything else exits non-zero with an error on stderr. +# +# The signer must also be the release principal, jc@linuxzone.org unless the environment +# variable VESSELSTACK_RELEASE_PRINCIPAL names another one (used by the tests). +set -euo pipefail + +expected_principal=${VESSELSTACK_RELEASE_PRINCIPAL:-jc@linuxzone.org} + +die() { echo "verify-release-tag: $*" >&2; exit 1; } + +if [ "$#" -lt 1 ] || [ "$#" -gt 3 ]; then die "usage: $0 TAG [ALLOWED_SIGNERS_FILE [EXPECTED_COMMIT]]"; fi +tag=$1 +case "$tag" in + ''|-*) die "invalid tag name: $tag" ;; +esac + +root=$(git rev-parse --show-toplevel 2>/dev/null) || die "not inside a git repository" +signers=${2:-} +if [ -z "$signers" ]; then signers=$root/.github/allowed_signers; fi +expected_commit=${3:-} +if [ "$#" -ge 3 ]; then + case "$expected_commit" in + *[!0-9a-fA-F]*|'') die "expected commit must be a full hexadecimal commit SHA: $expected_commit" ;; + esac + if [ "${#expected_commit}" -ne 40 ] && [ "${#expected_commit}" -ne 64 ]; then + die "expected commit must be a full 40 or 64 character SHA, not an abbreviation: $expected_commit" + fi + expected_commit=$(printf '%s' "$expected_commit" | tr 'A-F' 'a-f') + expected_commit=$(git rev-parse --verify --quiet "${expected_commit}^{commit}") \ + || die "expected commit $3 is not a commit in this repository" +fi +[ -f "$signers" ] || die "allowed signers file not found: $signers" +command -v ssh-keygen >/dev/null 2>&1 || die "ssh-keygen is required" + +ref="refs/tags/$tag" +# Resolve the ref to its object once. Everything below uses $tagobj, so a ref that moves +# during the run cannot make the signature and the binding checks refer to different objects. +tagobj=$(git rev-parse --verify --quiet "$ref") || die "tag $tag does not exist" +kind=$(git cat-file -t "$tagobj") +[ "$kind" = "tag" ] || die "$tag is a $kind reference (a lightweight tag), not an annotated tag object; release tags must be signed annotated tags" + +body=$(git cat-file tag "$tagobj") +# Header lines only (up to the first blank line); the message body is never consulted. +header=$(printf '%s\n' "$body" | sed '/^$/q') +inner_name=$(printf '%s\n' "$header" | sed -n 's/^tag //p' | head -n 1) +[ "$inner_name" = "$tag" ] || die "name mismatch: ref refs/tags/$tag holds a signed tag object named '$inner_name'; the tag object's own name must equal $tag" +target_type=$(printf '%s\n' "$header" | sed -n 's/^type //p' | head -n 1) +[ "$target_type" = "commit" ] || die "$tag points at a $target_type, not directly at a commit; release tags must tag a commit" + +# Explicit program and format so a repository-local signer (for example a 1Password agent +# helper) can never take part in verification. +status=0 +output=$(git -c gpg.format=ssh -c "gpg.ssh.allowedSignersFile=$signers" -c gpg.ssh.program=ssh-keygen \ + verify-tag --raw "$tagobj" 2>&1) || status=$? + +if [ "$status" -ne 0 ]; then + if printf '%s\n' "$output" | grep -qi 'no signature'; then + die "$tag is unsigned (annotated tag without a signature)" + fi + die "signature verification failed for $tag (not signed by an allowed key in $signers): $output" +fi + +line=$(printf '%s\n' "$output" | grep -E '^Good "git" signature for ' | tail -n 1 || true) +[ -n "$line" ] || die "no Good signature reported for $tag: $output" + +principal=${line#Good \"git\" signature for } +principal=${principal%% with *} +fingerprint=${line##* key } +if [ -z "$principal" ] || [ -z "$fingerprint" ]; then die "could not parse signer from: $line"; fi + +[ "$principal" = "$expected_principal" ] || die "$tag is signed by $principal, but the release principal is $expected_principal" + +tag_commit=$(git rev-parse --verify "${tagobj}^{commit}") || die "cannot resolve the commit of $tag" +if [ -n "$expected_commit" ]; then + [ "$tag_commit" = "$expected_commit" ] || die "$tag points at commit $tag_commit, but the expected commit is $expected_commit" +fi + +echo "OK: $tag is signed by $principal (key $fingerprint), commit $tag_commit" diff --git a/tests/test-clean-install.sh b/tests/test-clean-install.sh index 6fcc6e5..ac3d897 100755 --- a/tests/test-clean-install.sh +++ b/tests/test-clean-install.sh @@ -699,6 +699,47 @@ expect_unchanged_failure "real run with ':' password" xinstall "$X_APP/installer printf '\nMQTT_PASSWORD=" padded"\n' >> "$BADPW" expect_unchanged_failure "dry-run with padded password" xinstall "$X_APP/installer/install.sh" --config "$BADPW" --dry-run +# 1b. A value the env files cannot hold safely (a single quote with a $) is refused +# by a dry run and by a real run BEFORE anything changes: the installed env files +# stay byte-identical, no temp file is left behind, and the message names only the +# key, never the value. +BADVAL="$TEST_ROOT/badval.env" +cp "$XENV" "$BADVAL" +cat >> "$BADVAL" <<'CFG' +HOME_ASSISTANT_TOKEN="it's \$SECRET" +CFG +env_before=$(sha256sum "$XENV" "$X_APP/config/boat-chat.env" "$X_APP/config/control-panel.env") +expect_unchanged_failure "dry-run with unsafe env value" xinstall "$X_APP/installer/install.sh" --config "$BADVAL" --dry-run +grep -q "HOME_ASSISTANT_TOKEN" "$TEST_ROOT/failure.out" +grep -q 'Upgrading to 0.6.0' "$TEST_ROOT/failure.out" +if grep -q 'SECRET' "$TEST_ROOT/failure.out"; then echo "the refusal printed the value" >&2; exit 1; fi +expect_unchanged_failure "real run with unsafe env value" xinstall "$X_APP/installer/install.sh" --config "$BADVAL" +grep -q "HOME_ASSISTANT_TOKEN" "$TEST_ROOT/failure.out" +test "$env_before" = "$(sha256sum "$XENV" "$X_APP/config/boat-chat.env" "$X_APP/config/control-panel.env")" +test -z "$(find "$X_APP/config" -maxdepth 1 -name '.*.env.*' -print -quit)" +# A safe value with an apostrophe still passes the dry run. +GOODVAL="$TEST_ROOT/goodval.env" +cp "$XENV" "$GOODVAL" +cat >> "$GOODVAL" <<'CFG' +HOME_ASSISTANT_TOKEN="it's fine" +CFG +xinstall "$X_APP/installer/install.sh" --config "$GOODVAL" --dry-run | grep -q 'Preflight passed' + +# 1c. Stale env temp files (exact mktemp names) are removed by a real run; a dry run +# leaves them, and similarly named files are never touched. +for stale in .vesselstack.env.AbC123 .boat-chat.env.xYz789 .control-panel.env.000000; do : > "$X_APP/config/$stale"; done +for keep in .vesselstack.env.short .vesselstack.env.AbC1234 .other.env.AbC123 vesselstack.env.AbC123; do : > "$X_APP/config/$keep"; done +xinstall "$X_APP/installer/install.sh" --config "$XENV" --dry-run >/dev/null +test -e "$X_APP/config/.vesselstack.env.AbC123" +xinstall "$X_APP/installer/install.sh" --config "$XENV" >/dev/null +for stale in .vesselstack.env.AbC123 .boat-chat.env.xYz789 .control-panel.env.000000; do + test ! -e "$X_APP/config/$stale" || { echo "stale temp $stale was not removed" >&2; exit 1; } +done +for keep in .vesselstack.env.short .vesselstack.env.AbC1234 .other.env.AbC123 vesselstack.env.AbC123; do + test -e "$X_APP/config/$keep" || { echo "$keep should not have been removed" >&2; exit 1; } + rm -f "$X_APP/config/$keep" +done + # 2. Bridge username colliding with the administrator account. COLLIDE="$TEST_ROOT/collide.env" cp "$XENV" "$COLLIDE" diff --git a/tests/test-distribution.sh b/tests/test-distribution.sh index 28e5f16..904c9ec 100755 --- a/tests/test-distribution.sh +++ b/tests/test-distribution.sh @@ -14,7 +14,8 @@ bash -n "$ROOT/install.sh" "$ROOT/build-package.sh" "$ROOT/scripts/bootstrap-inf "$ROOT/tests/test-displays-esphome-config.sh" "$ROOT/tests/compile-display-firmware.sh" \ "$ROOT/scripts/prepare-offline-bundle.sh" \ "$ROOT/tests/test-build-package.sh" "$ROOT/tests/test-socketcan.sh" "$ROOT/tests/test-offline-install.sh" "$ROOT/tests/test-update-launcher.sh" \ - "$ROOT/scripts/update-requirements-lock.sh" "$ROOT/scripts/get-vesselstack.sh" "$ROOT/tests/test-get-vesselstack.sh" + "$ROOT/scripts/update-requirements-lock.sh" "$ROOT/scripts/get-vesselstack.sh" "$ROOT/tests/test-get-vesselstack.sh" \ + "$ROOT/scripts/verify-release-tag.sh" "$ROOT/tests/test-verify-release-tag.sh" python3 -m py_compile "$ROOT/scripts/pin-displays-requirements.py" python3 -m py_compile "$ROOT/control-panel/app.py" python3 -m py_compile "$ROOT/vesselstack-wizard.py" @@ -38,7 +39,8 @@ shellcheck "$ROOT/install.sh" "$ROOT/build-package.sh" "$ROOT/scripts/bootstrap- "$ROOT/scripts/install-displays-venv.sh" "$ROOT/tests/test-displays-esphome-config.sh" "$ROOT/tests/compile-display-firmware.sh" \ "$ROOT/scripts/prepare-offline-bundle.sh" \ "$ROOT/tests/test-build-package.sh" "$ROOT/tests/test-socketcan.sh" "$ROOT/tests/test-offline-install.sh" "$ROOT/tests/test-update-launcher.sh" \ - "$ROOT/scripts/update-requirements-lock.sh" "$ROOT/scripts/get-vesselstack.sh" "$ROOT/tests/test-get-vesselstack.sh" + "$ROOT/scripts/update-requirements-lock.sh" "$ROOT/scripts/get-vesselstack.sh" "$ROOT/tests/test-get-vesselstack.sh" \ + "$ROOT/scripts/verify-release-tag.sh" "$ROOT/tests/test-verify-release-tag.sh" if grep -nE 'mosquitto_passwd -b' "$ROOT/install.sh" "$ROOT/scripts/vesselstackctl" "$ROOT"/scripts/*.sh "$ROOT"/displays/*.py; then echo "MQTT passwords must not be passed on a command line" >&2 exit 1 diff --git a/tests/test-get-vesselstack.sh b/tests/test-get-vesselstack.sh index 297e1f1..227b498 100755 --- a/tests/test-get-vesselstack.sh +++ b/tests/test-get-vesselstack.sh @@ -647,7 +647,7 @@ refuse_out 'Open the addresses printed above' reset_state SECRET=sekrit-token-value-123 { - printf '%s\n' y 'Bad;Name' 'Sea Wolf' 'Sailing yacht' 3 Europe/Oslo y 12345 123456789 b '' ABCD1 11.5 3.8 + printf '%s\n' y 'Bad;Name' 'Sea Wolf' 'Sailing yacht' 2 Europe/Oslo y 12345 123456789 b '' ABCD1 11.5 3.8 printf '%s\n' 2 http://192.0.2.10:3000 # Signal K: existing server printf '%s\n' y n '' '' # CAN on, no vcan, interface, bitrate printf '%s\n' y '' # AIS on, device @@ -664,7 +664,7 @@ expect_out "A long-lived access token" expect_out "Home Assistant access token \\(optional\\) \\[not set\\]" expect_saved "BOAT_NAME='Sea Wolf'" expect_saved "BOAT_TYPE='Sailing yacht'" -expect_saved "BOAT_UNITS=nautical" +expect_saved "BOAT_UNITS=metric" expect_saved "BOAT_TIMEZONE=Europe/Oslo" expect_saved "BOAT_MMSI=123456789" expect_saved "BOAT_CALLSIGN=ABCD1" diff --git a/tests/test-verify-release-tag.sh b/tests/test-verify-release-tag.sh new file mode 100755 index 0000000..3f674e0 --- /dev/null +++ b/tests/test-verify-release-tag.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Tests scripts/verify-release-tag.sh with throwaway keys in a temporary repository. +# Never touches the caller's git configuration or signing agent. +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +SCRIPT="$ROOT/scripts/verify-release-tag.sh" +WORK=$(mktemp -d) +trap 'rm -rf -- "$WORK"' EXIT + +# Isolate from the caller's and the repository's git config (which may use a 1Password signer). +export HOME="$WORK/home" +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_CONFIG_NOSYSTEM=1 +unset GIT_DIR GIT_WORK_TREE SSH_AUTH_SOCK +mkdir -p "$HOME" + +ssh-keygen -q -t ed25519 -N '' -C test-allowed -f "$WORK/allowed" +ssh-keygen -q -t ed25519 -N '' -C test-other -f "$WORK/other" + +PRINCIPAL=release@example.test +export VESSELSTACK_RELEASE_PRINCIPAL=$PRINCIPAL +pubkey() { cut -d' ' -f1,2 "$1.pub"; } +{ + echo "# comments and blank lines must be accepted" + echo + printf '%s namespaces="git" %s\n' "$PRINCIPAL" "$(pubkey "$WORK/allowed")" +} > "$WORK/signers" +printf '%s namespaces="git" %s\n' "other@example.test" "$(pubkey "$WORK/allowed")" > "$WORK/signers-wrong-principal" + +REPO="$WORK/repo" +git init -q "$REPO" +cd "$REPO" +G=(git -c user.name=Tester -c "user.email=$PRINCIPAL" -c tag.gpgSign=false -c commit.gpgSign=false) +"${G[@]}" commit -q --allow-empty -m test + +sign_args() { echo "-c gpg.format=ssh -c user.signingkey=$1.pub -c gpg.ssh.program=ssh-keygen -c tag.gpgSign=true"; } +# shellcheck disable=SC2046 # sign_args output is intentionally word-split. +"${G[@]}" $(sign_args "$WORK/allowed") tag -s -m "allowed" v1.0.0 +"${G[@]}" tag -a -m "unsigned" v1.0.1 +"${G[@]}" tag v1.0.2 +# shellcheck disable=SC2046 +"${G[@]}" $(sign_args "$WORK/other") tag -s -m "other" v1.0.3 + +COMMIT=$(git rev-parse HEAD) +"${G[@]}" commit -q --allow-empty -m other +OTHER_COMMIT=$(git rev-parse HEAD) +git reset -q --hard "$COMMIT" +# Signed tag created as v9.9.9, then published under another ref name. +# shellcheck disable=SC2046 +"${G[@]}" $(sign_args "$WORK/allowed") tag -s -m "misnamed" v9.9.9 +git update-ref refs/tags/v1.1.0 "$(git rev-parse refs/tags/v9.9.9)" +git tag -d v9.9.9 >/dev/null +# Signed tags of a tree and of another tag. +# shellcheck disable=SC2046 +"${G[@]}" $(sign_args "$WORK/allowed") tag -s -m "tree" v2.0.0 "$(git rev-parse 'HEAD^{tree}')" +# shellcheck disable=SC2046 +"${G[@]}" -c advice.nestedTag=false $(sign_args "$WORK/allowed") tag -s -m "tag of tag" v2.0.1 "$(git rev-parse refs/tags/v1.0.0)" + +failures=0 +expect() { # expect pass|fail DESCRIPTION expected-message-fragment -- command... + local want=$1 desc=$2 frag=$3 out rc=0 + shift 4 + out=$("$@" 2>&1) || rc=$? + if [ "$want" = pass ] && [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -qF -- "$frag"; then + echo "ok - $desc" + elif [ "$want" = fail ] && [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -qF -- "$frag"; then + echo "ok - $desc" + else + echo "FAIL - $desc (rc=$rc): $out" >&2 + failures=$((failures + 1)) + fi +} + +expect pass "(a) tag signed by an allowed key passes" "signed by $PRINCIPAL" -- "$SCRIPT" v1.0.0 "$WORK/signers" +expect fail "(b) unsigned annotated tag fails" "unsigned" -- "$SCRIPT" v1.0.1 "$WORK/signers" +expect fail "(c) lightweight tag fails" "lightweight" -- "$SCRIPT" v1.0.2 "$WORK/signers" +expect fail "(d) tag signed by a different key fails" "verification failed" -- "$SCRIPT" v1.0.3 "$WORK/signers" +expect fail "(e) wrong principal fails" "release principal is $PRINCIPAL" -- "$SCRIPT" v1.0.0 "$WORK/signers-wrong-principal" +expect fail "missing tag fails" "does not exist" -- "$SCRIPT" v9.9.9 "$WORK/signers" +expect fail "missing signers file fails" "not found" -- "$SCRIPT" v1.0.0 "$WORK/nonexistent" +expect fail "signed tag whose internal name differs from its ref fails" "name mismatch" -- "$SCRIPT" v1.1.0 "$WORK/signers" +expect fail "correct tag with the wrong expected commit fails" "expected commit is $OTHER_COMMIT" -- "$SCRIPT" v1.0.0 "$WORK/signers" "$OTHER_COMMIT" +expect pass "correct tag with the right expected commit passes" "commit $COMMIT" -- "$SCRIPT" v1.0.0 "$WORK/signers" "$COMMIT" +expect fail "abbreviated expected commit is refused" "full 40 or 64" -- "$SCRIPT" v1.0.0 "$WORK/signers" "${COMMIT:0:12}" +expect fail "non-hex expected commit is refused" "full hexadecimal" -- "$SCRIPT" v1.0.0 "$WORK/signers" "HEAD" +expect fail "unknown full expected commit is refused" "not a commit" -- "$SCRIPT" v1.0.0 "$WORK/signers" "0123456789012345678901234567890123456789" +expect fail "signed tag of a tree fails" "tree, not directly at a commit" -- "$SCRIPT" v2.0.0 "$WORK/signers" +expect fail "signed tag of another tag fails" "tag, not directly at a commit" -- "$SCRIPT" v2.0.1 "$WORK/signers" +# Default path: .github/allowed_signers at the repository root. +mkdir -p .github +cp "$WORK/signers" .github/allowed_signers +expect pass "default signers file is .github/allowed_signers" "signed by $PRINCIPAL" -- "$SCRIPT" v1.0.0 +expect pass "empty signers argument keeps the default with an expected commit" "commit $COMMIT" -- "$SCRIPT" v1.0.0 "" "$COMMIT" + +if [ "$failures" -ne 0 ]; then + echo "$failures verify-release-tag test(s) failed" >&2 + exit 1 +fi +echo "verify-release-tag tests passed" diff --git a/tests/test_access_security.py b/tests/test_access_security.py index 5cabf27..28a2382 100644 --- a/tests/test_access_security.py +++ b/tests/test_access_security.py @@ -204,6 +204,94 @@ def test_newline_cr_nul_values_are_rejected_without_writing(self): self.assertFalse(self.env_file.exists() and "8765" in self.env_file.read_text()) + def test_settings_post_requires_the_access_code_like_get(self): + os.environ["BOAT_CHAT_ACCESS_TOKEN"] = "right-token" + body = {"settings": {"BOAT_CHAT_PROVIDER": "ollama"}} + # Loopback with no settings token passes the admin check, but not the access check. + self.assertEqual(401, self.call("POST", "/api/settings", body)[0]) + self.assertEqual(401, self.call("POST", "/api/settings", body, token="wrong")[0]) + self.assertFalse(self.env_file.exists() and "ollama" in self.env_file.read_text()) + self.assertEqual(200, self.call("POST", "/api/settings", body, token="right-token")[0]) + self.assertIn("ollama", self.env_file.read_text()) + + def test_settings_post_needs_both_codes_when_they_differ(self): + os.environ["BOAT_CHAT_ACCESS_TOKEN"] = "right-token" + os.environ["BOAT_CHAT_SETTINGS_TOKEN"] = "settings-secret" + data = json.dumps({"settings": {"BOAT_CHAT_PROVIDER": "ollama"}}).encode() + + def post(**headers): + request = urllib.request.Request(f"http://127.0.0.1:{self.server.server_port}/api/settings", data=data, method="POST", + headers={"Content-Type": "application/json", **headers}) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status + except urllib.error.HTTPError as error: + return error.code + self.assertEqual(401, post(**{"X-Boat-Chat-Token": "settings-secret"})) + self.assertEqual(403, post(**{"X-Boat-Chat-Token": "right-token"})) + self.assertEqual(401, post(**{"X-Boat-Chat-Token": "settings-secret", "X-Boat-Chat-Access-Token": "wrong"})) + self.assertEqual(200, post(**{"X-Boat-Chat-Token": "settings-secret", "X-Boat-Chat-Access-Token": "right-token"})) + + def test_access_code_cannot_be_cleared_unless_open_mode(self): + os.environ["BOAT_CHAT_ACCESS_TOKEN"] = "right-token" + app.update_settings({"BOAT_CHAT_ACCESS_TOKEN": "right-token", "BOAT_CHAT_PROVIDER": "ollama"}) + before = self.env_file.read_text() + for bad in (None, "", " "): + status, body = self.call("POST", "/api/settings", {"settings": {"BOAT_CHAT_ACCESS_TOKEN": bad}}, token="right-token") + self.assertEqual(400, status, repr(bad)) + self.assertEqual("The access code cannot be cleared; set a new one instead.", body["error"]) + self.assertEqual(before, self.env_file.read_text()) + self.assertEqual(200, self.call("POST", "/api/settings", {"settings": {"BOAT_CHAT_ACCESS_TOKEN": "new-token"}}, token="right-token")[0]) + os.environ["BOAT_CHAT_ALLOW_OPEN"] = "true" + app.update_settings({"BOAT_CHAT_ACCESS_TOKEN": None}) + + def test_settings_get_and_head_accept_the_access_code_header(self): + os.environ["BOAT_CHAT_ACCESS_TOKEN"] = "right-token" + os.environ["BOAT_CHAT_SETTINGS_TOKEN"] = "settings-secret" + + def send(method, **headers): + request = urllib.request.Request(f"http://127.0.0.1:{self.server.server_port}/api/settings", method=method, headers=headers) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status + except urllib.error.HTTPError as error: + return error.code + for method in ("GET", "HEAD"): + self.assertEqual(401, send(method), method) + self.assertEqual(401, send(method, **{"X-Boat-Chat-Access-Token": "wrong"}), method) + self.assertEqual(200, send(method, **{"X-Boat-Chat-Access-Token": "right-token"}), method) + + def test_blank_required_settings_are_rejected_without_writing(self): + os.environ["BOAT_CHAT_ALLOW_OPEN"] = "true" + app.update_settings({"BOAT_CHAT_PORT": "9000", "BOAT_CHAT_MAX_TOKENS": "800", "BOAT_CHAT_MODEL": "m1"}) + before = self.env_file.read_text() + for key in ("BOAT_CHAT_HOST", "BOAT_CHAT_PORT", "BOAT_CHAT_MAX_TOKENS", "BOAT_CHAT_CONTEXT_CHARS", "BOAT_CHAT_PROVIDER", "BOAT_CHAT_MODEL"): + status, body = self.call("POST", "/api/settings", {"settings": {"BOAT_CHAT_FALLBACK_PROVIDER": "ollama", key: ""}}) + self.assertEqual(400, status, key) + self.assertIn(key, body["error"]) + self.assertEqual(before, self.env_file.read_text(), key) + # The exact wipe the stale Settings dialog used to send. + wipe = {k: "" for k in ("BOAT_CHAT_HOST", "BOAT_CHAT_PORT", "BOAT_CHAT_MAX_TOKENS", "BOAT_CHAT_MODEL", "BOAT_CHAT_FALLBACK_MODEL")} + self.assertEqual(400, self.call("POST", "/api/settings", {"settings": wipe})[0]) + self.assertEqual(before, self.env_file.read_text()) + for bad in ("abc", "0", "70000"): + with self.assertRaises(ValueError): + app.update_settings({"BOAT_CHAT_PORT": bad}) + self.assertEqual(before, self.env_file.read_text()) + + def test_explicit_clear_and_blank_secret_semantics_are_kept(self): + os.environ["BOAT_CHAT_ALLOW_OPEN"] = "true" + app.update_settings({"BOAT_CHAT_MODEL": "m1", "BOAT_CHAT_FALLBACK_MODEL": "f1", "OPENAI_API_KEY": "sk-x"}) + app.update_settings({"BOAT_CHAT_MODEL": None, "BOAT_CHAT_FALLBACK_MODEL": None, "OPENAI_API_KEY": ""}) + saved = env_config.parse_env_file(self.env_file, app.SETTING_KEYS) + self.assertEqual("", saved["BOAT_CHAT_MODEL"]) + self.assertEqual("", saved["BOAT_CHAT_FALLBACK_MODEL"]) + self.assertEqual("sk-x", saved["OPENAI_API_KEY"]) + # An explicit None still means "clear", also for required keys (reset to default). + app.update_settings({"BOAT_CHAT_PORT": None}) + self.assertEqual("", env_config.parse_env_file(self.env_file, app.SETTING_KEYS)["BOAT_CHAT_PORT"]) + + class PrecedenceTests(unittest.TestCase): def test_single_env_variable_selects_the_file_for_both_services(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_control_panel.py b/tests/test_control_panel.py index 0a63caf..c815e89 100644 --- a/tests/test_control_panel.py +++ b/tests/test_control_panel.py @@ -232,6 +232,19 @@ def test_displays_bridge_is_opt_in_and_token_is_secret(self) -> None: self.assertEqual(panel.FIELD_BY_KEY["DISPLAYS_HA_TOKEN"]["kind"], "secret") self.assertEqual(panel.FIELD_BY_KEY["DISPLAYS_HA_TOKEN"]["section"], "Displays") + def test_signal_k_naming_units_choices_and_help_text(self) -> None: + sections = {item["section"] for item in panel.FIELDS} + self.assertIn("Signal K & NMEA 2000", sections) + self.assertNotIn("SignalK", sections) + for item in panel.FIELDS: + self.assertNotIn("SignalK", item["label"], item["key"]) + self.assertNotIn("SignalK", item["description"], item["key"]) + self.assertEqual(panel.FIELD_BY_KEY["BOAT_UNITS"]["choices"], ["us_customary", "metric"]) + self.assertEqual(panel.FIELD_BY_KEY["SIGNALK_MODE"]["choices"], ["existing", "docker", "native"]) + for key in ("SIGNALK_MODE", "SOCKETCAN_BITRATE", "AIS_DEVICE", "BOAT_TYPE", "BOAT_TIMEZONE"): + self.assertTrue(panel.FIELD_BY_KEY[key]["description"], key) + self.assertEqual(panel.FIELD_BY_KEY["BOAT_TIMEZONE"]["label"], "Timezone") + def test_displays_values_mirror_the_installer_checks(self) -> None: user = panel.FIELD_BY_KEY["DISPLAYS_BRIDGE_MQTT_USERNAME"] for good in ("vesselstack-bridge", "a", "A_b.c-9", "x" * 64, ""): @@ -508,6 +521,145 @@ def test_handler_authentication_requires_exact_token(self) -> None: # A non-ASCII header value is a plain mismatch, not a TypeError. handler.headers = {"X-VesselStack-Token": "t\u00f6ken"} self.assertFalse(handler.authenticated()) + # A pasted CONTROL_PANEL_TOKEN=... line, with or without quotes, is the same token. + for accepted in ("CONTROL_PANEL_TOKEN=test-panel-token", " CONTROL_PANEL_TOKEN = test-panel-token \n", + "CONTROL_PANEL_TOKEN='test-panel-token'", "export CONTROL_PANEL_TOKEN=\"test-panel-token\"", " test-panel-token "): + handler.headers = {"X-VesselStack-Token": accepted} + self.assertTrue(handler.authenticated(), accepted) + for refused in ("CONTROL_PANEL_TOKEN=", "CONTROL_PANEL_TOKEN=wrong", "CONTROL_PANEL_TOKEN=test-panel-token-x", ""): + handler.headers = {"X-VesselStack-Token": refused} + self.assertFalse(handler.authenticated(), refused) + + def test_normalize_token_strips_prefix_whitespace_and_quotes(self) -> None: + self.assertEqual(panel.normalize_token("CONTROL_PANEL_TOKEN=abc"), "abc") + self.assertEqual(panel.normalize_token(" abc "), "abc") + self.assertEqual(panel.normalize_token("CONTROL_PANEL_TOKEN='a b'"), "a b") + self.assertEqual(panel.normalize_token("abc=def"), "abc=def") + + +class SettingsValidationTests(unittest.TestCase): + def check(self, key: str, value: str) -> str: + return panel.validate_value(panel.FIELD_BY_KEY[key], value) + + def test_timezone_must_exist(self) -> None: + for good in ("Europe/Lisbon", "America/Los_Angeles", "UTC", ""): + self.assertEqual(self.check("BOAT_TIMEZONE", good), good) + for bad in ("Mars/Olympus", "europe/lisbon", "Europe/Lisbn", "../etc/passwd", "Lisbon"): + with self.assertRaisesRegex(ValueError, "Timezone not recognised"): + self.check("BOAT_TIMEZONE", bad) + + def test_timezone_is_not_blocked_without_a_timezone_database(self) -> None: + with mock.patch.object(panel.zoneinfo, "available_timezones", return_value=set()): + self.assertEqual(self.check("BOAT_TIMEZONE", "Mars/Olympus"), "Mars/Olympus") + + def test_boat_size_ranges(self) -> None: + for key, good, bad in ( + ("BOAT_LOA_M", ("13.2", "0.5", "500", ""), ("0", "-5", "500.1", "abc", "nan", "inf", "1e3", "1_0", "12 m", "13,2")), + ("BOAT_BEAM_M", ("4.1", "100", ""), ("0", "-1", "100.5", "1e400", "-0")), + ): + for value in good: + self.assertEqual(self.check(key, value), value) + for value in bad: + with self.assertRaises(ValueError, msg=f"{key}={value}"): + self.check(key, value) + + def test_can_bitrate_is_a_sensible_whole_number(self) -> None: + for good in ("250000", "10000", "1000000", ""): + self.assertEqual(self.check("SOCKETCAN_BITRATE", good), good) + for bad in ("0", "9999", "1000001", "250000.5", "-250000", "fast"): + with self.assertRaises(ValueError): + self.check("SOCKETCAN_BITRATE", bad) + + def test_errors_are_reported_per_field_and_nothing_is_written(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + vessel, chat, control = root / "vessel.env", root / "chat.env", root / "control.env" + panel.write_env(vessel, {"BOAT_NAME": "Old", "BOAT_TIMEZONE": "Mars/Olympus"}, []) + panel.write_env(chat, {}, []) + panel.write_env(control, {"CONTROL_PANEL_TOKEN": "t"}, []) + before = vessel.read_text() + with mock.patch.multiple(panel, VESSEL_ENV=vessel, CHAT_ENV=chat, PANEL_ENV=control, CONFIG_BACKUP_ROOT=root / "backups"): + with self.assertRaises(panel.FieldErrors) as caught: + panel._update_configuration({"BOAT_NAME": "New", "BOAT_TIMEZONE": "Mars/Phobos", "BOAT_LOA_M": "-5"}) + self.assertEqual(set(caught.exception.fields), {"BOAT_TIMEZONE", "BOAT_LOA_M"}) + self.assertEqual(vessel.read_text(), before) + # A stale invalid stored value never blocks saving an unrelated setting. + panel._update_configuration({"BOAT_NAME": "New"}) + self.assertEqual(panel.parse_env(vessel)["BOAT_NAME"], "New") + + def test_every_setting_has_plain_help_and_no_markdown(self) -> None: + for item in panel.FIELDS: + self.assertTrue(item["description"], item["key"]) + self.assertNotIn("`", item["description"], item["key"]) + self.assertNotIn("`", item["label"], item["key"]) + names = {section["name"] for section in panel.SECTIONS} + self.assertEqual({item["section"] for item in panel.FIELDS}, names) + + +class StatusFieldTests(unittest.TestCase): + def test_components_use_real_names_and_one_plain_status(self) -> None: + expected = {"signalk": "Signal K", "ais-catcher": "AIS-catcher", "socketcan": "SocketCAN", "node-exporter": "Node Exporter", + "homeassistant": "Home Assistant", "influxdb": "InfluxDB", "mosquitto": "Mosquitto", "boat-chat": "Boat Chat", + "telegram": "Telegram alerts", "prometheus": "Prometheus", "heimdall": "Heimdall", "displays-bridge": "Displays bridge"} + for name, label in expected.items(): + self.assertEqual(panel.describe_component(name, "container", True, "running")["label"], label) + self.assertEqual(panel.describe_component("grafana", "container", True, "running")["status"], "running") + self.assertEqual(panel.describe_component("grafana", "container", True, "exited")["status"], "stopped") + self.assertEqual(panel.describe_component("grafana", "container", True, "unknown")["status"], "unknown") + failed = panel.describe_component("boat-chat", "systemd", True, "failed") + self.assertEqual((failed["status"], failed["detail"]), ("stopped", "failed")) + # Turned off in settings but still running: Disabled, never "Active and Available". + leftover = panel.describe_component("telegram", "systemd", False, "active") + self.assertEqual((leftover["status"], leftover["still_running"]), ("disabled", True)) + self.assertEqual(panel.describe_component("telegram", "systemd", False, "inactive")["status"], "disabled") + for name in list(panel.COMPOSE_COMPONENTS) + list(panel.SYSTEMD_COMPONENTS): + info = panel.describe_component(name, "container", True, "running") + self.assertTrue(info["label"] and info["label"] == info["label"].strip(), name) + self.assertNotIn(info["label"], {"Signalk", "Ais Catcher", "Socketcan", "Node Exporter ", "Boat Chat ", "Displays Bridge"}, name) + + def test_backup_status_reads_newest_checksummed_archive_without_writing(self) -> None: + with tempfile.TemporaryDirectory() as directory: + backup = Path(directory) / "backups" + backup.mkdir() + old, new, orphan = backup / "vesselstack-20260101T000000Z.tar.gz", backup / "vesselstack-20260201T000000Z.tar.gz", backup / "vesselstack-20260301T000000Z.tar.gz" + for archive, when in ((old, 1_700_000_000), (new, 1_750_000_000), (orphan, 1_790_000_000)): + archive.write_bytes(b"x") + os.utime(archive, (when, when)) + (backup / (old.name + ".sha256")).write_text("x") + (backup / (new.name + ".sha256")).write_text("x") + status = panel.backup_status({"VESSELSTACK_BACKUP": str(backup)}) + self.assertEqual(status["last_backup_at"], 1_750_000_000) + self.assertEqual(status["mount"], "system_disk") + self.assertEqual(status["state"], "ok") + self.assertEqual({path.name for path in backup.iterdir()}, {old.name, new.name, orphan.name, old.name + ".sha256", new.name + ".sha256"}) + + def test_backup_status_unknown_or_missing_cases(self) -> None: + with tempfile.TemporaryDirectory() as directory: + self.assertEqual(panel.backup_status({"VESSELSTACK_BACKUP": str(Path(directory) / "none")})["state"], "none") + empty = Path(directory) / "empty" + empty.mkdir() + self.assertIsNone(panel.backup_status({"VESSELSTACK_BACKUP": str(empty)})["last_backup_at"]) + with mock.patch.object(panel.os.path, "ismount", return_value=False): + unmounted = panel.backup_status({"VESSELSTACK_BACKUP": "/media/backup-drive/vesselstack"}) + self.assertEqual((unmounted["mount"], unmounted["state"], unmounted["last_backup_at"]), ("not_mounted", "unknown", None)) + self.assertIn("not mounted", unmounted["message"]) + with tempfile.TemporaryDirectory() as directory: + with mock.patch.object(panel.os, "scandir", side_effect=PermissionError("denied")): + denied = panel.backup_status({"VESSELSTACK_BACKUP": directory}) + self.assertEqual((denied["state"], denied["last_backup_at"]), ("unknown", None)) + + def test_operation_summary_names_the_saved_file_or_the_reason(self) -> None: + ok = panel.summarize_operation({"label": "backup", "state": "succeeded", "output": ["copying", "/var/backups/vesselstack/vesselstack-20260101T000000Z.tar.gz"]}) + self.assertEqual(ok["saved_path"], "/var/backups/vesselstack/vesselstack-20260101T000000Z.tar.gz") + bad = panel.summarize_operation({"label": "backup", "state": "failed", "output": ["ctl: running backup", "ERROR: backup mount /media/backup is not mounted"]}) + self.assertEqual(bad["reason"], "backup mount /media/backup is not mounted") + self.assertEqual(panel.with_summary({"state": "running"}), {"state": "running"}) + + def test_restart_note_only_when_a_newer_panel_is_installed(self) -> None: + with mock.patch.object(panel, "installed_version", return_value="0.6.1"), mock.patch.object(panel, "RUNNING_IDENTITY", {"running_version": "0.6.0"}): + self.assertTrue(panel.restart_needed()) + with mock.patch.object(panel, "installed_version", return_value="0.6.0"), mock.patch.object(panel, "RUNNING_IDENTITY", {"running_version": "0.6.0"}): + self.assertFalse(panel.restart_needed()) if __name__ == "__main__": diff --git a/tests/test_control_panel_ui.cjs b/tests/test_control_panel_ui.cjs index c2482aa..ecc33c0 100644 --- a/tests/test_control_panel_ui.cjs +++ b/tests/test_control_panel_ui.cjs @@ -2,98 +2,300 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); -const source = fs.readFileSync('control-panel/static/app.js', 'utf8') + '\nthis.__ui = { state, statusClass, isConfigDirty, currentSettings, lock, setBusy, renderComponents, checkReleases, startOperation, renderOperation, operationSucceeded, refreshStatus, renderReleases, updateReleaseControls };'; +const appSource = fs.readFileSync('control-panel/static/app.js', 'utf8'); +const html = fs.readFileSync('control-panel/static/index.html', 'utf8'); +const css = fs.readFileSync('control-panel/static/styles.css', 'utf8'); +const source = appSource + '\nthis.__ui = { state, normalizeToken, isConfigDirty, dirtyKeys, currentSettings, lock, setBusy, renderComponents, checkReleases, startOperation, renderOperation, operationSucceeded, refreshStatus, renderReleases, runComponent, runAction, installRelease, discardEdits, toast, fieldMarkup, optionLabel, updateConfigStatus, renderBand, renderUpdates, requireReauth, CONFIRMS, describeOperation, setNotice, showFieldErrors, renderConfiguration, selectTab };'; + const nodes = new Map(); -const node = (id) => nodes.get(id) || nodes.set(id, { value: '', checked: false, disabled: false, textContent: '', hidden: false, className: '', innerHTML: '', classList: { toggle() {} }, addEventListener() {}, querySelector() { return null; }, querySelectorAll() { return []; } }).get(id); +const makeNode = () => ({ + value: '', checked: false, disabled: false, textContent: '', hidden: false, className: '', innerHTML: '', dataset: {}, open: false, + classList: { toggle() {}, add() {}, remove() {}, contains: () => false }, + addEventListener() {}, setAttribute() {}, removeAttribute() {}, closest: () => null, focus() {}, scrollIntoView() {}, + querySelector() { return null; }, querySelectorAll() { return []; }, isConnected: true, +}); +const node = (id) => nodes.get(id) || nodes.set(id, makeNode()).get(id); const fields = {}; const formControls = []; -let confirmCalls = 0; +const clearBoxes = []; +const dialogTitles = []; +let dialogAnswer = 'ok'; +const dialog = node('#confirm-dialog'); +dialog.showModal = function showModal() { + dialogTitles.push({ title: node('#confirm-title').textContent, intro: node('#confirm-intro').textContent, items: node('#confirm-items').innerHTML, ok: node('#confirm-ok').textContent }); + this.open = true; + this.returnValue = dialogAnswer; + this.open = false; + if (this.onclose) this.onclose(); +}; +dialog.close = function close(value) { this.returnValue = value; }; let fetchImpl = async () => ({ ok: true, json: async () => ({ configuration: context.__ui.state.config }) }); const context = { - console, setTimeout: () => 0, clearTimeout() {}, confirm: () => { confirmCalls += 1; return true; }, + console, setTimeout: () => 0, clearTimeout() {}, sessionStorage: { values: {}, getItem(key) { return this.values[key] || ''; }, setItem(key, value) { this.values[key] = value; }, removeItem(key) { delete this.values[key]; } }, - window: { addEventListener() {}, confirm: () => { confirmCalls += 1; return true; } }, - document: { querySelector: (selector) => node(selector), querySelectorAll: (selector) => selector.includes('#config-form') ? formControls : [] }, - FormData: class { constructor() {} entries() { return Object.entries(fields).filter(([key]) => !formControls.some((control) => control.name === key && control.disabled)); } }, - URL, + window: { addEventListener() {}, confirm: () => { throw new Error('window.confirm must not be used'); }, location: { reload() {} } }, + document: { + hidden: false, activeElement: { dataset: {} }, addEventListener() {}, + querySelector: (selector) => node(selector), + querySelectorAll: (selector) => selector.includes('#config-form') ? formControls : selector.includes('data-clear-secret') ? clearBoxes : [], + }, + URL, Date, fetch: (...args) => fetchImpl(...args), }; vm.createContext(context); vm.runInContext(source, context); const ui = context.__ui; +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const ok = (payload) => ({ ok: true, json: async () => payload }); +const control = (name, value, extra = {}) => { const item = { name, value, type: 'text', disabled: false, dataset: { readOnly: 'false' }, ...extra }; formControls.push(item); return item; }; + +// ---- static structure ------------------------------------------------------- +assert.doesNotMatch(appSource, /\bconfirm\s*\(/, 'no window.confirm (or any confirm call) is left in app.js'); +assert.doesNotMatch(appSource, /\balert\s*\(|\bprompt\s*\(/, 'no blocking native dialogs'); +assert.equal((html.match(/data-action="apply"/g) || []).length, 1, 'exactly one Apply control'); +assert.doesNotMatch(html, /Apply configuration|Apply now/, 'the duplicate Apply card and old label are gone'); +assert.doesNotMatch(html, /Install & start|Run preflight|Create backup|Your boat computer,/, 'old hero is gone'); +assert.match(html, /Reinstall & start/); assert.match(html, /Check setup/); +['Home', 'Settings', 'Updates', 'Maintenance'].forEach((name) => assert.match(html, new RegExp(`role="tab"[^>]*>${name}`))); +assert.equal((html.match(/role="tab"/g) || []).length, 4); +assert.match(html, / { - ui.state.token = 'async-token'; ui.state.busy = false; ui.state.operationRunning = false; - node('#include-lab').checked = true; + const posts = []; + fetchImpl = async (path, options = {}) => { + if (options.method === 'POST') { posts.push({ path, body: JSON.parse(options.body) }); return ok({ id: 1, label: 'x', state: 'running', output: [] }); } + return ok({ components: [], operation: { state: 'idle' }, installed_version: '0.6.0', running_version: '0.6.0' }); + }; + ui.state.token = 'test-token'; + ui.state.components = [{ id: 'grafana', label: 'Grafana', status: 'running' }, { id: 'signalk', label: 'Signal K', status: 'running' }]; + ui.state.installedVersion = '0.6.0'; + const askedFor = async (run) => { dialogTitles.length = 0; dialogAnswer = 'cancel'; await run(); await tick(); return dialogTitles.slice(); }; + for (const [action, title] of [['stop', 'Stop all services?'], ['restart', 'Restart all services?'], ['start', 'Start all services?'], ['install', 'Reinstall & start?'], ['apply', 'Apply saved settings?'], ['restart-panel', 'Restart the Control Panel?']]) { + const asked = await askedFor(() => ui.runAction(action)); + assert.equal(asked.length, 1, `${action} asks first`); + assert.equal(asked[0].title, title); + assert.equal(posts.length, 0, `cancelling ${action} starts nothing`); + } + assert.match((await askedFor(() => ui.runAction('stop')))[0].items, /Home Assistant and Grafana[\s\S]*Signal K[\s\S]*Boat Chat[\s\S]*Displays/, 'Stop all lists what goes dark'); + assert.match((await askedFor(() => ui.runAction('install')))[0].items, /Live data pauses[\s\S]*Unsaved edits[\s\S]*not touched/, 'Reinstall lists its effects'); + const reviewed = await askedFor(() => ui.runAction('update', { source_directory: '/var/lib/vesselstack/releases/x' })); + assert.match(reviewed[0].title, /update from this folder/); assert.match(reviewed[0].intro, /\/var\/lib\/vesselstack\/releases\/x/); + for (const [verb, pattern] of [['stop', /^Stop Signal K\?$/], ['restart', /^Restart Signal K\?$/], ['remove', /^Disable Signal K\?$/]]) { + const asked = await askedFor(() => ui.runComponent('signalk', verb)); + assert.equal(asked.length, 1, `component ${verb} asks first`); assert.match(asked[0].title, pattern); + } + assert.match((await askedFor(() => ui.runComponent('signalk', 'remove')))[0].intro, /stay off, including after a restart or Start all/); + ui.state.releases = [{ id: 9, tag: 'v0.6.1', version: '0.6.1', name: 'Fixes', html_url: 'https://github.com/jclima/VesselStack/releases/tag/v0.6.1' }]; + const install = await askedFor(() => ui.installRelease(9)); + assert.equal(install[0].title, 'Back up & install 0.6.1?'); assert.equal(posts.length, 0); + // Edits are never discarded and the panel never locks without asking. + formControls[0].value = 'Edited'; ui.state.config = { fields: [{ key: 'BOAT_NAME', kind: 'text', read_only: false }], values: { BOAT_NAME: 'Original' } }; + assert.equal((await askedFor(() => ui.discardEdits()))[0].title, 'Discard your edits?'); + const locked = await askedFor(() => ui.lock()); + assert.equal(locked[0].title, 'Lock the panel?'); assert.equal(ui.state.token, 'test-token', 'cancelling the lock keeps the session'); + formControls[0].value = 'Original'; + // Starting everything, restarting one component and the safe actions. + for (const action of ['backup', 'preflight', 'bootstrap-history']) { + ui.state.busy = false; ui.state.pendingOperation = null; + assert.equal((await askedFor(() => ui.runAction(action))).length, 0, `${action} is not interrupting and needs no dialog`); + assert.equal(posts.at(-1).body.action, action); + } + dialogAnswer = 'ok'; + ui.state.busy = false; ui.state.pendingOperation = null; posts.length = 0; + await ui.runAction('stop'); await tick(); + assert.deepEqual(posts[0], { path: '/api/action', body: { action: 'stop' } }, 'confirming Stop all posts the same action id as before'); + ui.state.busy = false; ui.state.pendingOperation = null; + await ui.runAction('install'); await tick(); + assert.equal(posts.at(-1).body.action, 'install', 'Reinstall & start keeps its install action id'); + ui.state.busy = false; ui.state.pendingOperation = null; + await ui.runAction('preflight'); await tick(); + assert.equal(posts.at(-1).body.action, 'preflight', 'Check setup keeps its preflight action id'); + ui.state.busy = false; ui.state.pendingOperation = null; + await ui.runComponent('grafana', 'remove'); await tick(); + assert.deepEqual(posts.at(-1), { path: '/api/component', body: { component: 'grafana', action: 'remove' } }); + assert.equal(Object.keys(ui.CONFIRMS).sort().join(), ['apply', 'install', 'restart', 'restart-panel', 'start', 'stop'].join()); + ui.state.pendingOperation = null; ui.state.busy = false; + + // ---- components: names, statuses, menu, configure ----------------------- + const comps = [ + { id: 'grafana', label: 'Grafana', description: 'Charts', section: 'Services & ports', manageable: true, enabled: true, state: 'running', status: 'running' }, + { id: 'heimdall', label: 'Heimdall', description: 'Start page', section: 'Services & ports', manageable: true, enabled: true, state: 'exited', status: 'stopped' }, + { id: 'telegram', label: 'Telegram alerts', description: 'Alerts', section: 'Boat Chat', manageable: false, enabled: false, state: 'active', status: 'disabled', still_running: true }, + { id: 'socketcan', label: 'SocketCAN', description: 'CAN', section: 'Signal K & NMEA 2000', manageable: false, enabled: false, state: 'inactive', status: 'disabled' }, + { id: 'ais-catcher', label: 'AIS-catcher', description: 'AIS', section: 'AIS receiver', manageable: false, enabled: true, state: 'unknown', status: 'unknown' }, + { id: 'control-panel', label: 'Control Panel', status: 'running' }, + ]; + ui.renderComponents(comps, true); + const grid = node('#component-grid').innerHTML; + assert.match(grid, /

      AIS-catcher<\/h3>/); assert.match(grid, /

      Telegram alerts<\/h3>/); assert.match(grid, /

      SocketCAN<\/h3>/); + assert.doesNotMatch(grid, /Control Panel<\/h3>/, 'the panel does not list itself'); + assert.doesNotMatch(grid, /Active|Available|Ais Catcher|Socketcan/, 'only Running, Stopped and Disabled words, real names'); + assert.match(grid, /status running">Running/); assert.match(grid, /status stopped">Stopped/); assert.match(grid, /status disabled">Disabled/); assert.match(grid, /status unknown">Unknown/); + assert.match(grid, /data-configure-section="Services & ports"/, 'Configure link where there are settings'); + assert.match(grid, /Turn on in Settings/, 'a config-driven disabled component points at Settings'); + assert.match(grid, /Still running although it is turned off/); + assert.doesNotMatch(grid, /data-verb="remove"[^>]*>Remove/, 'no Remove button, only the menu'); + assert.match(grid, /aria-label="More actions for Grafana"/); + assert.doesNotMatch(grid, /aria-label="More actions for (Telegram|SocketCAN|AIS)/, 'only core services can be disabled'); + assert.match(node('#component-note').textContent, /^5 components: 1 running, 1 stopped, 2 disabled, 1 unknown/, 'count note matches what is shown'); + assert.match(node('#band').innerHTML, /1 of 3 stopped/); + ui.state.menuOpen = 'grafana'; ui.renderComponents(comps, true); + assert.match(node('#component-grid').innerHTML, /removes its container and marks it disabled/, 'the menu explains what Disable does'); + ui.state.menuOpen = null; + ui.renderComponents([{ id: 'grafana', label: 'Grafana', manageable: true, enabled: false, state: 'running', status: 'disabled', still_running: true }], true); + assert.match(node('#component-grid').innerHTML, /Retry disabling/, 'disabled but still running core component offers a retry'); + ui.renderComponents([{ id: 'grafana', label: 'Grafana', manageable: true, enabled: false, state: 'stopped', status: 'disabled' }], true); + assert.doesNotMatch(node('#component-grid').innerHTML, /Retry disabling/); assert.match(node('#component-grid').innerHTML, />Enable — backup mount \/media\/backup is not mounted\. Plug in or mount the backup drive and try again\./); + assert.match(node('#banner').innerHTML, /
      Show details<\/summary>
      ]*>ctl: running backup\nERROR/, 'details hold the operation log');
      +  assert.doesNotMatch(node('#banner').innerHTML, /Update failed/, 'no update wording for a backup');
      +  assert.match(node('#banner').innerHTML, /aria-label="Dismiss message"/);
      +  ui.renderOperation({ id: 4, label: 'backup', state: 'succeeded', started_at: now, finished_at: now, output: ['x'], summary: { saved_path: '/media/backup/vesselstack-1.tar.gz' } });
      +  assert.match(node('#banner').innerHTML, /Backup finished [^<]*<\/strong> — saved to \/media\/backup\/vesselstack-1\.tar\.gz<\/span>/);
      +  assert.doesNotMatch(node('#banner').innerHTML, /restart vesselstack-control-panel/, 'no routine restart advice on success');
      +  ui.renderOperation({ id: 5, label: 'stop grafana', state: 'running', started_at: now, output: [] });
      +  assert.match(node('#banner').innerHTML, /Stopping Grafana…/); assert.equal(ui.state.busy, true);
      +  ui.renderOperation({ id: 5, label: 'stop grafana', state: 'succeeded', started_at: now, finished_at: now, output: [] });
      +  assert.match(node('#banner').innerHTML, /Grafana stopped [^<]*text', read_only: false }, 'existing');
      +  assert.match(selectMarkup, /