## Changelog ### v0.148.0 — coherent snapshot pairs + an offsite restore that actually restores (2026-07-19) Closes **R-43** and **R-44**, the two findings from `DIAG-immich-restore-2026-07-19`. The short version of that diagnosis: Viktor deleted 11 immich photos to test offsite restore, both restore runs flashed success, and the photos stayed gone. Two independent defects, both fixed here. **R-43 — no offsite path could restore a database.** All three offsite buttons were file-only. The two „visszaállítás" actions staged into a scratch folder and never touched postgres; the place-to-live action merged only files MISSING from the live tree and never replayed a dump. For a DB-indexed app — most of the catalog — that combination cannot bring content back: the bytes return and the app still cannot see them, because its index lives in the database. The dump was faithfully carried INTO every snapshot and could never be replayed OUT of one. New: **„Teljes visszaállítás (fájlok + adatbázis)"** (`ReconstituteFromOffsite`, `/backup/offbox/reconstitute`). Safety dump → stop → files overwritten to the snapshot's version → start → the snapshot's own dump replayed → health wait. Two invariants: - **Nothing is ever deleted.** The full-restore copier is `rsync -a` with NO `--ignore-existing` (a changed file becomes the snapshot's version) and NO `--delete` (a file created after the snapshot survives as an extra). A restore that silently removed newer work would be a data-loss event wearing a recovery button's label. - **The undo exists before the act.** A `pre-restore-` dump of the live database is written and verified on disk BEFORE anything is stopped, overwritten or replayed; if it cannot be taken the whole operation refuses with zero changes. The safety dumps live in the app's own unit and appear in `ListDumpFiles` — an undo the customer cannot see is not much of one. The replay reads the SCRATCH unit, not the live one: the live recovery unit is still never overwritten (it is the local restore path's source), so replaying from it would replay the current database back over itself and restore nothing. **R-44 — a manual push shipped an unrefreshed dump.** `RunOffboxBackup` went straight to the restic push; dumps came only from the separate 02:30 local run, so a manual push at any other hour shipped a dump up to ~24h old. On 2026-07-19 that dump was taken four hours before the customer's account existed and probed to `asset: 0 / user: 0 / album: 0` — a 52MB file whose entire bulk was immich's shipped geodata tables. Size and table count both called it healthy. Every offsite run — **manual and nightly** — now refreshes the dumps and recovery units FIRST, then captures. Order is the mechanism: the gap can only ADD files the DB does not reference yet (a harmless orphan blob), never remove one it does, so the file set is always a superset of what the restored DB points at. This also makes the nightly ordering structural instead of a coincidence of two scheduler entries at 02:30 and 04:15. Each unit manifest carries the run's `offsite_run_id` + `dumps_at`, so a snapshot's coherence is verifiable at restore time rather than assumed. **Honesty surfaces** (warn-level, never gates — a false positive that blocked a restore would be worse than the skew it guards against): - A pre-v0.148 snapshot has no stamp → the confirm says „Az adatbázis-mentés régebbi () — a fájlok és az adatbázis eltérő időpontból származnak." It still restores. - `ValidateDump` gained a content sniff: a structurally valid dump whose accounts table has zero rows raises „A mentett adatbázis üresnek tűnik". Exact table-name matching, deliberately — a substring match on "user" would flag `user_metadata` / `album_user` / `user_audit` on every healthy single-user box and turn the signal into noise. - The completion flash states an OUTCOME, not a mechanism: „A(z) X: N fájl és az adatbázis visszaállítva (mentés: ) — az alkalmazás újraindult." A no-database app says so explicitly rather than borrowing the confident sentence. - The old missing-only button now says what it does NOT do: „Adatbázist nem állít vissza — törölt tartalom ettől nem jelenik meg újra." A new `dump` progress phase („Adatbázisok mentése a pillanatképhez…") names the pre-phase, which on a large database dominates the early wall clock and would otherwise read as a hang. Tests: 11 new, with **5 red-proofs run and reverted** — replay removed (0 replayed), capture moved before the dump (`[capture dump]`), both undo guards removed (no refusal), substring table matching (join tables mistaken for accounts), buffer-exceeding rows uncounted (a wide row sniffed as empty). Two of those red-proofs found real test weaknesses rather than confirming strength: the first undo mutation was caught by a second guard, and the first table-matching test did not discriminate between the two matchers at all — both tests were rewritten to the cases that actually separate them. **NOT in this slice:** the catalog-wide invariant check (stays on R-41), nightly cadence, retention, quota math, tier-2, and the v0.147.x progress semantics beyond the one added phase line. The missing-only place button's own zero-file flash is also untouched — that is the v0.147 feedback arc's item, not R-43/R-44. ### docs — the workflow moved to DooPlex-local execution (2026-07-19) **Docs only, no version bump, no code change.** Claude Code now runs on DooPlex (192.168.0.180, Debian 13, `kisfenyo`) instead of the Windows workstation, working directly in `/mnt/5_hdd/felhom.eu/git/felhom-controller`. Builds are local commands; felhom-pve is one SSH hop. - `CLAUDE.md` — environment/access table rewritten (local DooPlex + `ssh felhom-pve`, no `$SSH` variable), build/deploy commands de-SSH'd, workspace-root pointer now `/mnt/5_hdd/felhom.eu/git/CLAUDE.md`. - **New clean-tree gate** in the build section: `git status --porcelain` empty AND `HEAD` == `origin/main` before any build — because the CC working tree IS the tree `build.sh` builds from. An unpushed change does not exist. - `claude-in-chrome` is **not available** on DooPlex — endpoint-level validation ("invoke the exact endpoint the UI invokes") is now the stated standard method; strict UI coverage is a manual pass. - Windows knowledge is preserved, not deleted: a "Legacy: Windows workstation" note in `CLAUDE.md` and `RUNBOOK-e2e-live-drive.md`, and `docs/vscode-ssh-fix.md` carries a LEGACY banner. Historical Windows references in past CHANGELOG/REPORT entries are left untouched — history is history. Platform-genuine mentions (the `_other.go` dev stubs, `\\FELHOM` shares in Windows Explorer, the Windows-grep multibyte rationale in the gate scripts) are unchanged. ### v0.147.3 — 4c follow-up 3: the run does not end with the last app (2026-07-19) Third real run, third thing only a live run could show. The per-app legs finished in ~15 seconds; the remaining **40 of the 57-second run** was the shares leg and `forget --prune` — during which the card sat frozen on „calibre-web — 8 / 8 fájl". The same frozen-looking silence 4c exists to remove, relocated to the end of the run. Progress now carries a **phase**. The post-app stages announce themselves („Megosztott mappák mentése…", „Karbantartás: régi mentések rendezése a távoli tárolón…") and the app-scoped counters are cleared when a phase starts, so the last app's finished numbers are never shown against work that is no longer about that app. Starting the next app clears the phase again. Pinned by a test. ### v0.147.2 — 4c follow-up 2: when NO counter can move, say what is being worked on (2026-07-19) The v0.147.1 file-count fallback fixed the incremental case but not the one the demo box actually hits. Watching a second real run: **bookstack** reported clean byte progress (100%, 154.0 MB, 7/7 files — the byte path works), while **immich** sat at `files_done 1 of 46`, `bytes_done 0`, for 42 seconds. restic 0.14 only counts a file into `bytes_done`/`files_done` when it **completes**, so an app dominated by a single large archive (immich's ~430MB volume tar) freezes *both* counters. No percentage can move in that window. So stop trying to. restic keeps reporting `current_files` and `seconds_elapsed` throughout; the card now shows the file being processed and the elapsed time — „Mentés: immich — 1 / 46 fájl (430.2 MB) · feldolgozás alatt: immich_upload.tar · 42 mp". „Working on this file for 42 seconds" is a completely different message from „0%", and it is the honest one. The last known `current_files` value persists across ticks that omit it (restic does not send it every time, and blanking the label every other second is its own flicker), and switching app clears it so one app's file is never shown against another. Both pinned by tests, along with the real 42-second status line shape. ### v0.147.1 — 4c follow-up: the progress bar must move on an INCREMENTAL run (2026-07-19) Found by watching the v0.147.0 card during a real manual run on the demo box, which is the only way this was ever going to surface. **The observation.** A 430MB immich push reported `0%` for 40+ seconds and then completed. The parser was not broken — restic was genuinely reporting no transferred bytes. On an incremental run where nothing changed, restic transfers nothing: `bytes_done` is `omitempty` on restic's side, so it is not even present in the JSON, and `percent_done` stays 0 for the whole run. Confirmed against the real schema by capturing `backup --dry-run --json` output from restic 0.14.0 in the controller image (the version comment in `offbox_progress.go` now quotes those captured lines verbatim). **Why it mattered.** A byte-only progress bar is indistinguishable from a hang in the COMMON case — the incremental run — which is precisely the silence 4c set out to remove. Shipping it would have replaced "no feedback" with "feedback that says 0% and looks stuck". - `files_done` / `total_files` are now parsed and published alongside the byte counters. They move on an incremental run even when bytes do not. - The card prefers bytes when bytes are moving; otherwise it drives the bar from files and says „N / M fájl ellenőrizve"; only before restic knows a total does it say „a mentendő adatok felmérése…". - `parseResticStatus` now returns a struct rather than four positional values, and a new test pins the real incremental-run line shape (bytes absent, files climbing) so a future refactor cannot quietly drop the file counters and restore the stuck bar. ### v0.147.0 — feedback slice 1: pressing a button says something (2026-07-19) Green: `go build ./... && go vet ./... && go test ./...` all pass (23 packages); `template_id_gate` + `emoji_gate` + `native_confirm_gate` + `offbox_rename_gate` + `app_row_dedup_gate` + `mojibake_gate` all PASS. (`docker_run_volume_path_gate` fails on `internal/appexport/estimate.go:179` — **pre-existing on HEAD, untouched by this release**; verified by stashing this work and re-running.) **The systemic complaint, twice in one evening: you press a button and nothing happens.** No progress, no ETA, no named result. This slice fixes the three worst offenders using the two patterns already in the codebase (the deploy 3-step panel and the storage-init status poll). It deliberately does **not** introduce a feedback framework — that is a ROADMAP item ("unified async-job feedback"), because three targeted cards are worth shipping tonight and a framework is not. - **4a — a verification restore now names its result.** The completion flash said the app had been restored „ellenőrző mappába a meghajtón" — *which* folder, on *which* drive, was invisible, so the customer could not go and look at the thing they had just asked for. It now carries the **full path**. The restore page gained a **„Meglévő ellenőrző másolatok"** listing (app · size · date · path) — until now nothing anywhere showed what these restores had accumulated, so they piled up and the only way to find them was SSH — each with a double-confirmed **„Másolat törlése"**. - That delete is the **only** delete this release adds, so it names a STACK, never a path: the Manager resolves the name inside a `backups/offsite-restore` root it computed itself and refuses anything landing outside. Red-proofed — neutralise the name guard and `stack: ""` resolves to the offsite-restore ROOT and takes every copy with it. Every refusal is asserted as a **non-effect** (the neighbouring copy and the live data are still on disk afterwards). - `backups/offsite-restore` was open-coded in three places; it now has one home (`offsiteRestoreRootFor`), and a test pins the path in the flash to the path in the listing so the customer can never be told about a directory the page cannot show or remove. - **4b — Megosztás enable shows what it is waiting for.** Enabling sharing ran `ReconcileSamba()` **synchronously inside the POST handler**. On a box whose golden had not baked `felhom-samba` that is `compose up -d` pulling ~100MB from a private registry: minutes of an apparently-hung form post, then „Beállítás mentve." whether or not anything had come up. Now detached + polled, with a card that distinguishes **„képfájl letöltése"** (image genuinely absent — the multi-minute case) from **„indítás"** (already baked — seconds). The distinction is decided *before* the work starts, because afterwards the image is always present and the card could never truthfully say „letöltés". - Success is **probed, not inferred**: `compose up -d` exits 0 on a crash-loop, so the terminal state is container liveness. `nil` from reconcile also covers "deliberately deployed nothing because there is no household password yet", which now gets its own message instead of a card spinning forever. - The **password** form starts the same job — with `UserSet` false reconcile deploys nothing, so on a fresh box *that*, not the enable toggle, is where the pull actually happens. - **4c — „Távoli mentés most" streams real progress.** restic was already reporting bytes and percentages; the runner seam used `CombinedOutput()` and threw them away. The manual run now passes `--json`, scans stdout line-by-line, and the page shows **total bytes, percent and the app currently being pushed**. Before the scan finishes it says „a mentendő adatok felmérése…" rather than pinning a bar at 0%, which reads as stuck. - **Manual only.** The nightly run stays silent and its output format is untouched — pinned by a test that fails if the scheduled path ever passes `--json` or publishes progress. - The poll now **arms unconditionally**. It used to start only if the page already rendered „Fut…", which loses a race the manual trigger always runs: the POST redirects and the page renders before the detached goroutine writes `LastStatus=running`, so the poll never armed and the customer watched a static page during the very run they had just started. - Red-proofed twice, both confirmed: break the parser → the percent assertion fails; drop the wiring → the `--json` assertion fails. The `--json` stream is tail-bounded (40 lines) so a large backup does not buffer megabytes of status spam for error diagnosis. - **Golden/controller infra-image drift closed at the source (supports the agent-side change).** `infra.Images()` derives the list from the existing pins, `--print-infra-images` prints it, and the golden bake now asks the controller binary it is about to bake instead of carrying its own copy. The copy had already drifted: `felhom-samba` was never added to it, so the golden baked 3 of 4 — which is *why* enabling Megosztás pulled at runtime. A test parses the const block out of the source and fails if a pin is added without reaching `Images()`. **Live-validated** on demo guest 9201 through the real UI. **No floor change** — Viktor decides floor timing. ### v0.146.0 — nav polish: styled scrollbars + collapsible sidebar groups (2026-07-18) UI-only; no behavioural or backup/restore surface touched. Green: `go build ./... && go vet ./... && go test ./...` all pass; `template_id_gate` + `emoji_gate` + `native_confirm_gate` + `offbox_rename_gate` + `mojibake_gate` + `app_row_dedup_gate` all PASS. - **Scrollbars (`style.css`).** The platform default is a light, chunky bar that reads as a bright stripe against the navy and competes with the content it is scrolling. Now thin and hairline coloured: `scrollbar-width: thin` + `scrollbar-color` for Firefox, `::-webkit-scrollbar` (8px, thumb `--line`, hover `--text-3`, `--radius`) for WebKit/Blink — **both** declared, because neither alone covers the browsers customers actually use. The two surfaces that really scroll take their own panel background as the track (`.sidebar` → `--bg-2`, `html` → `--bg-0`) so the gutter never shows through as a lighter channel. Tokens only, no raw hexes. - **Collapsible nav groups (`layout.html` + `style.css`, vanilla JS — no framework).** Tárhely, Biztonsági mentés and Megosztás are now accordions with a chevron indicator; **exactly one is open at a time**, and clicking the open one closes it. Groups without sub-items (Vezérlőpult, Alkalmazások, Rendszermonitor, Debug) are untouched plain links. Hungarian labels unchanged. - **The header is a real `