Merge remote-tracking branch 'origin/release/v1.46.0' into fix/trend-slope-span

This commit is contained in:
Marc-André Bombeck committed 2026-10-24 11:18:12 +02:00
commit 8b6b3bccce
19 files changed
+476 -102

No files matched your search

+1 -1
View File
@@ -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
+20 -5
View File
@@ -199,11 +199,26 @@ dev --create-only --name vX_Y_Z_rollback_<col>`), 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
+7 -7
View File
@@ -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.
+171 -59
View File
@@ -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://<user>:<password>@<host>:5432/<db>` |
| `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 "<your key>" | 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://<user>:<password>@<host>:5432/<db>` |
| `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).
+116
View File
@@ -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 `<main>`), 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 `<main>` (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);
});
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+27 -8
View File
@@ -631,10 +631,13 @@ body,
* owns the shell's bottom edge (the rail, `<main>`, 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 `<main>` 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;
}
}
/*
@@ -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(/<aside[\s\S]*?pl-\(--safe-area-left\)/);
expect(RAIL_WIDTH_COLLAPSED).toBe("w-[calc(4rem+var(--safe-area-left))]");
expect(RAIL_WIDTH_EXPANDED).toBe("w-[calc(16rem+var(--safe-area-left))]");
});
it("the account band mirrors the logo band", () => {
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");
});
});
@@ -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("<SidebarNav> 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,
);
+49
View File
@@ -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
* `<aside>`), so its content keeps the 4rem / 16rem it was laid out for and
* stays clear of the inset. The shell leaves the left side to the rail
* (`.shell-safe-area`, globals.css): an iPad in a Safari tab laid the shell
* out 10 px in from the left, and that strip showed the page colour beside
* the rail. Zero inset: exactly `w-16` / `w-64`.
*/
export const RAIL_WIDTH_COLLAPSED = "w-[calc(4rem+var(--safe-area-left))]";
export const RAIL_WIDTH_EXPANDED = "w-[calc(16rem+var(--safe-area-left))]";
/**
* A rail band that draws a border across the rail (the logo band, the footer
* group's scroll edge, the account band) reaches back over the left inset, so
* its line runs to the window's edge like the top bar's does, and adds the
* inset to its own left padding, so its content stays where it was.
*/
const RAIL_BAND_BLEED = "-ml-(--safe-area-left)";
/** The logo band's and the footer group's horizontal padding, inset included. */
export const RAIL_BAND_INSET = {
header: {
collapsed: `${RAIL_BAND_BLEED} pr-3 pl-[calc(0.75rem+var(--safe-area-left))]`,
expanded: `${RAIL_BAND_BLEED} pr-6 pl-[calc(1.5rem+var(--safe-area-left))]`,
},
footer: {
collapsed: `${RAIL_BAND_BLEED} pr-1.5 pl-[calc(0.375rem+var(--safe-area-left))]`,
expanded: `${RAIL_BAND_BLEED} pr-3 pl-[calc(0.75rem+var(--safe-area-left))]`,
},
} as const;
/** The account band, collapsed (the avatar centred) and expanded. */
export const RAIL_ACCOUNT_BAND_CLASS = {
collapsed: `${RAIL_ACCOUNT_BAND} ${RAIL_BAND_BLEED} border-sidebar-border flex items-center justify-center pl-(--safe-area-left)`,
expanded: `${RAIL_ACCOUNT_BAND} ${RAIL_BAND_BLEED} border-sidebar-border flex items-center pr-3 pl-[calc(0.75rem+var(--safe-area-left))]`,
} as const;
+20 -12
View File
@@ -26,7 +26,13 @@ import {
visibleNavDestinations,
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import {
RAIL_ACCOUNT_BAND_CLASS,
RAIL_BAND_INSET,
RAIL_WIDTH_COLLAPSED,
RAIL_WIDTH_EXPANDED,
SHELL_HEADER_BAND,
} from "@/components/layout/shell-metrics";
import {
RailNavList,
useNavOverflows,
@@ -268,10 +274,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
if (collapsed) {
return (
<div
className={cn(
"border-sidebar-border border-t p-3",
"[@media(max-height:940px)]:p-2",
)}
data-slot="sidebar-account"
className={RAIL_ACCOUNT_BAND_CLASS.collapsed}
>
<div className="flex items-center justify-center">
<DropdownMenu>
@@ -294,8 +298,11 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
}
return (
<div className="border-sidebar-border border-t p-3">
<div className="flex items-center gap-3 px-2 py-1">
<div
data-slot="sidebar-account"
className={RAIL_ACCOUNT_BAND_CLASS.expanded}
>
<div className="flex w-full min-w-0 items-center gap-3 px-2 py-1">
{/* The avatar + name in the expanded footer routes straight
into the account settings; the kebab to the right still opens
the rest of the user menu. */}
@@ -343,7 +350,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
*
* Steps (rows are full-width, so the click target is the whole row):
* - default: 40 px rows, 4 px apart
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
* - max-height 940 px: gaps close (40 px pitch), the list padding tightens
* (the account band keeps the logo band's height, `RAIL_ACCOUNT_BAND`)
* - max-height 820 px: 36 px rows
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
* where the alternative is rows hidden behind a scroll edge
@@ -627,8 +635,8 @@ export function SidebarNav() {
<aside
aria-label={t("nav.sidebar")}
className={cn(
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-(--safe-area-bottom) transition-[width] duration-200 motion-reduce:transition-none",
collapsed ? "w-16" : "w-64",
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-(--safe-area-bottom) pl-(--safe-area-left) transition-[width] duration-200 motion-reduce:transition-none",
collapsed ? RAIL_WIDTH_COLLAPSED : RAIL_WIDTH_EXPANDED,
)}
>
{/* The rail animates its width; the content does not. Expanded, this
@@ -656,7 +664,7 @@ export function SidebarNav() {
className={cn(
"border-sidebar-border",
SHELL_HEADER_BAND,
collapsed ? "px-3" : "px-6",
RAIL_BAND_INSET.header[collapsed ? "collapsed" : "expanded"],
)}
>
<Link
@@ -755,7 +763,7 @@ export function SidebarNav() {
className={cn(
RAIL_LIST_GAP,
"pb-1",
collapsed ? "px-1.5" : "px-3",
RAIL_BAND_INSET.footer[collapsed ? "collapsed" : "expanded"],
navOverflows && "border-sidebar-border border-t pt-1",
)}
>