From 45e48e9605f02d5867cadaa77c69874fb14175b5 Mon Sep 17 00:00:00 2001 From: badbread Date: Sat, 8 Aug 2026 19:38:01 -0700 Subject: [PATCH] docs: fix v0.2.0 release-audit documentation drift Nine evidence-backed fixes from a v0.2.0 documentation/runbook audit: 1. Migration range 0072-0078 corrected to 0072-0080 (COMPOSE.md, upgrade-and-rollback.md), and the "GO2RTC_AUTH is the only new key" claim reworded to also mention MAIN_REPAIR_TRANSCODE_ENABLED. 2. Secrets docs no longer claim GO2RTC_USER_FILE/GO2RTC_PASS_FILE work, those two don't support the _FILE convention end to end (embedded go2rtc reads process env directly, compose requires the plain vars). 3. Removed the nonexistent scripts/setup-env.sh --print flag from OPS-DEPLOY.md; pointed at the passphrase already sitting in .env instead. 4. Corrected the stale DB_POOL_SIZE guidance in OPS-DEPLOY.md, it is already forwarded by the stock docker-compose.yml. 5. Added a note to OPS-DEPLOY.md and OPS-BACKUP-RECOVERY.md that automated nightly backups are on by default (docs/BACKUP.md); scripts/backup-db.sh is framed as the supplemental/ad-hoc path. 6. Forwarded SEGMENT_RECEIPT_TIMEOUT_SECS in docker-compose.yml's recorder environment block so the recorder's log advice to raise it is actually true (unset/unparseable safely falls back to the internal default, confirmed in recording.rs); documented it in .env.example and the environment reference. 7. Rewrote ROADMAP.md's stale "where we are today" for the notification system (a full rules/channels/history engine already shipped) and prebuilt distribution (GHCR + signed Android releases are live), checking off the phase-checklist items that are actually done. 8. Documented plate naming and copy-plate-number on the LPR docs page, shipped on all four clients. 9. Corrected the Android mainv-first stream-fallback order and the served-vs-producer SDP detection detail in COMPONENT-MAP.md, and added a correction addendum to the DECISIONS.md entry. Signed-off-by: badbread --- .env.example | 1 + docker-compose.yml | 1 + .../configuration/environment-reference.md | 12 ++-- docs-site/docs/configuration/secrets.md | 10 ++-- .../getting-started/upgrade-and-rollback.md | 2 +- .../integrations/license-plate-recognition.md | 18 ++++++ docs/COMPONENT-MAP.md | 4 +- docs/COMPOSE.md | 9 ++- docs/DECISIONS.md | 10 ++++ docs/OPS-BACKUP-RECOVERY.md | 9 +++ docs/OPS-DEPLOY.md | 29 ++++++---- docs/ROADMAP.md | 57 ++++++++++++------- 12 files changed, 115 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index c529c351..80c192c9 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,7 @@ WEBRTC_CANDIDATE= # --- Recording --- SEGMENT_SECONDS=4 # 2-6s; shorter = snappier seek. Starts on a keyframe. +# SEGMENT_RECEIPT_TIMEOUT_SECS=90 # stall watchdog before a worker reconnects; clamped to [20, 3600] # --- Recorder supervision loops (optional; defaults are right for almost everyone) --- # CONFIG_POLL_SECONDS=30 # how often the recorder re-diffs the DB camera list diff --git a/docker-compose.yml b/docker-compose.yml index 494403eb..24bdac27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,7 @@ services: ARCHIVE_STORAGE_PATH: ${ARCHIVE_STORAGE_PATH:-/data/archive} ARCHIVE_STORAGE_NAME: "${ARCHIVE_STORAGE_NAME:-Archive}" SEGMENT_SECONDS: ${SEGMENT_SECONDS:-4} + SEGMENT_RECEIPT_TIMEOUT_SECS: ${SEGMENT_RECEIPT_TIMEOUT_SECS:-} # stall watchdog before a worker reconnects; empty = default 90s, clamped to [20, 3600] MOTION_HWACCEL: ${MOTION_HWACCEL:-cpu} # 'cpu' works on any host (software motion decode); set vaapi/cuda/auto for a GPU (overlays) or via the admin decode-backend setting MAX_GPU_DECODE_SESSIONS: ${MAX_GPU_DECODE_SESSIONS:-4} CONFIG_POLL_SECONDS: ${CONFIG_POLL_SECONDS:-30} diff --git a/docs-site/docs/configuration/environment-reference.md b/docs-site/docs/configuration/environment-reference.md index f0e3f8cb..439b52d5 100644 --- a/docs-site/docs/configuration/environment-reference.md +++ b/docs-site/docs/configuration/environment-reference.md @@ -20,10 +20,13 @@ key in `.env`, restart the affected container, done. No the admin console, the console value (stored in the database) wins over the env default; that's flagged in the notes. -Every secret-bearing key also answers to a `_FILE` twin (`JWT_SECRET_FILE`, -`GO2RTC_PASS_FILE`, and the rest) holding a path to read the value from, for -Docker secrets. Only `HA_TOKEN_FILE` gets its own row below, because the others -are mechanical; see [Secrets](/configuration/secrets) for the list. +Most secret-bearing keys also answer to a `_FILE` twin (`DATABASE_URL_FILE`, +`JWT_SECRET_FILE`, `SEED_ADMIN_PASSWORD_FILE`, `HA_TOKEN_FILE`) holding a path +to read the value from, for Docker secrets. `GO2RTC_USER`/`GO2RTC_PASS` are the +exception: the embedded go2rtc restreamer expands them straight from the +process environment and compose requires the plain vars, so those two don't +support `_FILE`. Only `HA_TOKEN_FILE` gets its own row below, because the +others are mechanical; see [Secrets](/configuration/secrets) for the list. ## Time zone @@ -69,6 +72,7 @@ console's Server & streaming settings, that value wins. | Key | Default | Notes | |---|---|---| | `SEGMENT_SECONDS` | `4` | 2 to 6 seconds; short segments mean near-instant seek | +| `SEGMENT_RECEIPT_TIMEOUT_SECS` | `90` | stall watchdog: how long a worker waits for the next segment before it reconnects. Raise it for a long-GOP camera whose keyframe interval exceeds the default. Clamped to `[20, 3600]`; unset or unparseable falls back to the default rather than erroring. | ## Recorder internals diff --git a/docs-site/docs/configuration/secrets.md b/docs-site/docs/configuration/secrets.md index 7d6636a7..4ebec842 100644 --- a/docs-site/docs/configuration/secrets.md +++ b/docs-site/docs/configuration/secrets.md @@ -113,10 +113,12 @@ provision a fresh database (you lose the segment index, so restore a old one immediately, so update any running worker with the new value. - Secrets that support it can come from a file instead of the environment via the `_FILE` convention (`DATABASE_URL_FILE`, `JWT_SECRET_FILE`, - `GO2RTC_USER_FILE` / `GO2RTC_PASS_FILE`, `SEED_ADMIN_PASSWORD_FILE`, - `HA_TOKEN_FILE`). Point one at a Docker-secret path and Crumb reads the - file, keeping the plaintext value out of the process environment and - `.env`. See `scripts/setup-secrets.sh` and + `SEED_ADMIN_PASSWORD_FILE`, `HA_TOKEN_FILE`). Point one at a Docker-secret + path and Crumb reads the file, keeping the plaintext value out of the + process environment and `.env`. `GO2RTC_USER`/`GO2RTC_PASS` don't support + this convention: the embedded go2rtc restreamer expands them straight from + the process environment, and compose requires the plain vars, so those two + stay as regular `.env` values. See `scripts/setup-secrets.sh` and `docker-compose.secrets.example.yml`. ## If you hand-edit `.env` instead of using the script diff --git a/docs-site/docs/getting-started/upgrade-and-rollback.md b/docs-site/docs/getting-started/upgrade-and-rollback.md index fd74dd2d..ec154cb7 100644 --- a/docs-site/docs/getting-started/upgrade-and-rollback.md +++ b/docs-site/docs/getting-started/upgrade-and-rollback.md @@ -50,7 +50,7 @@ images.) This one was tested end to end as an in-place upgrade from the published v0.1.1 images. It is a drop-in: `.env` needs no changes, the new migrations -(0072 through 0078) apply in a single pass on first boot, footage came +(0072 through 0080) apply in a single pass on first boot, footage came through byte-identical, and logins, roles, policies, and the authenticated RTSP restream default were all intact afterwards. Four things are worth knowing before you run it. diff --git a/docs-site/docs/integrations/license-plate-recognition.md b/docs-site/docs/integrations/license-plate-recognition.md index 4e985732..2c09c1af 100644 --- a/docs-site/docs/integrations/license-plate-recognition.md +++ b/docs-site/docs/integrations/license-plate-recognition.md @@ -126,6 +126,24 @@ draw them in the LPR section's per-camera zone editor over a live snapshot. OCR flip on one of those can never push a real watchlisted plate over the budget, which is the behavior I want (I'd rather never miss an alert). +## Naming a plate + +A plate is a string of characters until you tell Crumb otherwise. From the +Plates tab (web console, desktop, Android, and iOS) you can give any plate a +human-readable name, "Jason's truck", "Mail carrier", whatever tells you at a +glance what you're looking at. Naming is admin-only and is a separate action +from watchlisting: you can name a plate without watchlisting it, watchlist a +plate without naming it, or do both. Once set, the name shows wherever that +plate appears, reads, watchlist, and the plate detail, with the raw plate +number still legible underneath so you're never guessing what was actually +read. Clearing the name falls back to a watchlist label if one exists, then to +the raw plate. + +**Copy a plate number** to the clipboard straight from a reads row, a +watchlist row, or the plate detail, on the web console, desktop, Android, and +iOS, so you can hand it off (a call to the front gate, a note to a neighbor) +without retyping it. + ## Searching and retention Search the read database with `GET /plates` (exact, prefix, contains, or fuzzy diff --git a/docs/COMPONENT-MAP.md b/docs/COMPONENT-MAP.md index 3c7b58c4..2cdbaa30 100644 --- a/docs/COMPONENT-MAP.md +++ b/docs/COMPONENT-MAP.md @@ -310,8 +310,8 @@ is not. The web admin console doubles as the desktop's management surface | LPR A/B engine benchmark (`docs/DECISIONS.md` 2026-07-17 A/B entry; backend: `GET /lpr/ab-report` `view_plates` + `POST /lpr/ab-confirm` admin-only in `plates.rs`, pure pairing in `services/common/src/lpr_ab.rs`, `lpr_pass_truth` migration `0070`; applies only to `lpr_engine = 'both'` cameras) | Deferred (compact read-only stat view is a nice-to-have) | **Benchmark** dialog off the Plates tab (`apps/desktop-flutter/lib/ui/plates/ab_benchmark.dart`), button auto-hidden unless the server reports a `both` camera; confirm-true-plate is admin-only | Deferred | Deferred | | LPR plate names (issue #363, `docs/DECISIONS.md` 2026-07-31 plate-names entry; backend: `plate_labels` migration `0073`, `PUT /lpr/plate-labels` + `DELETE /lpr/plate-labels/:plate` admin-only in `plates.rs`, display-name resolution `COALESCE(plate_labels.label, lpr_watchlist.label)` on the normalized plate folded into `list_plate_reads` + the watchlist read in `db.rs`, `display_name` added to `PlateRead`/`PlateWatchlistEntry`, alert text in `detection_ingester.rs` uses it; exact-normalized keying, no fuzzy-variant naming in v1) | LPR reads + Watchlist rows show the resolved name; per-row **Name / Rename** (blank clears) affordance (`namePlate`, admin-only server-side) | reads + watchlist rows render `display_name`, plus set/edit/clear from a read row, the read pop-up, and a watchlist row (`plates_screen.dart` + `plate_name_dialog.dart`, admin-gated on `canNamePlates`) | reads + watchlist rows render `display_name` (`feature/plates/PlatesScreen.kt`) | reads + watchlist rows render `display_name`, **plus set / rename / clear** from a read row, a gallery card, or a watchlist row (`Features/Plates/PlatesView.swift` `PlateNameSheet`, pure logic in `PlateNaming.swift`, `CrumbAPI.setPlateLabel`/`clearPlateLabel`), gated on `AppContainer.isAdmin` to match the admin-only endpoints; blank clears, and the sheet states that naming is not alerting. Same surfaces carry a **copy-plate-number** affordance (button + context menu, `CrumbClipboard` in `Platform/Platform.swift`) that always copies the raw plate, never the name. **Deferred:** web PDF report + set/clear from the desktop/Android clients | | Adaptive live-wall quality (issues #382 desktop / #383 Apple / #384 Android, `docs/DECISIONS.md` 2026-07-20 live-wall entry; two-stage predictive guardrail (75%) + reactive backpressure (85% shed / 60% restore, hysteresis), shed order protects the focused/zoomed tile, "SD" badge; client-local per-machine thresholds, NO server change) | N/A (console has no live wall) | guardrail nudge + `gpuDecUtil` backpressure shed/restore (`apps/desktop-flutter/lib/state/adaptive_wall.dart`, `ui/wall_screen.dart`) | guardrail + ExoPlayer `DecoderCounters` / `PowerManager` thermal backpressure (`feature/live/WallDecodeMonitor.kt`); sheds to snapshot (wall is sub-preferring), count-based guardrail (no per-camera resolution client-side) | guardrail + `ProcessInfo.thermalState` backpressure (`Features/Live/WallLoadController.swift`); main-vs-sub as resolution proxy. **Deferred:** `AVSampleBufferDisplayLayer` frame-health refinement | -| Targeted `_subv` sub-stream repair (issues #483/#485/#501/#526; backend: `services/api/src/go2rtc.rs` `subv_name()` (`_subv`), `subv_src()`, `resolve_needs_subv()` + the per-pass `needs_subv` map with `AppState::set_subv_needed`/`retain_subv_needed`; `rtsp_subv_url` in `services/api/src/dto.rs`. NO migration, the state is in-memory and self-clears on api restart. A video-only copy restream so go2rtc republishes a proper `fmtp` line for subs whose SDP lacks one. Registration rule: only when the sub SDP is POSITIVELY detected as lacking `fmtp` AND the rtpmap encoding is H264/H265/HEVC (MJPEG has no `fmtp` by RFC 2435 and must never be flagged); an "unknown" verdict is sticky, not a re-registration. go2rtc spawns the ffmpeg only while a consumer is attached, so an idle `_subv` costs nothing. Absent field ⇒ the client behaves exactly as before) | N/A (console has no native RTSP live path) | Deliberately NOT consumed: libmpv parses the raw sub fine, so adding `_subv` here would buy a remux for nothing. Decision, not a gap | **The only consumer**: `feature/live/LiveStreamFallback.kt` (wall `subv → sub → mobile`, fullscreen `main → mainv → subv → sub → mobile`), `LiveCameraTile.kt`, `LiveFullscreenScreen.kt`, `data/Models.kt`; tests `SubStreamUrlTest.kt`, `LiveStreamFallbackTest.kt` | Deliberately NOT consumed, same rationale as desktop | -| Targeted `_mainv` MAIN repair, OPT-IN transcode (`docs/DECISIONS.md` 2026-08-08 `_mainv` entry; backend: `services/api/src/go2rtc.rs` `mainv_name()` (`_mainv`), `mainv_src()` (`ffmpeg:#video=h264#audio=aac` — a TRANSCODE, not a copy) + the per-pass `needs_mainv` map with `AppState::set_mainv_needed`/`retain_mainv_needed`; `mainv_url()` + `rtsp_mainv_url` in `services/api/src/playback.rs`/`dto.rs`; config `main_repair_transcode_enabled` (`MAIN_REPAIR_TRANSCODE_ENABLED`, default false) in `services/api/src/config.rs`, forwarded in `docker-compose.yml`. NO migration, state in-memory. Registration rule: only when the operator opts in AND the MAIN SDP is POSITIVELY detected as lacking `fmtp` (reuses `sdp_video_lacks_fmtp` on the main producer, always warm because the recorder consumes the main through go2rtc); sticky-unknown like `_subv`. WHY a transcode not a copy: a copy-remux restores `fmtp` but go2rtc then emits an HEVC parameter-set Aggregation Packet Media3 can't depacketize (verified 2026-08-08), so only a re-encode plays on Android. Absent field ⇒ client behaves exactly as before) | N/A (console has no native RTSP live path) | Deliberately NOT consumed: libmpv plays the raw H.265 main fine (no fmtp/AP sensitivity), so a transcode would buy nothing. Decision, not a gap | **The only consumer**: `feature/live/LiveStreamFallback.kt` (`MAINV` tier, fullscreen `main → mainv → subv → sub → mobile`, wall no-sub `main → mainv → mobile`; HD, no SD badge), `data/Models.kt` `rtspMainvUrl`; tests `LiveStreamFallbackTest.kt`. Also here: `missing attribute fmtp` added to the deterministic step-down signatures so a broken main drops to SD instantly even with the repair off | Deliberately NOT consumed, same rationale as desktop | +| Targeted `_subv` sub-stream repair (issues #483/#485/#501/#526; backend: `services/api/src/go2rtc.rs` `subv_name()` (`_subv`), `subv_src()`, `resolve_needs_subv()` + the per-pass `needs_subv` map with `AppState::set_subv_needed`/`retain_subv_needed`; `rtsp_subv_url` in `services/api/src/dto.rs`. NO migration, the state is in-memory and self-clears on api restart. A video-only copy restream so go2rtc republishes a proper `fmtp` line for subs whose SDP lacks one. Registration rule: only when the sub SDP is POSITIVELY detected as lacking `fmtp` AND the rtpmap encoding is H264/H265/HEVC (MJPEG has no `fmtp` by RFC 2435 and must never be flagged); an "unknown" verdict is sticky, not a re-registration. go2rtc spawns the ffmpeg only while a consumer is attached, so an idle `_subv` costs nothing. Absent field ⇒ the client behaves exactly as before) | N/A (console has no native RTSP live path) | Deliberately NOT consumed: libmpv parses the raw sub fine, so adding `_subv` here would buy a remux for nothing. Decision, not a gap | **The only consumer**: `feature/live/LiveStreamFallback.kt` (wall `subv → sub → mobile`, fullscreen `mainv → main → subv → sub → mobile`), `LiveCameraTile.kt`, `LiveFullscreenScreen.kt`, `data/Models.kt`; tests `SubStreamUrlTest.kt`, `LiveStreamFallbackTest.kt` | Deliberately NOT consumed, same rationale as desktop | +| Targeted `_mainv` MAIN repair, OPT-IN transcode (`docs/DECISIONS.md` 2026-08-08 `_mainv` entry; backend: `services/api/src/go2rtc.rs` `mainv_name()` (`_mainv`), `mainv_src()` (`ffmpeg:#video=h264#audio=aac` — a TRANSCODE, not a copy) + the per-pass `needs_mainv` map with `AppState::set_mainv_needed`/`retain_mainv_needed`; `mainv_url()` + `rtsp_mainv_url` in `services/api/src/playback.rs`/`dto.rs`; config `main_repair_transcode_enabled` (`MAIN_REPAIR_TRANSCODE_ENABLED`, default false) in `services/api/src/config.rs`, forwarded in `docker-compose.yml`. NO migration, state in-memory. Registration rule: only when the operator opts in AND the SERVED SDP is POSITIVELY detected as lacking `fmtp` (`stream_served_video_lacks_fmtp`, reading go2rtc's actual served SDP rather than the main producer's, since a copy-remux can change what ships even when the producer looks healthy); sticky-unknown like `_subv`. WHY a transcode not a copy: a copy-remux restores `fmtp` but go2rtc then emits an HEVC parameter-set Aggregation Packet Media3 can't depacketize (verified 2026-08-08), so only a re-encode plays on Android. Absent field ⇒ client behaves exactly as before) | N/A (console has no native RTSP live path) | Deliberately NOT consumed: libmpv plays the raw H.265 main fine (no fmtp/AP sensitivity), so a transcode would buy nothing. Decision, not a gap | **The only consumer**: `feature/live/LiveStreamFallback.kt` (`MAINV` tier, tried FIRST after #594: fullscreen `mainv → main → subv → sub → mobile`, wall no-sub `mainv → main → mobile`; HD, no SD badge), `data/Models.kt` `rtspMainvUrl`; tests `LiveStreamFallbackTest.kt`. Also here: `missing attribute fmtp` added to the deterministic step-down signatures so a broken main drops to SD instantly even with the repair off | Deliberately NOT consumed, same rationale as desktop | | Playback timeline "solo selected camera" (client-only UI preference, desktop-only, NO server change; `apps/desktop-flutter/lib/ui/motion_timeline/`) | N/A (console playback has no per-camera stacked motion timeline) | Toggle in the Playback legend bar collapses the stacked per-camera motion/detection strip to just the focused (maximized else selected) camera and follows the focus; pure decision `visibleTimelineCameras` in `motion_timeline_controller.dart` (unit-tested), persisted across sessions via `PlaybackPrefs.getSoloSelectedCamera`/`setSoloSelectedCamera`. Solo falls back to the full stacked view when there is no loaded selection (never a misleading empty strip) | N/A (Android timeline has no cross-camera stacked histogram) | N/A (iOS timeline has no cross-camera stacked histogram) | Parity walk for a new feature: diff --git a/docs/COMPOSE.md b/docs/COMPOSE.md index 4333a7fc..25c221c7 100644 --- a/docs/COMPOSE.md +++ b/docs/COMPOSE.md @@ -297,10 +297,13 @@ docker compose up -d If `CRUMB_VERSION` is pinned in `.env`, set it to the new version before you pull. If you never set it you are on `latest`, and the pull is enough. -- **Migrations run themselves.** First boot applies 0072 through 0078. No manual +- **Migrations run themselves.** First boot applies 0072 through 0080. No manual step, and no downtime beyond the container restart. -- **No new required settings.** `GO2RTC_AUTH` is the only new key, and leaving it - unset keeps the secure default: the LAN RTSP restream stays authenticated. +- **No new required settings.** Two new keys, both optional. `GO2RTC_AUTH` + leaves the secure default in place when unset: the LAN RTSP restream stays + authenticated. `MAIN_REPAIR_TRANSCODE_ENABLED` is off by default and only + matters if you opt in to the per-camera HD repair for a main stream Android + otherwise steps down to SD. - **`MOTION_HWACCEL`:** a new install now defaults to `cpu`, but an `.env` generated by 0.1.1 pins `MOTION_HWACCEL=auto` explicitly, so an upgrade leaves you on `auto`. That is safe now. `auto` no longer just asks whether cuda was diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f093a389..f4c7df0c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -136,6 +136,16 @@ Follow-ups tracked separately: record-from-sub main-repair blindness, and the sub-side incomplete-fmtp class (an `a=fmtp` present but missing its parameter sets). The transcode decision itself is unchanged. +**Correction (#594, later).** The Decision above has Android try `mainv` right +after the raw main (`main -> mainv -> subv -> sub -> mobile`). That order cost +every LPR camera a doomed connect to the raw main on each fullscreen open (the +startup lag called out elsewhere in this entry). `LiveStreamFallback.kt` now +tries `mainv` FIRST when the server has published it: fullscreen +`mainv -> main -> subv -> sub -> mobile`, wall no-sub `mainv -> main -> mobile`. +A camera with no repair published has no `mainv` rung and is unaffected, +`main -> ...` exactly as before. `docs/COMPONENT-MAP.md`'s ladder entries are +kept in step with this reorder. + --- ## 2026-08-08, Boot storage seeding is PATH-idempotent (skip a name whose directory is already covered) + runtime name lookups fall back to path — supersedes #557's "detect, never fix" diff --git a/docs/OPS-BACKUP-RECOVERY.md b/docs/OPS-BACKUP-RECOVERY.md index a7dca75a..cce724a1 100644 --- a/docs/OPS-BACKUP-RECOVERY.md +++ b/docs/OPS-BACKUP-RECOVERY.md @@ -7,6 +7,15 @@ it** (see the pre-deployment checklist at the bottom). Commands assume the stack lives at `/opt/crumb/app` (the compose project root, where `docker-compose.yml` and `.env` live). Adjust paths to your install. +> **Automated backups already run out of the box.** The api ships a built-in +> nightly `pg_dump` job, on by default, with its own rotation and failure +> alerting, see `docs/BACKUP.md`. You do not need to set up the manual +> `scripts/backup-db.sh` cron below just to have backups happening; this +> runbook's script and cron are the supplemental, ad-hoc path (custom +> schedule, dumps pushed somewhere the api's bind mount doesn't reach, etc.), +> not the thing that makes backups exist in the first place. Running both is +> harmless, they just produce two sets of dumps. + --- ## Why the database is the thing you must back up diff --git a/docs/OPS-DEPLOY.md b/docs/OPS-DEPLOY.md index 228691be..a315065a 100644 --- a/docs/OPS-DEPLOY.md +++ b/docs/OPS-DEPLOY.md @@ -44,7 +44,12 @@ admin password in the console after first login. compose edit; the recorder creates the subdir on first write. Then do the post-install hardening: -- Install the backup cron (`docs/OPS-BACKUP-RECOVERY.md`). +- Nothing to install for backups: the api runs a nightly `pg_dump` on its own, + on by default (see `docs/BACKUP.md`). Confirm a dump has actually landed + rather than assuming it's working. Only set up the manual + `scripts/backup-db.sh` cron (`docs/OPS-BACKUP-RECOVERY.md`) if you want + supplemental, ad-hoc dumps, e.g. pushed straight to a NAS the api's bind + mount doesn't reach, running both is harmless. - Enable the pre-commit secret guard if this is a working clone (below). - Run a tested-restore drill before going live. @@ -71,15 +76,16 @@ hook that blocks committing it.** ```bash scripts/setup-env.sh # generate secrets + seed an admin; prints the passphrase scripts/setup-env.sh --prompt # set your own admin password interactively -scripts/setup-env.sh --print # also echo the passphrase to stdout after writing scripts/setup-env.sh --force # ROTATE: overwrite an existing .env ``` It refuses to clobber an existing `.env` without `--force`, so it's safe to -re-run. Rotating `JWT_SECRET` invalidates outstanding tokens (users re-login); -rotating `POSTGRES_PASSWORD` requires also updating the role in Postgres -(`ALTER ROLE crumb PASSWORD '...'`), for the bundled DB the simplest path is -a fresh init or an explicit `ALTER ROLE`. +re-run. The admin passphrase only prints to the terminal once, at generation +time; if you miss it, it's still sitting in plaintext in `.env` as +`SEED_ADMIN_PASSWORD` until you rotate it. Rotating `JWT_SECRET` invalidates +outstanding tokens (users re-login); rotating `POSTGRES_PASSWORD` requires +also updating the role in Postgres (`ALTER ROLE crumb PASSWORD '...'`), for +the bundled DB the simplest path is a fresh init or an explicit `ALTER ROLE`. ### Keep secrets out of git @@ -95,11 +101,12 @@ a fresh init or an explicit `ALTER ROLE`. ### The DB_POOL_SIZE knob `api` and `recorder` read `DB_POOL_SIZE` (an integer; default **32** in code). -It's left **unset** in `docker-compose.yml` so the code default applies (note: -there's no ready-made line to uncomment, so add `DB_POOL_SIZE` to `.env` and wire -it into the api/recorder `environment:` blocks to override). For real camera -counts, size it for concurrent load, roughly **2 × cameras + 10** (e.g. 42 for -16 cameras, 74 for 32). Undersizing it causes pool-saturation hangs under load. +The stock `docker-compose.yml` already forwards it to both containers, and +`.env.example` carries a commented `DB_POOL_SIZE=42` line, so raising it is +just setting the value in `.env` and restarting both containers, no compose +edit needed. For real camera counts, size it for concurrent load, roughly +**2 × cameras + 10** (e.g. 42 for 16 cameras, 74 for 32). Undersizing it +causes pool-saturation hangs under load. --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7755de4a..6d4f9e1f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,9 +21,22 @@ Effort is engineering size, not priority. Decisions for the maintainer are calle A rules engine living in the always-on API turns motion, detection, and health signals into per-camera, scheduled, cooldown-gated notifications delivered via ntfy / webhook / email plus an in-app channel, with no mandatory cloud and no hard Frigate dependency, it works for any operator running their own server. +> **Update (0.2.0): the MVP shipped, via a different mechanism than planned +> below.** The Phase A rules engine, evaluators, dispatchers, and admin +> console section all exist. The Phase B/D "native Crumb push on Android" +> plan did not: instead of a WorkManager poll or FCM, mobile delivery rides +> on the channel dispatchers themselves (ntfy, Pushover, Telegram, Discord, +> Slack, generic webhook), each of which already solves push on the phone, so +> no first-party push client was needed. No client (Android, iOS, desktop) +> registers a `/notifications/devices` entry or consumes `/notifications` for +> local notifications; that surface exists in the API but nothing calls it +> yet. See "Where we are today" below and the phase checklist. + #### Where we are today -Alerting is a single hardcoded webhook watchdog. `services/api/src/alerts.rs` (`run_heartbeat_watchdog`) is the only notifier: when `ALERT_WEBHOOK_URL` is set it polls `recorder_heartbeat` every 30s and POSTs a `{content,text}` (Discord/Slack-shaped) payload once on stale (>60s) and once on recovery, via a single `alerted` latch. It is wired in `services/api/src/main.rs:365-374`; the config field `alert_webhook_url` is in `services/api/src/config.rs:122-127`. There are no rules, no per-camera scoping, no history, and no person/disk/camera-offline alerts. +Alerting is a full engine, not a watchdog. `services/api/src/notifications.rs` (1891+ lines, doc-commented with a route table) owns device registration, per-camera and default rules, quiet hours, snooze/presence gating, a `notifications/log` history, and CRUD for third-party channels; `run_notification_engine` (spawned in `main.rs`, ~line 685) polls `events` every 3s, fans out to registered devices, evaluates rule gates (per-camera override → user default → system default), and dispatches to every enabled channel via `services/api/src/channel_notify.rs`. Channels are DB rows (`notification_channels`, migration 0015, extended by 0079/0080), not env config: **Discord, Slack, Pushover, Telegram, ntfy, and generic webhook** are all live dispatch targets, each capability-gated on what it can carry (image attachment, single vs. multi-image) and each with its own `SnapshotMode` (none/plate/vehicle/both, migration 0080). Alert text is customizable per system-alert type (#568), and system-health evaluators for `camera_offline` and `low_disk` already exist in `services/api/src/alerts.rs` alongside the detection-event and motion sources below. The 0.2.0 release rebuilt the console's Notifications pane around named channels, an inline alert-text editor, and quiet hours (#583, migrations 0079-0080). + +The one thing that did **not** converge: `services/api/src/alerts.rs` (`run_heartbeat_watchdog`) still runs as a separate, simpler opt-in path, when `ALERT_WEBHOOK_URL` is set it polls `recorder_heartbeat` every 30s and POSTs a `{content,text}` payload directly, independent of the rule engine and channel model above. It was never folded into a seeded default rule as the original plan proposed. The event sources a real engine needs already exist: @@ -47,14 +60,14 @@ The hardest constraint is mobile push without mandatory FCM. The clean answer fo Phase A, MVP: engine + history + one default channel (L) -- [ ] Add tables via `ensure_*` (idempotent, mirror `main.rs`): `notification_rules` (camera_id NULL=all, event_type, severity, schedule jsonb, zones text[], min_score, cooldown_secs, enabled, per-rule channel set) and `notifications` (history: rule_id, camera_id, event_type, ts, title, body, snapshot_url, dedup_key, per-channel delivery status) (M) -- [ ] Define internal `CrumbEvent{kind,camera_id,severity,ts,payload}` + a tokio mpsc bus in the API (S) -- [ ] Refactor `alerts.rs` heartbeat watchdog into a `recorder_down` evaluator that emits `CrumbEvent` instead of POSTing directly (keep the stale/recovery latch) (S) -- [ ] Add evaluators: detection-event (tap the ingester channel or poll `events`), camera-offline (stateful watchdog over `camera_last_segment` with hysteresis + recovery), disk-threshold (statvfs % high-water from status.rs logic) (M) -- [ ] Rules engine: per-event filter by camera × type × schedule(TZ) × zone × min_score × cooldown; write a `notifications` row; dispatch (M) -- [ ] Dispatchers: generalize the proven `{content,text}` webhook + add an ntfy publisher (HTTP POST to topic; title/priority/click/attach snapshot) (M) -- [ ] Admin console: a Notifications section in `admin.html` to CRUD rules + view recent history (M) -- [ ] Config: `NTFY_BASE_URL`/`NTFY_TOPIC`/`NTFY_TOKEN`, `SMTP_*`; keep `ALERT_WEBHOOK_URL` backward-compatible as a seeded default rule (S) +- [x] Tables: `notification_rules` and channel/history tables shipped (`notification_channels` migration 0015, extended 0079/0080; `notification_log`), via numbered migrations rather than the `ensure_*` pattern (M) +- [ ] Define internal `CrumbEvent{kind,camera_id,severity,ts,payload}` + a tokio mpsc bus in the API, superseded, the shipped engine polls `events` every 3s directly instead of running an internal bus (S) +- [ ] Refactor `alerts.rs` heartbeat watchdog into a `recorder_down` evaluator that emits `CrumbEvent` instead of POSTing directly (keep the stale/recovery latch), not done, it still runs as a separate opt-in path (S) +- [x] Evaluators: detection-event, camera-offline, disk-threshold (`low_disk`) all shipped in `alerts.rs` / `notifications.rs` (M) +- [x] Rules engine: per-camera/default rules, quiet hours, min-score, cooldown, presence gating; dispatch via channels (M) +- [x] Dispatchers: shipped, and exceeded the plan, webhook, ntfy, Discord, Slack, Pushover, and Telegram are all live channel types (`channel_notify.rs`) (M) +- [x] Admin console: the Notifications pane in `admin.html`, rebuilt in 0.2.0 around named channels + an alert-text editor + quiet hours (#583) (M) +- [ ] Config: `NTFY_BASE_URL`/`NTFY_TOPIC`/`NTFY_TOKEN`, `SMTP_*` env vars, superseded, channels are DB rows configured in the admin console instead of env vars. `ALERT_WEBHOOK_URL` was NOT folded into a seeded default rule, it remains a separate legacy path (S) Phase B, In-app Crumb push (Android, no FCM) (M) @@ -66,8 +79,8 @@ Phase B, In-app Crumb push (Android, no FCM) (M) Phase C, More channels + richer rules (M) - [ ] SMTP email dispatcher with snapshot attachment (proxy via the existing `/events/{id}/snapshot`) (M) -- [ ] Pushover + Telegram dispatchers (generic HTTP, thin) (S) -- [ ] Per-rule quiet hours, severity→priority mapping, snooze/ack from the notification, digest/rollup to fight bursts (M) +- [x] Pushover + Telegram dispatchers (generic HTTP, thin) (S) +- [ ] Per-rule quiet hours (shipped), severity→priority mapping, snooze/ack from the notification (snooze shipped, ack not confirmed), digest/rollup to fight bursts (M) - [ ] Zone-aware nuisance-suppression presets for the common tree/street outdoor-camera cases (S) Phase D, Native sub-second motion + optional FCM (M) @@ -174,15 +187,15 @@ Ship Crumb as prebuilt public Docker images (deploy-by-pull + an offline tarball #### Where we are today -The build infrastructure is roughly 70% there; the gap is wiring + signing + an offline path + clients-in-CI, not new architecture. +Backend deploy-by-pull and signed Android releases are shipped. What's left is the desktop signed installer (superseded to Flutter, see above, and still unsigned) and the air-gapped offline tarball; neither needs new architecture, both are wiring. Backend: `services/api/Dockerfile` and `services/recorder/Dockerfile` are two-stage builds whose runtime stage is `debian:bookworm-slim` + jellyfin-ffmpeg + a single `COPY --from=builder` compiled binary. `docker-compose.yml` already parameterizes images as `${CRUMB_IMAGE_PREFIX:-crumbvms}/:${CRUMB_VERSION:-local}`, so the same file builds locally (default `local`) or pulls by tag from a registry. `.env.example` documents the deploy-by-pull toggle. `docs/RELEASE.md` specifies the versioned-image/rollback flow; `docs/IMAGES.md` the publishing "owner seam". Workspace license is `AGPL-3.0-or-later` (LICENSE + NOTICE at the repo root). (The `web/` Next.js app an earlier revision of this section covered was removed, the admin console ships inside the api image.) -CI (build-but-don't-push today): `.github/workflows/ci.yml` runs fmt/clippy/build/test, then builds the service images and tags via docker/metadata-action (`sha-`, `latest` on main, `v*` on tag). Push is gated on a `vars.REGISTRY` that is unset, so the `images` job builds-and-validates only and never pushes. No registry is wired yet. +CI, registry wired and public: `.github/workflows/ci.yml` runs fmt/clippy/build/test, then builds the service images and tags via docker/metadata-action (`sha-`, `latest` on main, `v*` on tag), pushing to GHCR whenever the `vars.REGISTRY` repo variable is set and the event isn't a PR. It is set: `docker-compose.yml`'s default `CRUMB_IMAGE_PREFIX` is `ghcr.io/badbread/crumbvms`, the same value `.env.example` documents, and the packages are public, `docker compose pull` works from a clean machine with no login. Desktop (two real gaps): `apps/desktop/src-tauri/tauri.conf.json` has `bundle.active:true, targets:"all"` (will produce NSIS+MSI on Windows) but NO `bundle.windows` signing block. CRITICAL: the app loads `libmpv-2.dll` from next-to-the-exe at runtime, and that DLL is not in the repo and not in `bundle.resources`, an installer built today ships an app that can't play video. No desktop bundle has been built here. CI does not build the desktop. -Android (signing not wired): the release buildType has R8 `isMinifyEnabled=true` + `isShrinkResources=true` + proguard, but no `signingConfigs{}`, no keystore, and a hardcoded `versionCode=1`/`versionName="0.1.0"`. Only a debug APK is produced today. CI does not build the APK. +Android, signed releases shipped: the release buildType has R8 `isMinifyEnabled=true` + `isShrinkResources=true` + proguard, and a conditional `signingConfigs{}` that activates when a keystore is available (`app/build.gradle.kts`), never breaking a plain debug build when it isn't. `.github/workflows/android-release.yml` builds `assembleRelease` on a version tag, signs it from a base64 keystore CI secret, verifies the signature, and uploads the signed APK plus its SHA-256 checksum to the GitHub Release. Versioning is no longer hardcoded: `apps/android/version.properties` drives `versionCode`/`versionName` and is bumped as part of the release-prep commit (currently `VERSION_CODE=5`, `VERSION_NAME=0.2.0`). #### Where we are going @@ -200,10 +213,10 @@ Phase 0, Decisions (prereqs) (S) Phase 1, Backend deploy-by-pull (M) -- [ ] Set CI repo/org var `REGISTRY=ghcr.io/crumbvms`; mark packages PUBLIC (flips the existing `images` job to push, no rewrite) (S) -- [ ] Cut the first `v0.1.0` git tag; confirm CI pushes recorder/api images tagged v0.1.0 + sha (S) -- [ ] Verify the stock `docker compose pull && docker compose up -d` path from a clean machine with NO registry auth (S) -- [ ] Pin third-party images (postgres:16-alpine, eclipse-mosquitto:2, alexxit/go2rtc) by digest in the shipped compose (S) +- [x] Set CI repo/org var `REGISTRY`, packages PUBLIC (shipped as `ghcr.io/badbread/crumbvms`, not the originally proposed `crumbvms` org) (S) +- [x] Cut the first git tag; CI pushes recorder/api images (past v0.1.0, currently at 0.2.0) (S) +- [x] Verify the stock `docker compose pull && docker compose up -d` path from a clean machine with NO registry auth (S) +- [ ] Pin third-party images (postgres:16-alpine, eclipse-mosquitto:2, alexxit/go2rtc) by digest in the shipped compose, still by tag, not digest (S) Phase 2, Air-gapped (offline tarball) (S) @@ -222,10 +235,10 @@ Phase 3, Desktop self-contained + signed installer (M) Phase 4, Android signed sideload APK (S) -- [ ] Add release `signingConfigs{}` in `app/build.gradle.kts`; store the keystore as a base64 CI secret (never in repo) (S) -- [ ] Replace hardcoded `versionCode=1` with a CI-injected monotonic build number (S) -- [ ] Add a CI `android` job → signed release APK (R8 already on) (S) -- [ ] Publish the APK via GitHub Releases; document install + the Unknown-Sources step; evaluate F-Droid later (S) +- [x] Add release `signingConfigs{}` in `app/build.gradle.kts`; store the keystore as a base64 CI secret (never in repo) (S) +- [x] Replace the hardcoded version with a real one, shipped as a committed `version.properties` bumped per release, not a CI-injected monotonic counter (S) +- [x] Add a CI `android` job (`android-release.yml`) → signed release APK (R8 already on) (S) +- [x] Publish the APK via GitHub Releases with a SHA-256 checksum; F-Droid not evaluated (S) Phase 5, Unified release pipeline + user docs (M)