From c1c2ba766c32857fae1719f58c6657eda0326ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 24 Oct 2026 10:32:16 +0200 Subject: [PATCH 1/3] fix(encryption): point the TrueNAS key backup step at the catalog app's Encryption Key field The TrueNAS app in the Community train takes the key in its own form field, not in an environment list. The step now names that field and keeps the environment-variable wording for an app installed via YAML. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/nl.json | 2 +- messages/pl.json | 2 +- messages/pt-BR.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/messages/de.json b/messages/de.json index 102a530a2..d1861b150 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8537,7 +8537,7 @@ "composeLabel": "Compose", "composeWhere": "Der Schlüssel ist ENCRYPTION_KEY (oder ENCRYPTION_KEYS) in der .env-Datei neben docker-compose.yml.", "truenasLabel": "TrueNAS", - "truenasWhere": "Öffne Apps, wähle HealthLog, dann Bearbeiten, und kopiere ENCRYPTION_KEY aus den Umgebungsvariablen.", + "truenasWhere": "Öffne Apps, wähle HealthLog, dann Bearbeiten. Der Schlüssel steht im Feld Encryption Key unter HealthLog Configuration; bei einer per YAML installierten App ist es ENCRYPTION_KEY in den Umgebungsvariablen.", "coolifyLabel": "Coolify", "coolifyWhere": "Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY aus den Environment Variables.", "unraidLabel": "Unraid", diff --git a/messages/en.json b/messages/en.json index da5a32d20..17f79ca84 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8537,7 +8537,7 @@ "composeLabel": "Compose", "composeWhere": "The key is ENCRYPTION_KEY (or ENCRYPTION_KEYS) in the .env file next to docker-compose.yml.", "truenasLabel": "TrueNAS", - "truenasWhere": "Open Apps, select HealthLog, choose Edit and copy ENCRYPTION_KEY from the environment settings.", + "truenasWhere": "Open Apps, select HealthLog and choose Edit. The key is the Encryption Key field under HealthLog Configuration; in an app installed via YAML, it is ENCRYPTION_KEY in the environment settings.", "coolifyLabel": "Coolify", "coolifyWhere": "Open the application in Coolify and copy ENCRYPTION_KEY from its Environment Variables.", "unraidLabel": "Unraid", diff --git a/messages/es.json b/messages/es.json index 4e55bf123..acb1a5a8c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8537,7 +8537,7 @@ "composeLabel": "Compose", "composeWhere": "La clave es ENCRYPTION_KEY (o ENCRYPTION_KEYS) en el archivo .env junto a docker-compose.yml.", "truenasLabel": "TrueNAS", - "truenasWhere": "Abre Apps, selecciona HealthLog, elige Editar y copia ENCRYPTION_KEY de las variables de entorno.", + "truenasWhere": "Abre Apps, selecciona HealthLog y elige Editar. La clave está en el campo Encryption Key de HealthLog Configuration; en una app instalada con YAML, es ENCRYPTION_KEY en las variables de entorno.", "coolifyLabel": "Coolify", "coolifyWhere": "Abre la aplicación en Coolify y copia ENCRYPTION_KEY de sus Environment Variables.", "unraidLabel": "Unraid", diff --git a/messages/fr.json b/messages/fr.json index 0d1b503e9..aa584be6e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8537,7 +8537,7 @@ "composeLabel": "Compose", "composeWhere": "La clé est ENCRYPTION_KEY (ou ENCRYPTION_KEYS) dans le fichier .env à côté de docker-compose.yml.", "truenasLabel": "TrueNAS", - "truenasWhere": "Ouvre Apps, sélectionne HealthLog, choisis Modifier et copie ENCRYPTION_KEY dans les variables d'environnement.", + "truenasWhere": "Ouvre Apps, sélectionne HealthLog et choisis Modifier. La clé se trouve dans le champ Encryption Key sous HealthLog Configuration ; pour une app installée via YAML, c'est ENCRYPTION_KEY dans les variables d'environnement.", "coolifyLabel": "Coolify", "coolifyWhere": "Ouvre l'application dans Coolify et copie ENCRYPTION_KEY depuis ses Environment Variables.", "unraidLabel": "Unraid", diff --git a/messages/it.json b/messages/it.json index 8067e9aa9..5660f25c6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8537,7 +8537,7 @@ "composeLabel": "Compose", "composeWhere": "La chiave è ENCRYPTION_KEY (o ENCRYPTION_KEYS) nel file .env accanto a docker-compose.yml.", "truenasLabel": "TrueNAS", - "truenasWhere": "Apri Apps, seleziona HealthLog, scegli Modifica e copia ENCRYPTION_KEY dalle variabili d'ambiente.", + "truenasWhere": "Apri Apps, seleziona HealthLog e scegli Modifica. La chiave è nel campo Encryption Key sotto HealthLog Configuration; in un'app installata tramite YAML è ENCRYPTION_KEY nelle variabili d'ambiente.", "coolifyLabel": "Coolify", "coolifyWhere": "Apri l'applicazione in Coolify e copia ENCRYPTION_KEY dalle sue Environment Variables.", "unraidLabel": "Unraid", diff --git a/messages/ko.json b/messages/ko.json index 842d0ab87..f6c3c8010 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8537,7 +8537,7 @@ "composeLabel": "Compose", "composeWhere": "키는 docker-compose.yml 옆 .env 파일의 ENCRYPTION_KEY(또는 ENCRYPTION_KEYS)입니다.", "truenasLabel": "TrueNAS", - "truenasWhere": "Apps에서 HealthLog를 선택하고 Edit을 누른 뒤 환경 설정에서 ENCRYPTION_KEY를 복사하세요.", + "truenasWhere": "Apps에서 HealthLog를 선택하고 Edit을 누르세요. 키는 HealthLog Configuration 아래의 Encryption Key 필드에 있습니다. YAML로 설치한 앱이라면 환경 설정의 ENCRYPTION_KEY입니다.", "coolifyLabel": "Coolify", "coolifyWhere": "Coolify에서 애플리케이션을 열고 Environment Variables에서 ENCRYPTION_KEY를 복사하세요.", "unraidLabel": "Unraid", diff --git a/messages/nl.json b/messages/nl.json index 44163c4a2..3ee41da95 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -8537,7 +8537,7 @@ "composeLabel": "Compose", "composeWhere": "De sleutel is ENCRYPTION_KEY (of ENCRYPTION_KEYS) in het .env-bestand naast docker-compose.yml.", "truenasLabel": "TrueNAS", - "truenasWhere": "Open Apps, selecteer HealthLog, kies Edit en kopieer ENCRYPTION_KEY uit de omgevingsinstellingen.", + "truenasWhere": "Open Apps, selecteer HealthLog en kies Edit. De sleutel staat in het veld Encryption Key onder HealthLog Configuration; bij een app die via YAML is geïnstalleerd, is het ENCRYPTION_KEY in de omgevingsinstellingen.", "coolifyLabel": "Coolify", "coolifyWhere": "Open de applicatie in Coolify en kopieer ENCRYPTION_KEY uit de Environment Variables.", "unraidLabel": "Unraid", diff --git a/messages/pl.json b/messages/pl.json index 5b215e72f..5cd362f44 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8537,7 +8537,7 @@ "composeLabel": "Compose", "composeWhere": "Klucz to ENCRYPTION_KEY (lub ENCRYPTION_KEYS) w pliku .env obok docker-compose.yml.", "truenasLabel": "TrueNAS", - "truenasWhere": "Otwórz Apps, wybierz HealthLog, kliknij Edytuj i skopiuj ENCRYPTION_KEY ze zmiennych środowiskowych.", + "truenasWhere": "Otwórz Apps, wybierz HealthLog i kliknij Edytuj. Klucz znajduje się w polu Encryption Key w sekcji HealthLog Configuration; w aplikacji zainstalowanej przez YAML to ENCRYPTION_KEY w zmiennych środowiskowych.", "coolifyLabel": "Coolify", "coolifyWhere": "Otwórz aplikację w Coolify i skopiuj ENCRYPTION_KEY z jej Environment Variables.", "unraidLabel": "Unraid", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 60977cca7..c0959056c 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -8537,7 +8537,7 @@ "composeLabel": "Compose", "composeWhere": "A chave é ENCRYPTION_KEY (ou ENCRYPTION_KEYS) no arquivo .env ao lado do docker-compose.yml.", "truenasLabel": "TrueNAS", - "truenasWhere": "Abra Apps, selecione HealthLog, escolha Editar e copie ENCRYPTION_KEY das configurações de ambiente.", + "truenasWhere": "Abra Apps, selecione HealthLog e escolha Editar. A chave está no campo Encryption Key em HealthLog Configuration; em um app instalado via YAML, é ENCRYPTION_KEY nas configurações de ambiente.", "coolifyLabel": "Coolify", "coolifyWhere": "Abra o aplicativo no Coolify e copie ENCRYPTION_KEY de suas Variáveis de Ambiente.", "unraidLabel": "Unraid", From 8d9386d73a4f51b57f081ea4b22cb8819dde661b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 24 Oct 2026 10:32:17 +0200 Subject: [PATCH 2/3] docs(truenas): cover the Community train app, its backups, and when the catalog needs a pull request The TrueNAS page now walks through the catalog install field by field, how to generate the two secrets, where TrueNAS keeps the key and why that is not a backup, a dump and restore from the postgres container shell, and updates with forward-only migrations. The listing is marked as pending review, and the install-via-YAML values stay for the time until it is live. The deploy notes list what needs a matching truenas/apps pull request in the same release cycle: a new or stricter required variable, a port or health check change, a Postgres major or extension, and any other compose-shape change. --- README.md | 2 +- docs/ops/deploy.md | 25 ++- docs/self-hosting/getting-started.md | 14 +- docs/self-hosting/truenas.md | 230 ++++++++++++++++++++------- 4 files changed, 199 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 299fa3065..214f00045 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ curl -s http://localhost:3000/api/version # returns version + buildSha + built Persistent state lives in exactly one named volume (`pgdata`, the Postgres data dir); the app container is stateless, so backing up the database backs up everything. -NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md) and [Portainer app templates](docs/self-hosting/portainer/README.md). +NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md), [Portainer app templates](docs/self-hosting/portainer/README.md) and [TrueNAS](docs/self-hosting/truenas.md) (the Community train app is pending catalog review). ## Self-hosting diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 14a790232..046be2905 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -199,11 +199,26 @@ dev --create-only --name vX_Y_Z_rollback_`), edit the SQL, ## Catalog templates -A new required environment variable, or a change to the compose shape, needs a -matching pull request to the TrueNAS apps catalog in the same release cycle -(and an update to `docs/self-hosting/unraid/healthlog.xml` and -`docs/self-hosting/portainer/templates.json`). See -`docs/self-hosting/truenas.md` for what the catalog template carries. +The TrueNAS catalog bot follows new image tags on its own, so a routine +release needs nothing there. These changes do need a matching pull request to +`truenas/apps` (`ix-dev/community/healthlog/`) in the same release cycle, and +an update to `docs/self-hosting/unraid/healthlog.xml` and +`docs/self-hosting/portainer/templates.json`: + +- a new required environment variable, or a new format or minimum for an + existing one (the form validates the secrets); +- a changed listening port, health check path or health semantics, or start + time beyond the template's five-minute start period; +- a required PostgreSQL major version, or an extension the stock `postgres` + image does not ship; +- any other change to the compose shape: a new container, a volume the app + must write to, a different user. + +The catalog app sets `HEALTHLOG_PLATFORM=truenas`, `PORT`, `HOSTNAME`, +`DATABASE_URL`, `ENCRYPTION_KEY`, `API_TOKEN_HMAC_KEY`, `APP_URL`, +`NEXT_PUBLIC_APP_URL` and `SESSION_COOKIE_SECURE`; everything else reaches it +through the form's additional environment variables. See +`docs/self-hosting/truenas.md` for the operator side. ## Verify the served version diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 0541c2333..071f83a1a 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -274,13 +274,13 @@ Encryption: the step shows the key id and a fingerprint (the first 12 hex characters of SHA-256 over the key) and can check a pasted copy without storing it. Admins see a dashboard reminder until the active key is confirmed. -| Platform | Where the key is set | -| --------- | --------------------------------------------------------------- | -| Compose | `.env` next to `docker-compose.yml` | -| TrueNAS | Apps, HealthLog, Edit, Environment | -| Coolify | the application's Environment Variables | -| Unraid | the container template (Docker tab, Edit) or the Compose `.env` | -| Portainer | the stack's Environment variables | +| Platform | Where the key is set | +| --------- | -------------------------------------------------------------------------------------------------- | +| Compose | `.env` next to `docker-compose.yml` | +| TrueNAS | Apps, HealthLog, Edit: the Encryption Key field (`ENCRYPTION_KEY` in a YAML install's environment) | +| Coolify | the application's Environment Variables | +| Unraid | the container template (Docker tab, Edit) or the Compose `.env` | +| Portainer | the stack's Environment variables | Set `HEALTHLOG_PLATFORM` (`compose`, `truenas`, `coolify`, `unraid`, `portainer`) to open the step on your platform's instructions. diff --git a/docs/self-hosting/truenas.md b/docs/self-hosting/truenas.md index 3f7f6abcb..460867eb4 100644 --- a/docs/self-hosting/truenas.md +++ b/docs/self-hosting/truenas.md @@ -1,82 +1,152 @@ -# HealthLog on TrueNAS SCALE +# HealthLog on TrueNAS -HealthLog runs on TrueNAS SCALE as a Docker app: one `app` container from -`ghcr.io/mbombeck/healthlog` and one PostgreSQL 16 database. The app container -is stateless; everything you care about lives in the database dataset and in -one value you must keep yourself: the encryption key. +HealthLog runs on TrueNAS Community Edition (formerly SCALE) as a Docker app: +one container from `ghcr.io/mbombeck/healthlog` and one PostgreSQL database. +The app container is stateless. Everything you care about lives in the +database dataset and in one value you must keep yourself: the encryption key. -## What the app needs +> **Status: pending catalog review.** A HealthLog app for the TrueNAS +> Community train has been prepared but is not listed yet. Until it is, use +> [Install via YAML](#before-the-listing-install-via-yaml) at the end of this +> page. This note goes away once the app appears under Apps, Discover Apps. -| Setting | Value | -| ------------ | ---------------------------------------------------------------------------------------- | -| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) | -| Port | `3000` (HTTP) | -| User | runs as uid `1001`; no extra capabilities, `no-new-privileges` is fine | -| Health check | `GET /api/health`, `200` when ready | -| Start period | at least 60 s, with more retries than the image's 5 × 30 s: migrations run at every boot | -| Database | PostgreSQL 16 (17 and later are not a drop-in), no extensions | -| Volumes | none for the app; the database gets its own dataset | +## Install from the Community train -## Environment +1. Generate two secrets. Open the TrueNAS shell (System, Shell) and run the + command twice, once for each field: -| Variable | Required | Notes | -| ----------------------- | -------- | ------------------------------------------------------------------------------------------- | -| `DATABASE_URL` | yes | `postgresql://:@:5432/` | -| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS (see below). | -| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. | -| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` | -| `APP_URL` | yes | the same address | -| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy | -| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's key backup step opens on the TrueNAS instructions | -| `GEOLITE2_DIR` | no | a mounted directory of GeoLite2 `.mmdb` files, so sign-in locations are resolved on the NAS | + ```bash + openssl rand -hex 32 + ``` -`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app -and runs the background jobs. + Each run prints 64 hex characters. Put both in your password manager + before you continue; see [Back up your encryption key](#back-up-your-encryption-key). + +2. Open Apps, Discover Apps, search for **HealthLog** and choose Install. +3. Fill in the form: + + | Field | What to enter | + | --------------------- | -------------------------------------------------------------------------------------------------------------- | + | Timezone | your timezone | + | Database Password | any strong password; only the two containers use it | + | Encryption Key | the first `openssl rand -hex 32` output | + | Token Signing Key | the second output. Changing it later signs everyone out | + | App URL | the address you open HealthLog at, with scheme and port, for example `http://192.168.1.10:30536` | + | Secure Cookies | leave off when you open HealthLog over `http://` on the LAN; turn on behind an HTTPS reverse proxy (see below) | + | WebUI Port | `30536` unless that port is taken | + | Postgres Data Storage | the default ixVolume, or a host path on a dataset you snapshot | + | Resources | keep memory at 1536 MB or more; the default of 4096 MB is fine | + +4. Install. The first start applies the database migrations before the web + server listens, so give it a minute. The app shows as running once + `/api/health` answers. +5. Open the App URL. The first account you register becomes the admin. + +The App URL has to match the address in the browser: passkeys, sign-in +redirects and the links in notifications are built from it. If you later put +HealthLog behind a reverse proxy with its own domain, change App URL to that +`https://` address. + +Settings the form does not show go under Additional Environment Variables, for +example `UPDATE_CHECK_DISABLED=1`. The full list is in +[environment.md](environment.md). To use your own GeoLite2 databases, add the +directory under Additional Storage and point `GEOLITE2_DIR` at its mount path +([geolite2.md](geolite2.md)). ## Plain HTTP on the LAN -Most NAS installs are reached over `http://`. Set `SESSION_COOKIE_SECURE=false` -for that. If it is left at its default, the browser drops the sign-in cookie -and the login page says so before you type a password: "This server only sets -its sign-in cookie over https://". +Most NAS installs are reached over `http://`. Leave **Secure Cookies** off for +that. With it on, the browser drops the sign-in cookie on a plain-HTTP page, +and the login page says so before you type a password: "Signing in cannot +complete on this page". Turn it on only when HealthLog is served over +`https://`, and set App URL to that `https://` address. ## Back up your encryption key -HealthLog encrypts tokens, notes, documents and in-database backups with -`ENCRYPTION_KEY`. The key is not stored in the database. If the app is deleted -and reinstalled with a new key over the old database dataset, the data is +HealthLog encrypts tokens, notes, documents and in-database backups with the +Encryption Key. The key is not stored in the database. If the app is deleted +and installed again with a new key over the old database dataset, the data is still there but cannot be read. -1. Copy `ENCRYPTION_KEY` from the app's settings (Apps, HealthLog, Edit, - Environment) into your password manager. -2. Sign in as the admin and open Admin, Encryption. The step shows the key id - and a fingerprint, the first 12 hex characters of SHA-256 over the key. - Compare it with your copy: +TrueNAS keeps the values you typed into the form with the app's configuration +on the apps pool. Treat that as a convenience, not as your copy: deleting the +app removes it, and a reinstall starts with empty fields. Keep the key in a +password manager or another place outside the NAS. + +1. Open Apps, select HealthLog and choose Edit. The key is the Encryption Key + field under HealthLog Configuration. +2. Sign in as the admin and open Admin, Encryption. The **Back up your + encryption key** step shows the active key id and a fingerprint, the first + 12 hex characters of SHA-256 over the key. Compare it with your copy: ```bash echo -n "" | xxd -r -p | sha256sum | cut -c1-12 ``` - or paste the copy into "Check my copy" (it is compared and dropped, never - stored or logged). + or paste the copy into **Check my copy**. It is compared and dropped, never + stored or logged. -3. Confirm the backup. Until you do, admins see a reminder on the dashboard. - After a key rotation the step comes back for the new key. +3. Press **I have backed up this key**. Until you do, admins see a reminder on + the dashboard. After a key rotation the step comes back for the new key. + +The catalog app sets `HEALTHLOG_PLATFORM=truenas`, so the step opens on these +instructions. + +## Backups and restore + +A restore needs two things: a backup of the database **and** the Encryption +Key that was active when it was taken. One without the other gets you nothing +back. + +- **Snapshots.** Snapshot the Postgres Data Storage dataset (the ixVolume + lives under the apps pool, a host path wherever you put it). Stop the app + first, or the snapshot holds a database that was mid-write. +- **A dump.** Open a shell on the `postgres` container (Apps, HealthLog, + the container's Shell) and run: + + ```bash + pg_dump -U healthlog healthlog > /var/lib/postgresql/healthlog-$(date +%F).sql + ``` + + The file lands in the Postgres Data Storage dataset, next to the database + files, so copy it off the NAS as well. + +- **Off-host.** HealthLog can push encrypted nightly backups to any + S3-compatible bucket; see [backup-restore.md](../ops/backup-restore.md). + +To restore a dump, run the app with the Encryption Key the dump was taken +under, put the dump file in the Postgres Data Storage dataset, and from the +`postgres` container shell replace the database with it: + +```bash +psql -U healthlog -d postgres \ + -c "DROP DATABASE healthlog WITH (FORCE);" \ + -c "CREATE DATABASE healthlog OWNER healthlog;" +psql -v ON_ERROR_STOP=1 -U healthlog healthlog < /var/lib/postgresql/healthlog-YYYY-MM-DD.sql +``` + +Then restart the app. Restoring over the existing database without dropping +it first does not work: the tables the background queue creates on its own +refuse a plain overwrite. ## When the key does not match the database At every start HealthLog checks that its key opens the data already in the -database. If it does not, it refuses to serve instead of failing on every -encrypted value: pages show "HealthLog cannot open its data", API requests -answer `503` with `meta.errorCode` `encryption.key_mismatch`, `/api/health` -answers `503` with `reason: "encryption_key_mismatch"`, and no background jobs -run. TrueNAS shows the app as unhealthy rather than crashed, so the reason -stays visible. +database. A missing or malformed key stops the container at once with +`HealthLog: ERROR — encryption configuration is invalid.` in its log. A +well-formed key that does not open the stored data is different: HealthLog +refuses to serve instead of failing on every encrypted value. Pages show +"HealthLog cannot open its data", API requests answer `503` with +`meta.errorCode` `encryption.key_mismatch`, `/api/health` answers `503` with +`reason: "encryption_key_mismatch"`, and no background jobs run. TrueNAS +shows the app as unhealthy rather than crashed, so the reason stays visible in +the app's logs. Fix it one of three ways, then restart the app: -1. Put the original `ENCRYPTION_KEY` back. -2. Point `DATABASE_URL` at the database that belongs to this key. +1. Put the original Encryption Key back (Apps, HealthLog, Edit). +2. Point the app at the database that belongs to this key, for example by + restoring that dataset. 3. Start fresh with an empty database. The old data and its backups need the old key. @@ -91,9 +161,8 @@ checks again at the next start. If you are certain the configured key is the one the data was written with, and the app still refuses because the check recorded a different key earlier, -remove that record and restart. Open a shell on the database container (Apps, -HealthLog, the database container's Shell) and run, with the key id the log -names (`v1` unless you use `ENCRYPTION_KEYS`): +remove that record and restart. Open a shell on the `postgres` container and +run, with the key id the log names (`v1` unless you use `ENCRYPTION_KEYS`): ```bash psql -U healthlog healthlog \ @@ -108,7 +177,7 @@ id: the rotation script removes its record once no values remain under it. On a first start there is no record to remove. If the check refuses there and you are certain the key is right, add `ENCRYPTION_KEY_CHECK` = `warn` under -Apps, HealthLog, Edit, Environment as a last resort. The check still runs and +Additional Environment Variables as a last resort. The check still runs and logs, `/api/health` reports `warning: "encryption_key_mismatch"` without failing, the app serves, and no key is recorded until the check passes. This switches off a safety check: with a wrong key every encrypted value fails and @@ -117,6 +186,49 @@ confirmed. ## Updates -Pick the new tag, take a snapshot of the database dataset first, and update. -Migrations run on start; the first start of a new install applies all of them, -which is why the start period above is generous. +The catalog follows HealthLog releases on its own: when a new image is +published, the TrueNAS catalog picks it up and the app shows an update. Take a +snapshot or a dump first, then update. + +Database migrations run at every start, before the web server listens, and +only go forward. The start log says how many are pending. Going back to an +older version with Roll Back does not undo them, and an older version may not +start against a database a newer one has migrated. The way back is the +database backup you took before the update, restored together with the older +version. + +The Postgres Image field offers the Postgres major versions the catalog +supports. Changing it runs a one-way upgrade of the database files, so back up +before you touch it. + +## Before the listing: install via YAML + +Until the catalog app is listed, install HealthLog with Apps, Discover Apps, +the menu next to Custom App, Install via YAML, using these values: + +| Setting | Value | +| ------------ | ---------------------------------------------------------------------------------------- | +| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) | +| Port | `3000` (HTTP) | +| User | runs as uid `1001` by default; no extra capabilities, `no-new-privileges` is fine | +| Health check | `GET /api/health`, `200` when ready | +| Start period | at least 60 s, with more retries than the image's 5 × 30 s: migrations run at every boot | +| Database | PostgreSQL 16 or 18, no extensions, in its own container with its own dataset | +| Volumes | none for the app; the database gets its own dataset | + +| Variable | Required | Notes | +| ----------------------- | -------- | ---------------------------------------------------------------------------------------- | +| `DATABASE_URL` | yes | `postgresql://:@:5432/` | +| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS. | +| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. | +| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` | +| `APP_URL` | yes | the same address | +| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy | +| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's key backup step opens on the TrueNAS instructions | + +`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app +and runs the background jobs. In a YAML install the key sits in the +environment block, so the place to copy it from is `ENCRYPTION_KEY` there. +To move from a YAML install to the catalog app later, take a dump, install the +catalog app with the same key, and restore the dump as described under +[Backups and restore](#backups-and-restore). From ccd7ee38c9ed53eb3c04dc8608336663a4946b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 24 Oct 2026 11:00:56 +0200 Subject: [PATCH 3/3] fix(shell): let the rail take the left inset and mirror its account band An iPad in a Safari tab laid the app shell out 10 px in from the left edge in portrait. The shell padded that inset, so a strip of page colour ran beside the rail from the logo band to the account row, and the rail's band borders stopped short of the edge. The desktop shell no longer pads the sides. The rail runs to the window's edge and takes the left inset inside its own background, widening by it so its content keeps its 4rem / 16rem; the logo band, the footer group and the account band reach back over the inset so their lines meet the edge. On the right the top bar and main already run to the edge. The phone shell keeps padding both sides. The side insets are read through --safe-area-left and --safe-area-right, so a test can set one. The account band takes the logo band's height (4rem) with the avatar centred in it, instead of 61 to 69 px of padding with more room below the avatar than above; the home-indicator inset stays under the band. tablet-shell.spec.ts sets a 10 px side inset and a 20 px home indicator and checks that the rail owns the window's left column of pixels, that its band lines start at x = 0, that nothing in it sits inside the inset, and that the account band mirrors the logo band. shell-viewport.test.ts holds the routing. --- e2e/tablet-shell.spec.ts | 116 ++++++++++++++++++ src/app/globals.css | 35 ++++-- .../layout/__tests__/shell-viewport.test.ts | 54 ++++++++ .../layout/__tests__/sidebar-nav.test.tsx | 3 +- src/components/layout/shell-metrics.ts | 49 ++++++++ src/components/layout/sidebar-nav.tsx | 32 +++-- 6 files changed, 268 insertions(+), 21 deletions(-) diff --git a/e2e/tablet-shell.spec.ts b/e2e/tablet-shell.spec.ts index 66d3fc6fe..905baba36 100644 --- a/e2e/tablet-shell.spec.ts +++ b/e2e/tablet-shell.spec.ts @@ -610,3 +610,119 @@ for (const vp of [ }); } } + +/** + * A side inset. An iPad in a Safari tab laid the shell out 10 px in from the + * left edge in portrait, which no browser under test reports. The shell and + * the rail read the side insets through `--safe-area-left` / `-right` + * (globals.css), so setting them is the same inset to both. + */ +const SIDE_INSET = 10; + +async function setSideInsets(page: Page, px: number) { + await page.addStyleTag({ + content: `:root { --safe-area-left: ${px}px !important; --safe-area-right: ${px}px !important; }`, + }); +} + +const SIDE_INSET_VIEWPORTS = [ + { name: "iPad Pro 11 portrait", width: 834, height: 1194 }, + { name: "iPad Air 11 portrait", width: 820, height: 1180 }, + { name: "iPad Air 11 landscape", width: 1180, height: 820 }, + { name: "iPad Pro 13 landscape", width: 1366, height: 1024 }, +] as const; + +for (const vp of SIDE_INSET_VIEWPORTS) { + test(`${vp.name}: the rail runs to the left edge over a side inset`, async ({ + page, + }) => { + await page.setViewportSize({ width: vp.width, height: vp.height }); + await openDashboard(page); + await setSideInsets(page, SIDE_INSET); + await setBottomInset(page, HOME_INDICATOR); + await expectShellPinned(page); + await expect(page.locator("aside")).toBeVisible(); + + const m = await page.evaluate(() => { + const aside = document.querySelector("aside")!; + const box = (el: Element | null) => { + const r = el!.getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top, bottom: r.bottom }; + }; + // What paints a pixel: the rail, or the page behind the shell (the + // root, the body, or one of the shell's own unpainted boxes around the + // rail and `
`), or some other chrome that meets the edge. + const owner = (x: number, y: number) => { + const hit = document.elementFromPoint(x, y); + if (!hit) return "nothing"; + if (hit.closest("aside")) return "rail"; + const unpainted = + hit === document.documentElement || + hit === document.body || + (hit.id !== "main-content" && + hit.contains(document.getElementById("main-content"))); + return unpainted ? `page:${hit.tagName}` : "chrome"; + }; + const header = aside.querySelector('[data-slot="sidebar-header"]')!; + const account = aside.querySelector( + '[data-slot="sidebar-column"]', + )!.lastElementChild!; + const avatar = account.querySelector('[data-slot="avatar"]')!; + const footer = aside.querySelector('[data-slot="sidebar-footer"]')!; + const links = [...aside.querySelectorAll("nav a[href]")].map( + (a) => a.getBoundingClientRect().left, + ); + const headerBox = box(header); + const accountBox = box(account); + return { + innerWidth, + innerHeight, + aside: box(aside), + header: headerBox, + account: accountBox, + avatar: box(avatar), + logoLeft: box(header.querySelector("svg")).left, + footerLeft: box(footer).left, + firstLinkLeft: Math.min(...links), + // Down the window's first column of pixels, and its last. + leftEdge: [ + (headerBox.top + headerBox.bottom) / 2, + innerHeight / 2, + (accountBox.top + accountBox.bottom) / 2, + innerHeight - 2, + ].map((y) => owner(1, y)), + rightEdge: [ + (headerBox.top + headerBox.bottom) / 2, + innerHeight / 2, + ].map((y) => owner(innerWidth - 1, y)), + }; + }); + + // No strip: the rail paints the inset from the logo band to the bottom + // edge, and the top bar and `
` (or a docked panel) meet the right + // edge. + expect(m.leftEdge).toEqual(["rail", "rail", "rail", "rail"]); + expect(m.rightEdge).toEqual(["chrome", "chrome"]); + expect(m.aside.left).toBe(0); + // The band lines run to the edge: the logo band, the account band and + // the footer group (whose scroll edge draws a line) start at x = 0. + expect(m.header.left).toBe(0); + expect(m.account.left).toBe(0); + expect(m.footerLeft).toBe(0); + // And everything in the rail stays clear of the inset. + expect(m.logoLeft).toBeGreaterThanOrEqual(SIDE_INSET); + expect(m.firstLinkLeft).toBeGreaterThanOrEqual(SIDE_INSET); + expect(m.avatar.left).toBeGreaterThanOrEqual(SIDE_INSET); + + // The account band mirrors the logo band: the same height, the avatar + // centred in it, and the home indicator's inset below it. + expect(m.account.bottom - m.account.top).toBe( + m.header.bottom - m.header.top, + ); + const above = m.avatar.top - m.account.top; + const below = m.account.bottom - m.avatar.bottom; + expect(Math.abs(above - below)).toBeLessThanOrEqual(1); + expect(m.account.bottom).toBe(m.innerHeight - HOME_INDICATOR); + expect(m.aside.bottom).toBe(m.innerHeight); + }); +} diff --git a/src/app/globals.css b/src/app/globals.css index b8dcc6df5..979d6311e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -631,10 +631,13 @@ body, * owns the shell's bottom edge (the rail, `
`, the bottom bar, the * Coach button). It is `env()` everywhere it matters; a test that needs an * inset no desktop browser reports sets it instead - * (`e2e/tablet-shell.spec.ts`). + * (`e2e/tablet-shell.spec.ts`). The side insets are read the same way, by + * the shell and the rail (`.shell-safe-area` below). */ :root { --safe-area-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-left: env(safe-area-inset-left, 0px); + --safe-area-right: env(safe-area-inset-right, 0px); } html[data-scroll="body"] { scrollbar-gutter: stable; @@ -691,18 +694,34 @@ body, @layer components { /* * `.shell-safe-area` — the authenticated app shell's share of the installed - * app's safe area (see the note in `auth-shell.tsx`). The shell takes the top - * and side insets as padding, and the strip behind the status bar is painted - * in the chrome colour (`--card`, the top bar and the sidebar) rather than - * the page colour, so the status bar reads as part of the bar beneath it. A - * zero inset makes the gradient zero tall: nothing changes in a browser tab. + * app's safe area (see the note in `auth-shell.tsx`). The shell takes the + * top inset as padding (and the side insets in the phone shell), and the + * strip behind the status bar is painted in the chrome colour (`--card`, + * the top bar and the sidebar) rather than the page colour, so the status + * bar reads as part of the bar beneath it. A zero inset makes the gradient + * zero tall. + * + * The side insets are not always zero in a browser tab: an iPad in a Safari + * tab laid the shell out 10 px in from the left edge in portrait, with + * Safari's own toolbar buttons moved in by the same 10 px. Padded here, that + * inset showed the page colour in a strip left of the rail, from the logo + * band down to the account row, and cut the rail's band borders short of the + * edge. So the desktop shell leaves the sides to what meets them: the rail + * takes the left inset inside its own background (`sidebar-nav.tsx`), and + * on the right the top bar and `
` run to the edge with gutters wider + * than any tablet's side inset. The phone shell has no rail and keeps them + * here. */ .shell-safe-area { padding-top: env(safe-area-inset-top, 0px); - padding-right: env(safe-area-inset-right, 0px); - padding-left: env(safe-area-inset-left, 0px); + padding-right: var(--safe-area-right); + padding-left: var(--safe-area-left); background: linear-gradient(var(--card), var(--card)) top / 100% env(safe-area-inset-top, 0px) no-repeat; + @variant shell-desktop { + padding-right: 0; + padding-left: 0; + } } /* diff --git a/src/components/layout/__tests__/shell-viewport.test.ts b/src/components/layout/__tests__/shell-viewport.test.ts index 28e5ebfa7..76778853f 100644 --- a/src/components/layout/__tests__/shell-viewport.test.ts +++ b/src/components/layout/__tests__/shell-viewport.test.ts @@ -12,6 +12,10 @@ import { readSource } from "@/__tests__/helpers/source-files"; import { MAIN_SCROLL_CLASS, + RAIL_ACCOUNT_BAND, + RAIL_WIDTH_COLLAPSED, + RAIL_WIDTH_EXPANDED, + SHELL_HEADER_BAND, SHELL_ROOT_CLASS, SUB_SHELL_GRID_FLOOR, } from "../shell-metrics"; @@ -137,3 +141,53 @@ describe("the bottom edge is cleared in both shells", () => { ); }); }); + +/** + * The side insets. An iPad in a Safari tab laid the shell out 10 px in from + * the left edge in portrait; the shell padded that inset and its strip showed + * the page colour beside the rail, from the logo band to the account row. + * Desktop browsers report no side inset, so `e2e/tablet-shell.spec.ts` + * simulates one through `--safe-area-left` and measures the result; these + * hold the decisions that route the inset to the rail. + */ +describe("the side insets belong to what meets them", () => { + const css = () => readSource("src/app/globals.css", ".shell-safe-area {"); + const rail = () => + readSource( + "src/components/layout/sidebar-nav.tsx", + "export function SidebarNav", + ); + + it("are env() behind one property each, which the shell and the rail read", () => { + const root = readSource("src/app/globals.css", "--safe-area-left"); + expect(root).toMatch( + /--safe-area-left:\s*env\(safe-area-inset-left,\s*0px\);/, + ); + expect(root).toMatch( + /--safe-area-right:\s*env\(safe-area-inset-right,\s*0px\);/, + ); + expect(css()).not.toMatch(/padding-(?:left|right):\s*env\(/); + }); + + it("the desktop shell pads no side, the phone shell pads both", () => { + const block = /\.shell-safe-area\s*\{([\s\S]*?)\n \}/.exec(css())![1]; + expect(block).toMatch(/padding-left:\s*var\(--safe-area-left\);/); + expect(block).toMatch(/padding-right:\s*var\(--safe-area-right\);/); + expect(block).toMatch( + /@variant shell-desktop\s*\{\s*padding-right:\s*0;\s*padding-left:\s*0;\s*\}/, + ); + }); + + it("the rail takes the left inset inside its own background", () => { + const source = rail(); + expect(source).toMatch(/ { + const height = (value: string) => /\bh-(\d+)\b/.exec(value)?.[1]; + expect(height(RAIL_ACCOUNT_BAND)).toBe(height(SHELL_HEADER_BAND)); + expect(RAIL_ACCOUNT_BAND).toContain("border-t"); + }); +}); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 6243311c0..c6d519608 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -56,6 +56,7 @@ vi.mock("@/hooks/use-mounted", () => ({ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { RAIL_WIDTH_COLLAPSED } from "../shell-metrics"; import { SIDEBAR_EXPANDED_CONTENT_WIDTH, SidebarCollapseToggle, @@ -559,7 +560,7 @@ describe(" width transition never overflows the nav", () => { }); mockMountedRef.value = true; const html = render(); - expect(asideClass(html).split(" ")).toContain("w-16"); + expect(asideClass(html).split(" ")).toContain(RAIL_WIDTH_COLLAPSED); expect(columnClass(html).split(" ")).not.toContain( SIDEBAR_EXPANDED_CONTENT_WIDTH, ); diff --git a/src/components/layout/shell-metrics.ts b/src/components/layout/shell-metrics.ts index fba668a0e..c415c51d1 100644 --- a/src/components/layout/shell-metrics.ts +++ b/src/components/layout/shell-metrics.ts @@ -91,3 +91,52 @@ export const MAIN_SCROLL_CLASS = * widths in both the expanded and the collapsed rail. */ export const SHELL_HEADER_BAND = "h-16 border-b"; + +/** + * The rail's account band, the bottom counterpart of its logo band: the same + * height, the border on the other side, the avatar centred in it. Padded on + * its own (`p-3`, `p-2` on short windows) it came out 61 to 69 px tall with + * 18 px above the avatar and more below, and its divider sat at no height + * the logo band shares. The home-indicator inset stays under the band (the + * rail's `pb-(--safe-area-bottom)`), so the band itself never shifts. + */ +export const RAIL_ACCOUNT_BAND = "h-16 shrink-0 border-t"; + +/** + * The rail's width, collapsed (4rem) and expanded (16rem), plus the window's + * left inset. The rail runs to the window's left edge and takes that inset as + * padding inside its own background (`pl-(--safe-area-left)` on the + * `