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). 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/messages/de.json b/messages/de.json index 7d8918b71..6f864da4b 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 eafaa47f1..2d0cf02be 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 b42bb66a5..8da9b29d7 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 b6e43ded2..4868fa1bc 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 d8ef6d7a3..72c6fc963 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 4c2a81deb..8d276f7c4 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 6afe594a6..956928aca 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 d4685f56a..79ab4ebff 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 5f3533071..7d846ed19 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", 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 + * `