## Changelog

### v0.106.0 — offsite provisioning SLICE 2: controller apply-bridge (2026-07-09)

Pairs with hub v0.38.0. On startup the controller reconciles the hub-served `offsite:` descriptor into a
working key-only offbox target — closing the loop to a hands-off, hub-driven offsite target. (Auto-confirm =
SLICE 3; soft-quota = SLICE 4.)

- **`internal/config`:** `OffsiteConfig` (`offsite:` section) mirroring the hub descriptor
  (enabled/type/host/user/port/repo_path/quota_gb/box_type/**host_fingerprint**) — deep-merged from `controller.yaml`.
- **`internal/offsiteapply` (the apply-bridge):** `Bridge.Reconcile` — idempotent (a descriptor-hash marker
  at `<dataDir>/offbox/applied_marker` prevents re-consuming a spent password) and fail-safe (any step fails
  → nothing persisted, retried next cycle). Flow: **scan + VERIFY the box host key against `host_fingerprint`
  (no blind TOFU)** → generate the controller keypair → **consume the one-time password**
  (`POST /api/v1/offsite/consume-password/{id}`, Bearer APIKey, single-use, never logged) → install the
  pubkey (`sshpass -e ssh-copy-id -p 23 -s -f`, **pinning the scanner-verified `known_hosts` with
  `StrictHostKeyChecking=yes` — no `accept-new`/TOFU on the install or verify session**, so a MITM cannot
  substitute a key in the gap between the scan and the install) + verify key auth → configure the offbox target →
  `EscrowState="pending"` (fork-4 enable path via `Manager.ApplyOffsiteTarget`) → persist the marker LAST.
  Seams (consume/scan/keygen/install/enable) so unit tests fake all I/O. A consumed-but-failed install logs a
  loud "password is spent — reset on the hub" signal.
- **`internal/backup`:** `Manager.ApplyOffsiteTarget` reuses `WriteOffboxSecrets`/`SetOffboxTarget`/
  `PushOffboxPasswordForEscrow` → `EscrowState="pending"`; the escrow stage-push is best-effort (agent-down ≠ apply failure).
- **`cmd/controller`:** wires the bridge (real seams — HTTP consumer, x/crypto/ssh host-key scanner, ed25519
  keygen, sshpass installer) and runs `Reconcile` async at startup (non-blocking; the config-refresh restart re-runs it).
- **`Dockerfile`:** + `sshpass`.
- Tests (faked seams): apply-end-to-end (pinned known_hosts + key + pending + marker + **pw-not-logged**);
  host-key mismatch → refuse **+ companion red-proof** (drop the verify → wrong key pinned → test fails);
  idempotent (marker match → no re-consume); install-fail → fail-safe **+ companion red-proof** (persist
  marker early → failed apply looks done → test fails).
- **NOT yet live-applied** — the supervised end-to-end (hub provisions on the new pool box → controller
  consumes + installs + configures) is the next runbook, gated on the hub's new scoped `HETZNER_TOKEN`.

### v0.105.0 — fork-4: offsite password custody hand-off + atomicity gate + DR inject + DR coord (2026-07-09)

Pairs with agent v0.77.0 to make the restic-offsite repo password recoverable at DR (rides the customer-R
escrow) and forbids an un-escrowed offsite copy from existing. Validated design: custody spike `febdc56`.

- **Hand-off** (`internal/agentapi/client.go`): `StageEscrowSecret` pushes the repo password to the agent's
  `POST /escrow/stage-secret` over the authenticated pinned local-API channel (value never logged). The
  enable flow (`internal/web/offbox_handlers.go`) reads the 0600 password via a new
  `Manager.PushOffboxPasswordForEscrow` (the handler never sees the value) and marks `EscrowState="pending"`.
- **Atomicity gate** (`internal/backup/offbox.go`): `OffboxRunnable()`/`offboxEscrowed()` — `RunOffboxBackup`
  (and thus the daily scheduler + the run handler) **refuses to run until `EscrowState=="escrowed"`**, so no
  un-recoverable offsite ciphertext can exist. `OffboxConfigured()` is unchanged (config/UI still work).
  New `settings.OffboxTarget.EscrowState` (`""|"pending"|"escrowed"`, additive, preserved across edits).
- **Confirm + DR inject** (`internal/web`): `POST /backup/offbox/confirm-escrow` (operator, after the escrow
  ceremony) → escrowed; `POST /backup/offbox/inject-password` (DR) → `Manager.InjectOffboxPassword`
  pre-places a recovered 64-hex password 0600 (tmp+rename), refusing to clobber without `force` — a
  subsequent `WriteOffboxSecrets` then uses it (the pre-place seam). `/backups` shows a pending-escrow
  notice + confirm button.
- **DR recipe** (`internal/report/dr_recipe.go`): `DRRecipeAppHalf.OffsiteRestic *DRResticCoord`
  {host,user,port,repo_path} — coordinates ONLY (the password is escrowed, the SFTP key is regenerable);
  populated from `Manager.OffboxCoord()`; clears the `_NoSecrets` regex.
- Tests: atomicity (pending blocks run; confirm enables) **+ companion red-proof** (gate disabled → runs
  while pending → FAIL); DR inject pre-place honored + refuse-clobber + companion (no-inject generates a
  DIFFERENT password); `OffboxCoord`; agent stage endpoint (0600 + non-secret ack + cross-guest 403 +
  value-not-in-log); `DRResticCoord` no-secrets. Web: run-gate + confirm + inject endpoints.
- **NOT yet live-validated** — the supervised escrow ceremony (enable→stage→escrow-create→confirm→gated run)
  is the operator-run follow-up.

### v0.104.0 — off-box unit discovery (durable, deployment-independent) + no-silent-success (2026-07-09)

Fixes the off-box mis-resolution + silent-success landmine surfaced by the Storage-Box spike and pinned by
the DIAG report (`felhom.eu/documentation/audits/SPIKE-storagebox-restic-direct-2026-07-09.md`). Root cause:
`runOffboxInternal` located each toggled app's recovery unit via `RecoveryUnitPath(AppNamespaceRoot(stack),
stack)`; `AppNamespaceRoot`→`GetAppDrivePath` reads the app's **live** `app.yaml` `HDD_PATH` and returns `""`
for a not-currently-deployed app, **silently falling back to `systemDataPath`**. So a toggled-but-undeployed
app was looked for on the wrong drive → `os.Stat` failed → skipped → the run returned `nil` → status `ok`
with 0 snapshots (no alert).

- **Discovery over inference** (`internal/backup/offbox.go`): new `discoverOffboxUnit` / `offboxCandidateNSRoots`
  scan the durable storage registry — every registered *schedulable, non-decommissioned* path
  (`GetSchedulableStoragePaths`) ∪ `systemDataPath`, deduped by resolved nsRoot — for `backups/primary/<app>`,
  independent of live deploy state. Multiple copies of the same unit (drive churn) → the **newest by manifest
  `CreatedAt`** (mtime fallback) is backed up, the stale one WARN-logged. `AppNamespaceRoot` and the primary
  WRITE paths (`CaptureRecoveryUnit`/dumps) are **unchanged**.
- **No silent success** (`RunOffboxBackup`): `runOffboxInternal` now returns `(backedUp, missing, err)`.
  ≥1 toggled but `backedUp==0` → a **hard error** (`LastStatus="error"` + `offboxNotify` fires with a non-nil
  err → operator alert). A *partial* run stays `ok` but sets a new customer-visible **`OffboxTarget.LastWarning`**
  (`internal/settings/settings.go`, `last_warning,omitempty`) naming the skipped apps; rendered on `/backups`
  in the `--warn` style (`internal/web/templates/backups.html`), preserved across a config edit
  (`internal/web/offbox_handlers.go`).
- Tests (`internal/backup/offbox_test.go`): six non-hollow cases (A discovery-on-registered-drive, B 0/N
  hard-error+alert, C partial→warning, D newest-of-two-copies, E happy path, edge 0-toggled), asserting the
  exact discovered `src` + `LastStatus`/`LastError`/`LastWarning` + notify-err. **Companion red-proofs run:**
  (A) reverting to `AppNamespaceRoot` resolution → 0 backups → FAIL; (B) `if false` on the 0/N promotion →
  silent `ok` → FAIL; both reverted.
- **NOT yet live-validated against the Storage Box** — awaiting supervised re-provision + endpoint round-trip
  (box repos/creds were torn down with the spike). Unit suite fully covers the discovery + status logic.

### v0.103.0 — F-C2-1: config loader no longer corrupts a bcrypt password_hash (silent auth bug) (2026-07-07)

Fixes campaign-2 finding **F-C2-1** (`felhom.eu/documentation/tests/CAMPAIGN-2-2026-07-07.md`).
`loadAndParse` and `LoadFromBytes` ran `os.ExpandEnv` over the **entire** YAML before parse. A bcrypt
hash (`$2a$10$…`) is full of `$word` sequences, so `ExpandEnv` silently replaced each with its (usually
empty) env value — corrupting `web.password_hash` on load (proven: `$2a$10$N9qo8uL…` → `"a0"`). A silent
auth-integrity bug.

- **Fix:** removed both `os.ExpandEnv` calls (`config.go` :234 loadAndParse, :249 LoadFromBytes) — parse
  the raw bytes directly. The sanctioned, typed env path (`applyEnvOverrides` → `FELHOM_WEB_PASSWORD_HASH`,
  applied after parse) is unchanged; no shipped `controller.yaml` relies on file-level `${VAR}`
  interpolation (only `docker-compose.yml` uses `${DOMAIN}`, which is compose-level).
- **Behavior change:** a literal `${VAR}` in a controller.yaml value is now preserved verbatim (was
  expanded). No repo config depends on the old behavior.
- Tests (`config_test.go`): bcrypt hash loads byte-identical (file + bytes paths; red-proof: pre-fix
  `ExpandEnv` mangles it to `"a0"` → FAIL, demonstrated + reverted); `FELHOM_WEB_PASSWORD_HASH` override
  still wins; literal `${VAR}` preserved.

### v0.102.0 — async restore family: no more proxy-timeout error page on a succeeding restore (2026-07-06)

Re-adjudicates campaign **F4** (`felhom.eu/documentation/audits/RERUN-p1p3-2026-07-06.md`): all three
restore surfaces (`/backup/restore`, `/backup/tier2/restore`, `/backup/offbox/restore`) blocked the
HTTP request until completion. Through cloudflared's hard 100s cap + traefik, a real customer got an
**error page while the restore silently succeeded**; the off-box one was worse — it bounded on
`r.Context()`, so a proxy read-timeout **canceled the SFTP restore mid-flight**.

- **Async family** (mirrors the existing `offboxRunHandler` shape): each handler fast-path refuses a
  concurrent op (`IsRunning()` → "Egy mentési/visszaállítási művelet már fut."), then runs the restore
  in a **background goroutine** and redirects immediately with a "Visszaállítás elindult…" flash. The
  offbox restore's context moved from `r.Context()` to `context.Background()+30m` (fixes the mid-flight
  cancel). The restore functions' internal single-flight acquire is unchanged.
- **Op-status surface** (`internal/backup/opstatus.go`): mutex-guarded in-memory current-op + terminal
  `last{op,stack,ok,message,finished_at}`, deep-copy getter; new `GET /api/backup/restore-status`
  (distinct from `/backup/status`, which proxies the agent's PBS status). In-memory, lost on restart
  (same precedent as notification cooldowns).
- **UI** (`backups.html`): a progress banner polls the status every 3s — neutral while running (shown
  even on a fresh page load mid-op), success on completion, red **only** on failure.
- Tests: `opstatus_test.go` (begin→running→terminal, deep-copy, failure); `async_restore_test.go`
  (handler returns <500ms while the restore parks in a blocking provider + op-status transitions;
  double-click refused with no second launch). Red-proof: the pre-fix synchronous handler blocks the
  request indefinitely (test killed at 30s) vs <500ms async.

### v0.101.0 — campaign findings F3 (sync deadline) + F2 evidence gap (agent refusal surfacing) (2026-07-06)

From the no-mercy campaign (`felhom.eu/documentation/audits/CAMPAIGN-nomercy-2026-07-06.md`).
No behavior change for the happy paths; two robustness/diagnosability fixes.

- **F3 — sync git subprocess deadline** (`internal/sync/sync.go`): `runGitInDir` had no context,
  so a hung remote parked the sync goroutine in `cmd.Run()` — the `doSync` defer never ran,
  `syncing` stayed true, and every manual + periodic sync was refused with "Szinkronizálás már
  folyamatban" until a controller restart. Each git command now runs under
  `exec.CommandContext` with a fresh per-command `gitCmdTimeout` (120s); the deadline error names
  the timeout and the masked git args (no silent hang). Debounce + failed-sync-arms-debounce
  unchanged. Tests: `TestRunGitInDir_CancelledContextKillsSubprocess` (red-proof: pre-fix
  `exec.Command` runs to completion → FAILs), `TestTriggerSync_FailureReleasesSyncingAndAllowsRetry`.
- **F2 evidence gap — agent refusal surfacing** (`internal/agentapi/client.go`): `EjectDisk` and
  `Decommission` used `c.post`, which discards a non-2xx body — so the agent's informative refusal
  (`"…decommission refused (role: system)"`) was flattened to a bare `HTTP 403` (the exact campaign
  evidence). Both now use `postWithStatus` + a shared `refusalError` that carries the agent's
  reason (truncated ~300, no bodies/secrets) through to the controller's Hungarian error and the
  UI/API response. The generic `post` and all other callers are untouched. Tests: T-D1/T-D2
  (fake-agent 403 → reason surfaced; red-proof: pre-fix `c.post` yields bare `HTTP 403` → FAILs),
  T-D3 success unchanged, plus an ok:false 2xx-business-refusal case.

### v0.100.0 — one-click class-C file restore from the Tier-2 copy (2026-07-05)

TASK C2 — closes drill finding **F2** (`DRILL-appdata-restore-2026-07-04.md` §4): HDD bind-mount
user files (`appdata/<stack>`) had no customer recovery path — Tier-2 protected them nightly, but
getting deleted files back was an operator copy-back by hand.

- **Engine** (`internal/backup/tier2_restore.go`): `Manager.RestoreTier2Files(stack)` — in-place,
  **additive-only** restore from the RECORDED Tier-2 copy (`CrossDriveBackup.DestinationPath`, never
  a fresh `selectTier2Target`). Semantics = `rsync -a --ignore-existing`: files missing live are
  copied back; existing live files are NEVER overwritten (a customer edit after the last copy wins);
  nothing is EVER deleted (the `rsyncMirror --delete` trap in this direction would erase every file
  created since last night — the new `rsyncRestoreMissing` copies the mirror's exec shape with the
  opposite-direction flags). Single-flight with backup/restore; all refusals (no copy / LastRun
  empty / copy dir gone / either drive disconnected / live drive decommissioned) happen BEFORE the
  stop, with customer-readable Hungarian reasons; stop → copy → start → health; copy/restart errors
  surface (F17). File count from `--itemize-changes` (`>f` lines); file names never logged at INFO.
- **Endpoint + UI**: `POST /backup/tier2/restore` (`internal/web/server.go` + `handlers.go`,
  backupRestoreHandler-shaped guards) + a **"Fájlok visszaállítása"** button on the healthy Tier-2
  layer row (`templates/backups.html`; hidden when unconfigured / never ran / target drive
  disconnected/inactive) with a confirm dialog stating the additive-only contract + last-copy time.
  Zero files copied = success ("Nincs hiányzó fájl — minden fájl megvan a helyén."), not an error.
- Out of scope by design: overwrite/point-in-time restore (offbox + operator paths), per-file
  selection, `recovery-unit/` (backup artifacts are not user files). Apps that index their data dir
  (e.g. Nextcloud) may need a rescan before restored files appear in their own UI — noted in
  `felhom.eu/documentation/controller/backup-architecture.md`.
- Tests: orchestration via a `restoreFilesCopier` seam (stop→copy→start order, src/dst contract,
  refusal NON-effects: never stopped, copier never invoked), Scenario-D zero-copy success, itemize
  parsing, handler guards, and an FS-level semantics test of the real rsync (LookPath-skipped where
  rsync is absent). Companion red-proof: swapping the flags for `rsyncMirror`'s mirrors the backup
  over live — the differing live file gets clobbered AND the live-only file gets deleted (both
  assertions red; verified on the build server, reverted).

### v0.99.0 — restore-path fixes: dead restore UI + volume dumps + blank-secret redeploy (2026-07-05)

TASK C1 — fixes F1/F3/O4 from the 2026-07-04 restore drill
(`felhom.eu/documentation/audits/DRILL-appdata-restore-2026-07-04.md`). F2 (one-click in-place
class-C restore) deliberately NOT included — product-design work (C2).

- **F1 (HIGH — the restore panel was dead):** `GET /api/backup/snapshots?stack=` now exists
  (`internal/api/router.go` + `backup.Manager.ListRestorePoints`, `internal/backup/restore_points.go`).
  The backups.html restore panel fetched this restic-era route, got the catch-all 404, so the
  snapshot dropdown never populated and "Visszaállítás indítása" could never enable. Returns the
  ONE honest keep-side restore point (the current recovery unit): `time` = newest artifact mtime
  (manifest / db-dumps / volume-dumps), `short_id:"helyi"`, `tier:1` always (Tier-2 copies are NOT
  restorable via POST /backup/restore — never listed), `drive_label` from the storage registry.
  Guards: traversal/empty → 400 (`validStackParam`), unknown stack → 404, no unit yet → `ok:true, data:[]`.
  No template change needed — the JS payload contract was honoured server-side.
- **F3 — named-volume data was never backed up:** `DumpAppVolumesSafe` had no production caller.
  New `runVolumeDumps` loop in `runDBDumpsInternal` (`internal/backup/backup.go`), running BEFORE
  `captureAllRecoveryUnits` so manifests enumerate the fresh tars. Gate order is load-bearing:
  protected-stack + has-volumes checks precede the Safe call (which stops the stack before its own
  check — unconditional calls would bounce every volume-less app nightly); disconnected/decommissioned
  drives skip like the DB loop. Failures land in the run summary and fail the run (no silent
  partial). Zero-DB early return removed (volume-only apps still get dumps + unit refresh).
  Test seam: `dumpVolumesSafe` func field (F17-style).
- **O4 — missing resettable secret redeployed blank:** the restore proceed-path now generates a
  replacement credential via the catalog field's `generate` spec (`stacks.Manager.GenerateSecretForField`
  → `backup.SetSecretGenerator` seam, wired in main.go), persisted encrypted through the existing
  `RecreateStackFromUnit` → `SaveAppConfig` path. Data-keys are NEVER generated (gate untouched +
  generator refuses `data_key` fields); values never logged. No-generator fields keep proceeding
  with an upgraded "may fail to start" WARN. Residual case documented: a restored volume tar
  carrying the OLD internal credential hash may still need a manual in-DB reset.

Tests: 272 → 286 top-level test funcs (+14: api snapshots ×3, backup restore-points ×4, volume-dump gating ×3, backup secret-gen ×3, stacks secret-gen ×1);
all three fixes companion-red-proofed (hollow `[]` endpoint / removed volume gate / no-generation
each fail their test). Full `go build && go vet && go test ./...` green.

### docs — CLAUDE.md refresh: slim-down to stable orientation (2026-07-03)

No code change, no version bump. CLAUDE.md 338 → ~160 lines: full 30-package layout map (was 7);
stale bare-metal `/opt/docker` deploy steps replaced with the verified 9201 bootstrap deploy
(`/etc/felhom-controller-image` + `felhom-controller-bootstrap.service`); embedded hub build section
deleted (points to felhom.eu); deep runbooks/design/testing content moved to the new skills
(`felhom-build-deploy`, `felhom-ui-design`, `felhom-testing` — source `felhom.eu/skills/`); "Key
patterns"/"lessons" pruned to session-critical invariants (rest live in REUSE.md). Standing rule
adopted: CLAUDE.md carries no version-pinned current state — that lives in CONTEXT/CHANGELOG/REUSE.

### docs — REUSE.md introduced (2026-07-03)

Cross-repo reuse-map rollout (docs-only, no code change, no version bump). New `REUSE.md` at the
repo root: curated map of canonical helpers (62 rows), patterns, dangerous lookalikes (rsyncMirror
`--delete`, raw os.RemoveAll on drive paths, fresh agentapi.New per request…), test seams, extension
points, and observed duplication (12 clusters, NOT fixed). Every entry code-verified at file+symbol;
cited paths machine-checked by `felhom.eu/scripts/reuse_refs_check.py` (green). CLAUDE.md gains the
"See REUSE.md before writing new code" pointer + the same-commit maintenance rule.

### v0.98.3 — hide "Eltávolítás a listából" on wizard-enrolled drives (2026-07-02)

User feedback follow-up: list-removal (registry-entry delete; data + mount untouched) is only
meaningful as the undo of a MANUAL path add. On a wizard-enrolled drive (/mnt/felhom-drives/) the
resulting de-registered-but-still-agent-bound limbo is never what the customer wants — its real
lifecycle is Biztonságos leválasztás / Végleges leszerelés. New `StoragePathView.IsEnrolled`
(path-prefix check) gates the button; manually added paths keep it, and the decommissioned-branch
"Eltávolítás a rendszerből" (final cleanup) is unchanged. Endpoint untouched.
Test: enrolled card must not render the remove form, manual card must (TestListRemovalHiddenForEnrolledDrives).


### v0.98.2 — drive-card action clarity: dedupe + self-documenting labels (2026-07-02)

User feedback: two "Leválasztás" buttons per drive, and four near-synonymous labels (Letiltás /
Leválasztás / Eltávolítás / Leszerelés) for very different operations. storage.html only:

- **Dedupe:** the agent-level eject no longer renders on a card that already offers the registry
  safe-disconnect (enrichCard passes `hasSafeDisconnect`, detected from the card's own
  storageDisconnect button) — one detach affordance per card. Non-USB registered drives (no
  safe-disconnect) and unregistered drives keep the agent eject.
- **Labels + tooltips (endpoints unchanged):** Letiltás/Engedélyezés → "Új telepítések
  letiltása/engedélyezése"; registry Leválasztás → "Biztonságos leválasztás" (apps stop, safe
  unplug, reconnectable); Eltávolítás → "Eltávolítás a listából" (registry-entry removal only,
  data untouched); Leszerelés → "Végleges leszerelés" (permanent, optional migrate-first); agent
  Törlés… → "Formázás…" (that's what it does). Every action button carries an explanatory `title`.


### v0.98.1 — drive-card spacing: enrichment rows no longer touch (2026-07-02)

User feedback: on the Meghajtók cards the agent tag row ("Felhasználói adat", "lassú", uuid) and the
agent action row ("Leválasztás", "Törlés…") rendered with zero vertical gap. The
`.drive-agent-extra` slot is now a flex column with a .6rem gap (+ .6rem top margin, hidden when
empty); the inline margin in `enrichCard` dropped in favor of the slot styles; metarow horizontal
gap tightened to .75rem. CSS + one JS-string line only (storage.html, style.css).


### v0.98.0 — storage IA follow-up: Meghajtók / Hálózati tárhely subpages (2026-07-02)

User feedback on the D1 Tárhely page: the NAS-add button rendered directly next to the local-drive
enrollment buttons ("Új meghajtó inicializálása" / "Meglévő meghajtó csatolása") — two different
storage classes confusingly interleaved. The page splits into two subpages under Tárhely:

- **`/storage` — Tárhely — Meghajtók** (storage.html): physical drive registry + unified agent view
  + migrate + wizard entry points + manual add. The enrollment buttons now live unambiguously in the
  local-drive context.
- **`/storage/network` — Tárhely — Hálózati tárhely (NAS)** (storage_network.html, new): the NAS
  share list ("NAS-megosztások") + add form + its JS moved verbatim (incl. its own `openDialog` copy
  for the remove overlay).
- **layout.html:** the Tárhely main-nav item gains two always-visible nested sub-links (Meghajtók /
  Hálózati tárhely; `.nav-links-nested` CSS); the parent stays highlighted on both subpages.
- **handlers.go / server.go:** `NetworkStoragePaths` moved from `storagePageData` into the new
  `networkStoragePageData` (page key `storage-network`) + `storageNetworkPageHandler`;
  `GET /storage/network` route. No `/api/storage/*` change.
- Tests updated: `/storage` must NOT render the NAS section, `/storage/network` renders it and
  nothing drive-related; the section inventory + no-native-confirm scans cover the new template.
  Both template gates green; `go build/vet/test ./...` green (18 pkgs).
- Live-validated on 9201: both subpages render with correct sidebar active states; the NAS add-form
  toggle + `nsToggleSmb` + `openDialog` exercised on the new page.


### v0.97.0 — TASK-D1: settings split + Tárhely page (unified drive view) (2026-07-02)

The 1451-line settings monolith becomes four pages; storage is promoted to a first-class main-nav
page with a unified (registry + agent) drive view; native browser dialogs migrate to the overlay
pattern. **IA/appearance only** — no `/api/storage/*` payload, storage semantics, or agent-client
change. Four commits (d50a919, f8e18a9, cb6f04c, 622d932).

- **Routing (server.go):** new `GET /storage` (Tárhely), `GET /settings/notifications` (GET→page /
  POST→save split on the same path), `GET /settings/security`; the enrollment wizards move to
  `/storage/init` + `/storage/attach`, with **301** permanent redirects from the old
  `/settings/storage/{init,attach}`.
- **Data builders (handlers.go):** `settingsData()` decomposed into `settingsBaseData` +
  `systemPageData` / `storagePageData` / `notificationsPageData` / `securityPageData`; each GET
  handler and each error-re-rendering POST handler uses exactly its page's builder + template. All
  five storage action redirects now land on `/storage?storage_msg=…`.
- **Template split:** `settings.html` deleted; sections moved verbatim into `settings_system.html`
  (Rendszer konfiguráció, Verzió és frissítés, Vezérlő/Kiszolgáló újraindítása),
  `settings_notifications.html` (Értesítések, Alkalmazás-email), `settings_security.html` (Jelszó
  módosítás, Földrajzi korlátozás, Vészhelyzeti információk — misspelled heading + section copy
  accents fixed), and `storage.html`. The NAS + migrate sections (previously nested inside
  `{{if .StoragePaths}}` and invisible with zero drives) are now unconditional on `/storage`.
- **Sidebar (layout.html):** Tárhely main-nav item (hard-drive icon) + a "Beállítások" group with
  Rendszer / Értesítések / Biztonság és hozzáférés sub-links (active state per page); orphaned
  `.sidebar-settings-link` CSS deleted, `.nav-group-label` / `.nav-links-sub` added.
- **Unified drive view (storage.html):** registry cards render server-side as before; the agent
  `/api/disks` list ENRICHES each connected user-data card in place (role tag via `i-lock`, drive
  class, durable-id mono line, agent-only register/eject/wipe actions) joined on mount path — one
  card per drive. Two extra groups: **Rendszermeghajtók** (system/backup, read-only, lock tag, no
  actions) and **Nem regisztrált meghajtók** (register action only). Agent-down → one warn note
  (`Az ügynök nem elérhető…`), all registry cards still render (graceful degradation). Agent-view
  helpers emit design-system `.tag` markup (no `.badge`); the 🔒 emoji is gone.
- **Overlay migration:** every native `confirm()`/`prompt()` on the four pages routes through a
  light `.confirm-overlay` dialog (`openDialog`; texts verbatim) — storage remove forms,
  netStorageRemove, storageMigrateAll, storageDisconnect, storageDecommission (migrate + the
  type-to-confirm anyway branch preserved like-for-like), storageReEnroll, triggerUpdate,
  controller/server restart, and the two geo Hungary-removal confirms. One froze a browser tab
  during D0 validation; none remain.
- **D0 leftovers:** the D0 grep gate false-negatived multibyte emoji on Windows — a Python
  codepoint gate (`scripts/emoji_gate.py`) found and removed **8** survivors (📁🔄🔒📦 across
  backups/debug/deploy/storage; the ★ default-marker → „(alapértelmezett)"). Orphaned
  `.badge-lock`/`.lock-ico` CSS deleted (grep-zero first).
- **Gates & tests (+8):** `scripts/template_id_gate.py` (JS element-ID integrity — every
  `getElementById`/`querySelector('#…')` resolves in its own template; red-proven by a misplaced
  function), `scripts/emoji_gate.py` (0), Go tests for the four-page render + cross-leak, the h3
  section inventory (all 11 old headings accounted for), 301s, storage redirect + flash, password
  inline re-render, no-native-confirm scan, agent-down warn-note, and codepoint emoji scan.
  Redirect + inventory tests red-proven against pre-split code. `go build/vet/test ./...` green.
- Live-validated on 9201 via claude-in-chrome: all four pages + the 301 redirect, the unified view
  (3 enriched cards with role tags + durable-ids, Rendszermeghajtók group read-only with 0 action
  buttons), the Leválasztás overlay opened + cancelled (drive untouched), and a full label-rename
  round-trip through the real UI (flash on /storage, renamed back). NOT live-validated: agent-down
  degradation (static/unit only — the agent must not be stopped on the live host); destructive
  storage ops (endpoints unchanged; the moved UI paths await a supervised session).

### v0.96.0 — TASK-D0: design system v2 re-skin (appearance only) (2026-07-02)

Full customer-UI re-skin to the approved Felhom design system v2 — navy token palette, exception-based
status color, vendored fonts/icons, flat metadata. **Appearance only:** no route/handler/IA changes;
every page keeps its URL, sections, forms and behavior. Canonical reference:
`felhom.eu/documentation/design/design-system.md`. Four commits (b073cc4, 5dc277f, f100cef, 7df061c)
+ a bug-fix (4906524).

- **Vendored assets (`internal/web/static/fonts/`, `templates/icons.html`, `embed.go`, `server.go`):**
  Plus Jakarta Sans + JetBrains Mono as variable woff2 (latin + latin-ext — ő/ű), embedded and served
  from `/static/fonts/` (font/woff2, immutable cache); Google Fonts `@import` removed (CDN silently
  broke offline nodes). Vendored 30-icon Lucide sprite included at top of `<body>`; all emoji replaced
  by sprite icons or plain text (templates AND JS-built strings).
- **Setup CSS fix (`internal/setup/handlers.go`):** `handleCSS` served a dataDir-derived filesystem
  path that never exists in the container — production setup mode silently fell back to `minimalCSS`.
  Now serves the embedded `web.StyleCSS()` (new accessor); minimalCSS only if the embedded read errors
  (logged). `minimalCSS` retokened to v2.
- **`templates/style.css` (rewritten in place):** v2 `:root` tokens; single 2px radius; every
  `box-shadow` + the bg grid overlay deleted; new components — `.meter` (3px hairline track, blue
  nominal fill, neutral 70/85 ticks, warn/crit `.meter-flag` „Fogyóban a hely" / „Kritikusan kevés
  hely"), `.tag` (square state chip + dot, pulse on progress, reduced-motion respected), `.metarow`,
  `.panel`/`.list`/`.section-h`, boxless `.stats`, buttons (danger = crit outline until confirm),
  `:focus-visible` outlines.
- **funcmap (`internal/web/funcmap.go`):** `stateColor` → `run/progress/warn/neutral/off`
  (**stopped/exited is neutral, NOT red** — operator-approved exception-color change; restarting =
  warn); `usageColor`/`tempColor` → `nominal/warn/crit` (thresholds unchanged); `stateLabel`
  Hungarian copy untouched (byte-identity guarded by test). New `timeAgoStr` (see fix below).
- **All 19 web templates + setup templates:** bars → meters (template + JS-generated markup),
  badges/pills → tags, informational pills → metarows with icons, legacy `var(--*)` names in inline
  styles/JS renamed to v2 tokens, monitoring Chart.js palette (cpu `#2EA8F5`, memory `#8E7CE8`, temp
  `#E0A93E`, load `#5EC4B6`; v2 tooltip/grid/tick literals), deploy 3-step progress → sprite icons,
  catchall page (standalone) fully retokened with inline SVGs, login two-tone H1.
- **fix(backups) 4906524:** `OffboxTarget.LastRun` is an RFC3339 *string*; backups.html passed it to
  `timeAgo` (expects `time.Time`) → GET /backups 500'd on any node where an off-box backup had ever
  run. Pre-existing since v0.93.0, exposed by the D0 click-through; fixed with `timeAgoStr`.
- **Tests (+7):** §8 truth tables for stateColor/usageColor/tempColor + stateLabel guard (red-proven
  vs the old funcmap), font route + `StyleCSS()` accessor, setup embedded-CSS (Scenario E, red-proven
  vs the old handler). Grep gate: 34 banned patterns (old hexes, 999px, box-shadow, CDN import,
  legacy class names, emoji) at **zero** in `internal/{web,setup}` (baseline: 143 hits).
- Live-validated on guest 9201 via claude-in-chrome: full click-through, no Google Fonts requests,
  `document.fonts.check` true, ő/ű render in PJS latin-ext, dashboard Scenario-A assertions
  (0 green fills, 0 shadows, 0 radii >2px) DOM-verified. NOT live-validated: setup wizard rendering
  (unit-tested only), warn/crit meter states on real hardware (demo node healthy; unit-tested).

### v0.95.0 — enrollment wizards use the raw-device scan `/disks/candidates` (Impl-2b) (2026-07-01)

Final drive-enrollment piece: both enrollment wizards now source candidates from the agent's Impl-2a
raw-device scan instead of the `Observe()`-based `/api/disks` list — so a brand-new (non-PVE-storage)
drive is finally visible + enrollable end-to-end. The enroll flow (`runStorageInit`/`runStorageAttach`)
and the Impl-1 guarded `mkfs` are UNCHANGED; the wizards just get the right candidate list.

- **`internal/agentapi/client.go`:** `ListCandidates(ctx) (CandidatesResult, error)` → agent
  `GET /disks/candidates`; types `CandidatesResult{Initialize,Attach []DiskCandidate}` +
  `DiskCandidate{Device,SizeBytes,Model,FSType,DataBearing,Mountable,MountSource,DurableID}` mirroring
  the agent's `candidates.go`.
- **`internal/web/agent_disk_handlers.go`:** `GET /api/disks/candidates` proxy
  (`agentDiskCandidatesHandler`, copy of `agentDisksListHandler`) — passthrough, NO controller-side
  filtering (the agent's unclaimed-disk filter is authoritative + fail-safe).
- **`templates/storage_init.html`:** fetch `/api/disks/candidates` → render the `initialize` list
  (model/size/current-FS + a data-bearing marker); dropped the client-side "already-managed" filter
  (the server list already excludes OS/enrolled/claimed disks). Data-bearing → the existing wipe-confirm.
- **`templates/storage_attach.html`:** fetch `/api/disks/candidates` → render the `attach` list
  (mountable-FS disks); selecting posts the FS-bearing node + its fstype to the existing
  `/api/storage/attach` (mount + bind, NO format).
- **TOCTOU:** the wizard trusts the agent's Impl-1 `Format` guard as the backstop (re-checks unclaimed at
  format time), not the list's freshness — a device claimed between scan and enroll is refused.
- Tests: `agentapi` `TestListCandidates` + `_Error`. `go build/vet/test ./...` clean. Live end-to-end
  raw enrollment of `/dev/sdd` validated through the real UI (see REPORT).

### v0.94.0 — pull-based config-refresh (re-pull controller.yaml + self-restart on a config change) (2026-06-30)

Config delivery is now pull-based, riding the report ACK exactly like the Phase 2 version floor — the hub
never connects into the box. This replaces the hub's retired "Push Config" (companion hub change v0.26.0)
and is the mechanism by which an operator config edit reaches a running box.

- **`internal/report/pusher.go`:** `PushResponse` gains `ConfigVersion int` (`json:"config_version"`).
  0 = the hub didn't advertise it (old hub / report-only customer) → no action.
- **`internal/report/config_refresh.go` (NEW) — `ConfigRefresher.Reconcile`.** The testable reconcile
  (all side effects injected): on a config_version change vs. the last-applied version, **Refresh** (re-pull
  `controller.yaml`) → **Record** → **Restart**. First-ever ACK (nothing recorded) records the baseline
  WITHOUT restarting (the first-boot pull already has the current config); an unchanged version is a no-op
  (no restart storm); a failed pull keeps the current config and does NOT record/restart (retried next
  cycle); record-before-restart so the restarted process sees it applied and doesn't loop.
- **`internal/bootstrap/bootstrap.go` — `RefreshConfig`.** Re-pulls `controller.yaml` from the hub and
  rewrites it, re-merging `local_api` from the same read-only `bootstrap.json` mount (no secret stashed
  elsewhere). Reuses the existing `pullWithRetry`/`mergeLocalAPI`/`writeFileAtomic`. Overwrites
  `controller.yaml` (hub = source of truth); NEVER touches `settings.json`; fail-safe (any failure leaves
  the current config unchanged + returns an error). NOT first-boot-gated (unlike `MaybeIngest`).
- **`internal/settings/settings.go`:** `applied_config_version` + `GetAppliedConfigVersion` /
  `SetAppliedConfigVersion` (persisted so the version survives the restart).
- **`internal/api/selfrestart.go`:** exported `GracefulSelfRestart` (the unexported one now calls it) so
  the main.go reconcile reuses the one graceful-restart mechanism instead of reinventing an `os.Exit`.
- **`cmd/controller/main.go`:** wires the reconcile into `OnPushResponse` beside the floor reconcile —
  same report cycle, no new timer, no agent involvement. The first-boot `MaybeIngest` never-clobber is
  untouched (the refresh is a separate explicit re-pull).
- Tests: `Reconcile` (change→refresh+record+restart; **same-version no-op RED-PROOF**; baseline-no-restart;
  failed-pull no-record/no-restart; zero-version no-op; record-fail skips restart) + `RefreshConfig`
  (re-pull overwrites + re-merges local_api; failed pull leaves config unchanged; absent bootstrap errors
  without writing). `go build/vet/test ./...` green.

### v0.93.0 — NAS Part B: off-box backup target (restic-over-SFTP) (2026-06-30)
Closes the NAS arc: back the app-data tier (each off-box app's recovery unit + DB dumps + volume tars) up
to the customer's NAS as an **encrypted restic repo over SFTP** — the "1 off-site" leg of 3-2-1, distinct
from the local cross-drive rsync copy and the agent's PBS whole-CT DR. No kernel mount; restic talks SFTP
to the NAS directly. Spike-validated (SPIKE-nas-storage Q8).
- **`Dockerfile`:** restic was dropped when cross-drive migrated restic→rsync — re-added `restic` +
  `openssh-client` (restic's sftp backend shells out to `ssh`); version pinned by the Debian release.
- **`internal/backup/offbox.go` (NEW):** the restic-SFTP backend + orchestration.
  - **Fail-fast (the load-bearing spike Q8 lesson):** every restic call carries
    `-o sftp.args=…-oConnectTimeout=10…` so a dead NAS errors in ~10 s instead of a multi-minute TCP hang.
    Also `-oStrictHostKeyChecking=yes -oUserKnownHostsFile=<pinned>` (no blind TOFU) + `-oBatchMode=yes`.
  - init-if-absent (idempotent — a present repo is reused, never re-init), per-app `restic backup --tag`,
    `forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune` retention, single-flight (shares
    `m.running`) + migration-guard, restic's **own exit code** checked (never pipe-swallowed), restore via
    `restic restore latest --tag <app> --target <scratch>` (non-destructive).
  - **Secrets:** the SSH private key + the auto-generated repo password are 0600 files in the data dir —
    never logged, never in a committed/non-0600 file; the repo is encrypted so the NAS sees only ciphertext.
    They ride DR via the PBS whole-CT snapshot of the rootfs (the data dir), so a rebuilt box can reach the
    off-box repo (the recovery-unit/dr-recipe stay secret-free).
- **`internal/settings/settings.go`:** `OffboxTarget` (host/port/user/repo path/schedule + runtime status,
  no secrets) + per-app `AppBackupPrefs.Offbox` toggle + helpers.
- **`cmd/controller/main.go`:** daily `offbox-backup` schedule (04:15) gated on enabled+configured; a
  failure (incl. fail-fast dead-NAS) alerts the operator via the allowlisted `backup_failed` event.
- **UI (`backups.html`):** "Külső (NAS) mentés" section — target config (host/port/user/repo + out-of-band
  SSH key + known_hosts textareas), status (last run / repo size / snapshots), per-app toggles, run-now,
  restore-to-scratch.
- Tests: ConnectTimeout present in base args + **the fail-fast companion red-proof** (a fake SSH transport
  hangs to the ctx deadline WITHOUT the arg, fails fast WITH it); dead-NAS run fails fast + alerts + records
  status; restore round-trip byte-identical (SFTP-shaped seam); single-flight skip; repo idempotency;
  secrets are 0600. `go build/vet/test ./...` green.

### v0.92.0 — NAS network storage Part A2: registry + UI + per-share health (2026-06-30)
The controller side of NAS network storage, proxying to the validated agent foundation (felhom-agent
v0.50.0 `/netstorage/*`). An operator can add a customer's NAS share and point a media app at it — all via
the UI. A NAS is a **distinct storage kind** (NOT a drive): no enroll/eject/decommission/migrate/wipe/SMART.
- **`internal/agentapi/client.go`:** `AddNetStorage`/`ListNetStorage`/`RemoveNetStorage` + the
  `NetworkMountStatus` mirror (health `ok|idle|unreachable`; `idle` is benign, only `unreachable` degraded).
  The SMB credential is passed STRAIGHT THROUGH to the agent (which writes the 0600 file) — **never persisted
  by the controller**.
- **`internal/settings/settings.go`:** `StoragePath.Kind` discriminator (`""`/`drive` | `network`) + the
  network descriptors (Protocol/Server/Export/MappedUID/MappedGID — **no password**); `IsNetwork()` +
  `IsNetworkStoragePath()`; `NetworkMountRoot`.
- **`internal/web/netstorage_handlers.go` (NEW):** `POST /api/storage/netstorage/{add,remove}` +
  `GET /api/storage/netstorage`; registers/deregisters a Kind=network `StoragePath`; merges the agent's live
  per-share health for the UI.
- **Kind-gating (the safety centerpiece):** `refuseNetworkLifecycle` blocks the drive ops
  (eject/decommission/migrate/wipe) on a network path server-side; the drive-absent **gate**
  (`planDriveGates`) and the **missing-storage** surface now SKIP network paths — so an `unreachable` NAS is a
  **recoverable warning**, never the drive "missing → stopped" cascade (Scenario C). `networkStorageWarnings`
  drives a distinct "Hálózati tárhely nem elérhető" app-card badge.
- **UI (`settings.html`):** a "Hálózati tárhely (NAS)" section — add form (NFS/SMB, server/export, uid,
  SMB creds), per-share health badges, remove. Network shares are auto-selectable as a media app's `HDD_PATH`
  (they register Schedulable). Hungarian, minimal emoji.
- Tests: agentapi round-trip (creds forwarded, health states); registry Kind-gate **companion** (drive
  lifecycle refuses a network path; a drive path is not gate-refused); `unreachable`≠`missing` **companion**
  (the drive gate stops an absent drive but NOT a network path). `go build/vet/test ./...` green.

### v0.91.0 — F2: alert on born/persistent-down channel (not only transitions) (2026-06-29)
- **What:** closes F2 from the full-stack testrun — a channel failure present at **startup/reseed**
  (e.g. the controller boots right after a leaf regen → first observation is `pin_mismatch`) was
  dashboard-only, **no operator email, forever**. Now a born-down non-transient reason alerts on cycle 1.
- **`internal/channelhealth/checker.go`:** added an `alerted` flag (have we emitted for the CURRENT
  down-spell?). A confirmed down that comes from up/unseeded OR changes reason re-arms (`alerted=false`)
  then emits once; a steady down that already alerted does not re-fire; recovery (up) re-arms. Removed
  the `prev==""` silent-seed-for-down branch (a born-down IS a real down-spell). Debounce stays intact:
  a **transient** born-down (refused) still needs N≥2 (the cold-boot agent-not-yet-up race), and a
  **healthy** first-obs still seeds silently.
- Tests: F2 born-down non-transient **red-proof** (one alert cycle 1) + companion showing the old
  seed-silent path would not have alerted; born-down transient still debounced; recovery re-arms the
  spell. Version `0.90.0 → 0.91.0`.


### v0.90.0 — Controller→agent channel health-check (periodic probe + classified operator alert) (2026-06-29)
- **What:** the next self-health slice — a ~60s scheduler job that proves the controller↔agent
  local-API channel, classifies failures, and alerts the operator + dashboard on a state change.
  Closes the gap the R1 pin-mismatch incident exposed (the channel was only checked once at startup and
  only logged). Spike-proven: `felhom.eu/documentation/audits/SPIKE-controller-agent-channel-health-2026-06-29.md`.
- **`internal/channelhealth` (NEW):** a `Checker` over two seams — a `Probe` (the channel call) and a
  `Sink` (dashboard + operator notify). Each run classifies the result into `up | down:<reason>` by the
  spike Q1 error-substring map (`pin_mismatch` / `unauthorized` / `unreachable` / `timeout` /
  `misconfigured` / `construction_error` / `unknown`). **Debounce:** transient reasons (refused/timeout)
  require **N≥2 consecutive** down-probes before alerting — so the ~1s agent-restart socket gap (spike
  Q2) does NOT page; pin/401/DNS/construction alert on the first down observation. First scheduler
  observation **seeds** state without notifying (mirrors host_staleness/host_capability). A
  construction error (`agentClient()` can't build — latches via `sync.Once`) is surfaced **distinctly**.
- **Probe via the PRODUCTION memoized client (`Server.ProbeAgentChannel`):** GET /storage through
  `s.agentClient()` — NOT a fresh `agentapi.New` per probe. Per the spike, the memoized client
  self-heals after an agent restart, reflects exactly what the disk UI sees (zero divergence), and
  avoids the per-call transport leak the singleton fixed.
- **Operator alert + dashboard:** `Notifier.NotifyAgentChannelDown/Recovered` relay an **English,
  operator-only** event (the customer can't act on "the agent re-keyed"; the event type isn't a
  customer toggle, same as the host_* events) on a **transition** (up→down, down→up, or reason-change),
  with the existing hub 1h cooldown. `AlertManager.SetAgentChannelAlert` shows a short Hungarian
  banner whenever the channel is down (state-based, idempotent — a born-down channel shows even though
  it's seeded silently). **No customer email.** No agent or hub change (the hub relays the new event
  types generically).
- Tests: classifier per-reason, transitions (up→down once, no duplicate, recovered, reason-change
  re-alerts), first-obs seed, and the **debounce red-proof** (one refused → no alert; two consecutive →
  exactly one). Version `0.89.0 → 0.90.0`.

### v0.89.0 — App-email: plaintext-only listener (:2526) + split-From mapping (2026-06-29)
- **What:** closes the two relay gaps from `FINDING-app-email-rollout-2026-06-29.md` so the
  opportunistic-STARTTLS clients (cal.com, nextcloud) can use the relay.
- **Gap 1 — `internal/mailrelay/server.go`:** a **third listener `:2526`** that is plaintext and does **NOT
  advertise STARTTLS** (`TLSConfig` left nil ⇒ go-smtp omits the STARTTLS capability from EHLO). Clients that
  opportunistically upgrade to STARTTLS and then validate the cert with no skip-verify knob (Nodemailer/Symfony
  Mailer) never attempt TLS against it. Accepted posture: plaintext on the single-tenant app Docker bridge only
  (never host/internet). `:2525` (STARTTLS) and `:2465` (implicit-TLS) unchanged. New config
  `mail_relay.plain_no_tls_listen` (default `:2526`).
- **Gap 2 — `internal/stacks/metadata.go` + `mailenv.go`:** `SMTPMapping` gains **`tls_mode`** (`""`/`starttls`
  →2525 default; `plaintext`→2526; `implicit-tls`→2465 — `smtpEnv` now picks the port from it instead of the
  hardcoded 2525) and **`from_domain_var`** (split-From: when set, inject `FromVar=<local>` +
  `FromDomainVar=<domain>` separately, for nextcloud's `MAIL_FROM_ADDRESS`+`MAIL_DOMAIN`; unset = the current
  `<local>@<domain>`).
- **No regression:** default `tls_mode` keeps vaultwarden/gitea/rallly on 2525 and mealie's plaintext path
  unchanged; the hub is untouched (it relays whatever raw MIME the shim sends).
- **Tests:** `smtpEnv` port-by-tls_mode (+ companion that plaintext≠starttls port), split-From (+ companion
  single-From), and the `:2526` listener has `TLSConfig==nil` & a real EHLO showing it does NOT advertise
  STARTTLS while `:2525` does.

### v0.88.0 — App-email SMTP relay: in-process shim + per-app injection (2026-06-29)
- **What:** deployed apps can now send outbound email (password resets, invites, confirmations) through one
  managed path — **app → in-controller SMTP shim → hub → Resend** — with the Resend key staying hub-side.
  Implements `SPIKE-smtp-app-relay-2026-06-28.md` (verdict READY). Architecture: **Shape 1**, the shim runs
  **in-process inside the controller** (operator-confirmed), reusing the controller's existing hub client.
- **New `internal/mailrelay/`:** a `go-smtp` server with two listeners — `:2525` plaintext+STARTTLS and
  `:2465` implicit-TLS (self-signed cert generated at boot, CN/SAN = the shim service name). Advertises AUTH
  PLAIN+LOGIN and **accepts any credentials, ignoring them** (apps send none; some require the offer; a
  ~15-line LOGIN sasl server fills go-sasl's gap). `policy.go` validates the **From header** domain against
  an allowlist (default `felhom.eu`) and rejects with a clean 5xx **before** any hub call. `forward.go`
  POSTs the **raw MIME** to the hub `POST /api/v1/mail` with the controller's hub Bearer key — **single-shot**
  (no retry, no spool in v1); the hub HTTP status maps to an SMTP reply (2xx→250, 4xx→451, 5xx→554) so the
  app surfaces the real outcome. `lifecycle.go` starts/stops the shim at runtime so the global toggle takes
  effect without a controller restart. Listeners bind to the app Docker network only — never host/internet.
- **Settings + injection:** new global **app-email** toggle (`settings.AppEmail{Enabled,FromName}`); new
  `.felhom.yml` **`smtp_mapping`** block (renames host/port/security/from/from-name to an app's env keys, plus
  fixed `extra` vars); per-app toggle persisted in `app.yaml` (`AppConfig.EmailEnabled`). The relay env is
  injected at compose time in `stackEnv` (host=shim, port=2525, security/from per mapping) **only when**
  global ON + per-app ON + the app has a mapping — derived each compose, never persisted. New
  `config.MailRelayConfig` (listeners, shim host, From allowlist; kill-switch).
- **UI (Hungarian):** Settings page "Alkalmazás-email" card (global toggle + optional household From-name);
  per-app "Email-küldés" toggle on the deployed app's config page (only for apps with `smtp_mapping`),
  save → recreate the stack to apply.
- **Tests:** `mailrelay` (happy-path passthrough byte-equality, From-reject-before-forward + companion,
  single-shot-on-hub-failure + companion, status mapping, LOGIN lifecycle, real-socket STARTTLS end-to-end);
  `stacks` (mapping parse, both-toggles-on injection, per-app/global-off no-injection, no-mapping, Mealie-style
  mapping, household From-name). New dep `github.com/emersion/go-smtp` v0.24.0 + go-sasl.

### v0.86.0 — Phase 2 managed updates: floor-driven auto-update (2026-06-27)
- **What:** the controller now honors an operator-enforced **minimum version** (FLOOR) delivered on the
  hub report ACK and **auto-updates to the floor** when below it — the managed default (no customer
  click). The customer "update to latest" button is unchanged (latest, opt-in); the floor is the
  **auto-target**, never latest.
- **`internal/report/pusher.go`:** `PushResponse` gains `min_controller_version` + `latest_version`
  (the pusher already parsed the ACK for `customer_blocked` — extended, not a new path). *(The task
  pointed at `notify/notifier.go`'s response-discards, but the actual report sender is `pusher.go`,
  which already had an `OnPushResponse` seam — used here.)*
- **`cmd/controller/main.go`:** the existing `OnPushResponse` callback now also calls
  `updater.SetFloor(resp.MinControllerVersion)` + `updater.MaybeAutoUpdate()` — riding the existing
  report cycle; **no new timer/endpoint**.
- **`internal/selfupdate/updater.go`:** `SetFloor`/`GetFloor` + `MaybeAutoUpdate()` which **reuses the
  Phase 1 `performUpdate`** (in-guest pull → agent `SwapController` → rollback on failure) with the
  **floor** as target (`initiatedBy="auto-floor"`). Strict no-op unless: floor set, current parses,
  current < floor (at/above = nothing — does NOT chase latest), agent wired, no backup running, no swap
  in flight, not already attempted this floor (in-memory + persisted-state guard = no flapping/storm),
  and the floor is **pullable** (floor ≤ latest available; floor > latest → warn + do nothing).
- **UI (settings, Hungarian):** shows "Minimális verzió (üzemeltető): X" when a floor is set, and during
  an auto-update surfaces the same restart-poll panel as the button (auto-polls `/api/health` on load).
- **Tests (`internal/selfupdate/floor_test.go`):** below-floor→floor (not latest); at/above→no-op;
  no-floor inert; floor>latest→no chase + warn; no-flap (one swap across repeated reconciles); raised-floor
  honored (Scenario C/E); dev/no-agent→no-op. **Companion red-proof (verified):** making `MaybeAutoUpdate`
  always no-op fails the below-floor test → restored → green.
- **No agent change** (reuses Phase 1 swap). Live (demo 9201): dogfood-deployed 0.86.0 via the Phase 1
  self-update (the exact endpoint the Settings button invokes), then global floor set to 0.87.0 → the box
  auto-updated 0.86.0 → 0.87.0 with **no click** (`last_state.initiated_by="auto-floor"`, success);
  at/above-floor produced **no second update** (no flap). Hub `controller_version`=0.87.0. See REPORT.md.

### v0.85.1 — version-only build (live self-update validation target) (2026-06-26)
- No code change vs v0.85.0. Pushed as the registry "latest" so the live e2e self-update path could be
  validated via the real Settings button (demo `0.85.0 → 0.85.1`: in-guest pull → agent swap → reload).

### v0.85.0 — Self-update reworked: in-guest pull + agent swap (Phase 1) (2026-06-26)
- **Problem:** the self-update button was dead in the LXC architecture — `selfupdate/updater.go` drove
  the old bare-metal flow (`docker compose -f /opt/docker/felhom-controller/docker-compose.yml up -d`),
  a path that doesn't exist in the guest ("docker-compose.yml nem elérhető"). The stranded 0.77.0 demo
  could detect 0.84 but not install it.
- **Fix (Phase 1):** the controller now **pulls** the target image in-guest (its existing registry token
  via `docker login --password-stdin` → `docker pull` → `docker logout`, over the shared docker socket),
  then **delegates the swap to the host agent** (`agentapi.Client.SwapController` → agent
  `POST /controller/swap`). The agent — external to the controller container — rewrites
  `/etc/felhom-controller-image`, restarts the bootstrap unit, verifies health, and **rolls back** if the
  new controller doesn't come up. The controller never `docker rm`/recreates itself.
- **Removed** the dead compose path: `performUpdate`/`updateComposeFile`/`composePath` and the
  `docker compose up -d` flow are gone. `DryRun` now reports `agent_reachable` + `pull_capable` instead of
  `compose_writable`.
- Success/failure is detected on the **next boot** by the existing `VerifyStartup` (running version vs
  target) — a rollback lands the previous version → "failed (version mismatch)". The UI button + poll
  (`triggerUpdate`/`pollUntilBack`) are unchanged. **Latest-only** (no version picker — Phase 2).
- `agentapi`: new `SwapController` (202) + `SwapStatus`. `NewUpdater` takes an `AgentSwapper` (nil on an
  un-provisioned guest → update unavailable) instead of a compose path.
- Tests (`internal/selfupdate/updater_test.go`): up-to-date → no pull/no agent; pull-fails → agent never
  called; happy → pull then one `SwapController` with the right ref; no-agent → unavailable.

### v0.84.0 — Show an app's auto-generated initial login on its page (catalog-driven) (2026-06-26)
- **Problem:** some apps generate a random first-login password into a file at first boot (Crafty →
  `/crafty/app/config/default-creds.txt`) instead of taking it from a deploy field. Customers had to
  read the container logs to find it — the static `app_info.default_creds` hint can't carry a
  per-install secret.
- **General, catalog-driven mechanism (not Crafty-specific):**
  - `.felhom.yml` gains an optional `initial_credentials` block: `{file, format: json|regex|plain,
    container?, username_key/password_key (json), username_pattern/password_pattern (regex), note}`.
  - `internal/stacks/metadata.go`: new `InitialCredentials` struct + `Metadata.InitialCreds` (deep-copied
    in `deepCopyStack`).
  - `internal/stacks/initialcreds.go`: `ReadInitialCredentials(stack)` reads the file **live** from the
    running container (`docker exec <c> cat <file>` — path passed as a single arg, no shell) and parses
    it via the pure, unit-tested `parseInitialCreds` (json/regex/plain). Never persists the secret to
    `app.yaml`; returns a non-Available result (card hidden) when the container is down / file missing /
    parse fails. Container defaults to the stack's main container (`findProbeContainer`).
  - `internal/web/handlers.go`: `appDetailHandler` populates `InitialCreds` for deployed apps with the
    spec; `app_info.html` renders a "Kezdeti belépési adatok" card with username + masked password
    (Megjelenítés/Másolás, value read from a hidden element — never inlined into JS), labelled clearly as
    the **initial** password (stays valid only until the customer changes it in-app).
  - Tests: `parseInitialCreds` json/regex/plain + error paths.
- **Security note:** this surfaces a live working credential on the app page — same exposure class as the
  existing post-deploy password reveal and `default_creds` card. It relies on the dashboard being
  auth-gated in production (the demo's public-unauth dashboard is a separate, pre-existing tracked issue).
- Paired with `app-catalog-felhom.eu` adding the `initial_credentials` block to crafty-controller.

### v0.83.0 — Traefik scoped serversTransport for self-signed HTTPS backends (fixes crafty 502) (2026-06-26)
- **Problem:** the crafty-controller healthcheck fix (catalog `68ce009`) un-withheld its Traefik route,
  exposing a pre-existing 502 — Traefik proxied **HTTP** to Crafty's **HTTPS-only** self-signed backend
  on `:8443`. Crafty is the first/only catalog app with an HTTPS backend; all others serve plain HTTP, so
  Traefik's default HTTP transport works for them.
- **Fix (scoped, Option B — verification stays ON by default):** the controller now renders a Traefik
  file-provider dynamic config defining a **named** `insecure-skip-verify` serversTransport
  (`http.serversTransports.insecure-skip-verify.insecureSkipVerify: true`). A service opts out of backend
  TLS verification only by referencing it (`serverstransport=insecure-skip-verify@file`) — no global
  `insecureSkipVerify` (the rejected Option A). `insecureSkipVerify` is not settable via Docker labels in
  Traefik v3, so it must live in file/static config; the matching `scheme=https` + `@file` reference
  labels go on the app (catalog repo).
  - `internal/infra/infra.go`: new pure `RenderServersTransports()` + exported `ServersTransportInsecure`
    constant.
  - `internal/stacks/infra.go`: new `ensureServersTransports(traefikDir)` writes
    `dynamic/serverstransports.yml` (0644) idempotently (write-only-on-change, like `wireController`, so
    the traefik file-watcher doesn't reload each self-heal tick). Called from `EnsureBaseStack` **outside**
    `ensureTraefik` (which early-returns when traefik is already running) so an established node still
    materializes the file on the next self-heal tick; the watcher hot-loads it (no traefik restart).
  - Rationale for skip-verify: a per-container self-signed cert has no CA to verify against and the hop
    never leaves the host's internal docker bridge.
- Paired with `app-catalog-felhom.eu` adding `scheme=https` + `serverstransport=insecure-skip-verify@file`
  to the crafty service. Tests: `TestServersTransports` + the YAML-parse matrix.

### v0.82.0 — FileBrowser sync no longer bounces the file UI on no-op; drop dead restic binary (2026-06-24)
- **F2 — gate the FileBrowser recreate on an actual change.** `syncFileBrowserMounts` (`internal/web/handlers.go`)
  previously ran `docker compose up -d --force-recreate --remove-orphans` **unconditionally**, so every
  controller restart and every storage sync force-recreated the FileBrowser container even when its
  `config.yaml`/compose were byte-identical — bouncing the customer's file-access UI and contradicting the
  "Vezérlő újraindítása → apps keep running" promise. Now it captures the on-disk `config.yaml`+compose
  **before** the writes and re-reads the **final** content **after** them (so the integrations'
  `ReapplyConfigForTarget` edits are included), and recreates only when something actually changed via the
  new pure helper `fbNeedsRecreate(oldCfg,newCfg,oldCompose,newCompose)`; otherwise a plain `up -d` ensures
  it's running without a bounce. The restore-mode DB reset (`sourcesChanged && resetDBOnChange` → `down -v`)
  is preserved and forces `changed=true` (a reset removes the container). First-ever run (empty old files)
  still recreates. Unit-tested (`filebrowser_gate_test.go` `TestFbNeedsRecreate`, incl. red-proof against the
  old unconditional behaviour).
- **F1 — dropped the unused `restic` binary from the image** (`controller/Dockerfile`). The disk-tier restic
  work moved to the host agent; no controller code execs the binary (the only `"restic"` references are a
  backup-dir-name comparison and the `Method` config string, both unaffected). Removed the `restic` apt line
  and its comment. The `ResticSchedule`/`migrateResticToRsync` config+settings paths are **untouched** (still
  live in the dashboard).

### v0.81.0 — retire the drive-activation banner; add a standalone "Kiszolgáló újraindítása" button (2026-06-23)
- **Removed the obsolete drive-activation banner.** In the intermediary-mount model an enrolled drive
  binds **live** into the running guest (agent `disks.go` — no `pct set -mpN`, no slot, no reboot), so
  the "… meghajtó aktiválásra vár / Újraindítás most (~30 mp)" banner was a relic of the old per-drive
  reboot model. It was also effectively dead since v0.78 (`pendingActivationDrives` keyed `attached` by
  the agent's RAW `MountPath` but compared it to the now-STABLE `sp.Path`). Removed: the
  `{{if .PendingDrives}}` banner block + `window.activatePendingDrives` JS (`settings.html`), the
  `data["PendingDrives"]` feed (`handlers.go`), and the dead `pendingActivationDrives` helper +
  its now-unused `internal/system` import (`storage_handlers.go`).
- **Repointed the reboot endpoint to a non-storage route.** Renamed `handleStorageActivate` →
  `HandleServerReboot` and split out a testable `serverReboot` core (mirrors `runStorageInit`); removed
  the `/api/storage/activate` case from `ServeStorageAPI`; mounted the handler at the new
  `/api/server/reboot` (same `RequireAuth` + `CsrfProtect`) in `cmd/controller/main.go`. The agent
  `GuestReboot` primitive is reused unchanged. (`/api/storage/activate` now returns 404.)
  *Note:* the handler is exported (`HandleServerReboot`) because `cmd/controller/main.go` wires it
  cross-package — same convention as every other web handler mounted there.
- **Added the standalone "Kiszolgáló újraindítása" settings card.** A deliberate full-server (guest)
  restart affordance, a sibling to the existing "Vezérlő újraindítása" controller-only restart.
  New `settings-card` + `restartServer()` JS (reuses the existing `pollRestart()` loop) in
  `settings.html`; posts to `/api/server/reboot`.
- **Test:** `TestHandleServerReboot_CallsGuestReboot` (`storage_handlers_test.go`) — a fake `diskAgent`
  asserts `GuestReboot` is invoked exactly once and the response is 202 `{ok:true, rebooting:true}`.
  `diskAgent`/`mockAgent` gained `GuestReboot`. Green gate: `go build ./... && go vet ./... && go test ./...`.

### v0.80.0 — disk card: show + act on the stable path, not the raw host mount (2026-06-23)
- Follow-up to v0.78/0.79. The storage disk card displayed the drive's **raw** host PVE mount
  (`/mnt/<name>`) — which doesn't exist inside the guest — instead of the **stable** in-guest path
  (`/mnt/felhom-drives/<name>`, the `guest_path`) the registry, app `HDD_PATH`, and FileBrowser use.
- It also passed the **raw** path to the Leválasztás/Törlés buttons, so those would unmount the drive
  but leave its **stable** registry entry orphaned (`RemoveStoragePath` is keyed on the stable path), and
  the impact warning (`/api/storage/impact?where=`) found no affected apps (HDD_PATH is the stable path).
- Fix (`settings.html`): the card sub-line + the eject/wipe buttons now use the stable path (`regKey(d)`);
  the type-to-confirm name is derived from the basename so it still matches the server check; **register**
  keeps posting the raw path (its agent guest-attach operates on raw). `handleStorageWipe` now maps the
  registered path to raw via `agentWhere()` for the agent eject (matching `handleStorageEject`), so the
  drive deregisters cleanly. Agent-facing ops are unchanged (same raw paths); only display + the
  controller's own registry bookkeeping are corrected.

### v0.79.0 — disk view: key the "registered" check on the stable path (2026-06-23)
- Follow-up to v0.78.0. The storage disk-view JS (`settings.html` `regBadge`/`actions`) decided whether
  a drive was registered by looking up its **raw** `mount_path` (`/mnt/<name>`) in the registry — but
  since v0.78.0 the registry correctly stores the **stable** path (`/mnt/felhom-drives/<name>`), so an
  enrolled, working drive showed a spurious **"Nem regisztrált"** badge + **"Regisztrálás"** button.
- Fix: new `regKey(d)` = `d.guest_path || d.mount_path` (the agent already reports the stable
  `guest_path` per disk); `regBadge`/`actions` now key on it. `registerDrive()` still posts the RAW
  `mount_path` (the agent operates on raw; `handleStorageRegister` maps it to stable). Display-only.

### v0.78.0 — storage register: use the STABLE intermediary path, not the raw path (2026-06-23)
- **Bug:** `handleStorageRegister` (the "Regisztrálás" action for an already-mounted, unregistered drive)
  registered the **raw** `/mnt/<name>` host path verbatim, unlike its siblings `runStorageInit`/
  `runStorageAttach` which map through `stablePathForName` to the **stable** intermediary path
  `/mnt/felhom-drives/<name>`. The agent binds the drive at the stable path (intermediary model), so the
  controller ended up watching an empty placeholder dir on the guest **rootfs** → the drive showed as on
  the system drive (**"Rendszermeghajtón"**, ~31 GB), `0 connected / N disconnected`, and the
  **"… meghajtó aktiválásra vár"** banner never cleared (`registerStoragePath`→`EnsureUserdataSkeleton`
  even `mkdir`'d those rootfs placeholders). Surfaced after a destroy+re-provision, where surviving host
  mounts make "Regisztrálás" the natural action. Diagnosis:
  `felhom.eu/documentation/audits/DIAGNOSE-drive-bind-after-reprovision-2026-06-23.md`.
- **Fix** (`internal/web/storage_handlers.go` `handleStorageRegister`): register
  `stablePathForName(path.Base(req.Where))`, matching init/attach. `attachIntoGuest` still receives the
  **raw** path (the agent operates on raw); the success log + JSON now report the stable path (+ raw).
  Test: `TestHandleStorageRegister_RegistersStablePath` (+ red-proof). No other behavior changed.

### v0.77.0 — per-app open_path for the "Megnyitás" link (2026-06-23)
- The dashboard/deploy/app-info **"Megnyitás"** (open) button was hardcoded to the bare subdomain root
  `https://{sub}.{domain}` for every app. Apps whose UI isn't at `/` (e.g. Gokapi redirects `/` away;
  Ghost's admin is at `/ghost/`) opened to the wrong place.
- New optional `open_path` field on `Metadata` (`internal/stacks/metadata.go`, `.felhom.yml`) appended to
  the URL in all three link sites (`dashboard.html`, `deploy.html`, `app_info.html` via `.Meta.OpenPath`).
  Empty = bare root (unchanged for the other 50 apps). No handler changes (all three templates already
  carry `.Meta`). Catalog: `gokapi` → `/admin`, `ghost` → `/ghost/`.

### v0.76.0 — campaign-#3 hardening: settings recovery, restore-name validation, quiesce-marker quarantine (2026-06-22)

Three controller findings from chaos campaign #3, all small, all controller-side.

- **S1 [MEDIUM] — no more crash-loop on a corrupt `settings.json`.** `internal/settings/settings.go`:
  `save()` now writes a last-known-good `<path>.bak` **after** the primary rename succeeds (best-effort);
  `Load()` on a JSON-parse error recovers from `.bak` (re-promotes it to primary) and, failing that,
  **preserves** the corrupt file as `*.corrupt-<ts>` and starts on safe defaults — never returns the
  error that made `main.go` `Fatalf`/crash-loop. New `Settings.LoadWarning` surfaced as a dashboard
  banner. (`main.go`'s `Fatalf` stays — now only the genuine IO-unreadable path is fatal.) Recovery is
  safe: an empty `PasswordHash` falls back to `controller.yaml`, the storage registry re-discovers.
- **F2 [MEDIUM, defense-in-depth] — validate `stack_name` against path traversal.** New
  `web/validate.go` `validStackName` (single segment; rejects `/`, `\`, `..`, NUL). Gated in
  `backupRestoreHandler` (`handlers.go`) and `apiExportStart` (`handler_export.go`) before any
  restore/export work. (Storage `where=` was already validated by `gateWhere`.)
- **S3 [LOW] — quarantine a corrupt quiesce marker.** `quiesce/quiesce.go` `readMarker` now logs a
  `[WARN]` + renames a bad-JSON marker to `*.corrupt-<ts>` instead of silently dropping it (still
  returns "no marker" → no recovery, the correct contract).
- Tests: T-S1a-d (settings recovery), T-F2a-c (validation + both handlers), T-S3a/b (quarantine), all
  red-proofed against the pre-fix code. Agent/hub untouched.

### v0.75.0 — gate userdata MkdirAll on a live mountpoint (no writes into an absent drive) (2026-06-22)

**Bugfix — two `MkdirAll`-into-`<drive>/userdata` sites fired without checking the drive was mounted**,
producing `mkdir …/userdata: permission denied` + transient `Created` flapping during a drive-absent
window (campaign-#2 findings #2/#3). Worse than noise: writing into an unmounted mountpoint lands app
data on the guest **rootfs**, shadowed when the drive returns (data-integrity + rootfs-fill hazard).

- `internal/stacks/manager.go` — `ensureUserdataMounts` (the deploy belt) now skips when the
  `HDD_PATH` drive root is an **external** path (not `sysDataPath`) that is **not a live mountpoint**;
  the app is held by `planDriveGates` instead. New injectable `Manager.isMountPoint` seam (defaults to
  `system.IsMountPoint`) for testability. The system/local path is never gated (it's legitimately not a
  mountpoint).
- `internal/web/handlers.go` — the FileBrowser sync loop skips (and does not mount) a registered path
  under `StableParentDir` that isn't a live mountpoint, via a new pure `skipFileBrowserPath` helper.
  Matches `planDriveGates`' external-only rule.
- `EnsureUserdataDir`/`EnsureUserdataSkeleton`/`planDriveGates` unchanged (gated the callers).
- Tests: `TestEnsureUserdataMounts_{SkipsAbsentExternalDrive,EnsuresWhenMounted,SystemPathNeverSkipped}`
  + `TestSkipFileBrowserPath` (both red-proofed against the pre-fix code).
- **Boot-time** occurrence (docker boot-restore starting drive-backed apps before the agent mounts the
  drives) is a separate cause — documented as a design note (CONTEXT.md), not changed here.

### v0.74.0 — fix the controller→agent connection leak (per-call agentapi client) (2026-06-22)

**Bugfix — agent local-API socket leak that took down the whole agent-backed feature set after ~5 days.**
`Server.agentClient()` built a fresh `agentapi.Client` (hence a fresh bare `http.Transport` with
`IdleConnTimeout:0`) on **every** call and discarded it without closing idle connections. The agent's
keep-alive left one idle ESTABLISHED socket per call to `192.168.0.162:8443`; these accumulated
(~5.8k/day, measured 206 in 47 min) until the ephemeral source-port range for that tuple exhausted →
`connect: cannot assign requested address` (EADDRNOTAVAIL), killing storage UI, host-metrics, and
whole-guest backup. (`:8006`/pveproxy was immune — the controller never dials it.) Diagnosis:
`felhom.eu/documentation/tests/unattended-test-campaign-2026-06-22-8443-diagnosis.md`.

- `internal/web/server.go` — `Server` gains `agentCli *agentapi.Client` + `agentCliErr error` +
  `agentCliOnce sync.Once` (and the `agentapi` import).
- `internal/web/agent_disk_handlers.go` — `agentClient()` now memoizes the build via `agentCliOnce`
  and **reuses one shared client** (cfg.LocalAPI is static per process — a config-apply self-restarts).
  The empty-endpoint "not configured" guard stays OUTSIDE the Once. All 19 call sites unchanged.
- `internal/agentapi/client.go` — `New` Transport hardened: `MaxIdleConns:4`, `MaxIdleConnsPerHost:2`,
  `IdleConnTimeout:90s` (was a bare Transport, `IdleConnTimeout:0`). Added optional `Client.Close()`
  (CloseIdleConnections) hygiene helper.
- Tests: `TestAgentClient_ReusesSameInstance` (+ `TestAgentClient_UnconfiguredErrors`) and
  `TestNew_TransportIdlePoolBounded` — both red-proofed against the pre-fix code.
- Agent, its bridge-IP bind, and firewall rules were **not** touched (controller-only fix).
  Separate open item: the defense-in-depth host firewall rule scoping `:8443` to the guest bridge
  subnet is still absent (pve-firewall disabled) — to be closed independently.

### v0.73.0 — DR recipe: emit the secret-free customer+apps half in the hub report (2026-06-16)

**DR recipe slice (controller half).** Additive `dr_recipe` section on the controller's hub report — the
customer + apps half of the secret-free reconstruction recipe (`SPIKE-dr-recipe-2026-06-16.md`). The hub
assembles it with the agent's storage/guest/PBS half into one customer recipe.

- `internal/report/dr_recipe.go` — `DRRecipeAppHalf{recipe_version, customer{id,display,domain}, apps[]}`
  built by the pure `BuildDRRecipeAppHalf(...)` over the DEPLOYED, non-protected stacks. Per app:
  `AppRecipe{catalog_ref (Meta.Slug, falls back to name), enabled, storage_bindings[]}`. Storage bindings
  are parsed from the compose (`appStorageBindings`) — each `${HDD_PATH}`/`${USERDATA_PATH}` volume bind
  becomes `{container_path, drive (basename of HDD_PATH), subpath}` (e.g. romm → felhom-flash:userdata/roms);
  named volumes are excluded. Wired into `BuildReport`.
- **THE BOUNDARY (the emitter is the enforcement point).** v1 ships an explicit ALLOWLIST — only the three
  fields above — and the emitter reads **NOTHING** from `AppConfig.Env`, so no `ENC:` value / token /
  password can ride along. Allowlist, not denylist → a new field is excluded by default.
- Tests (the load-bearing no-secrets boundary test + companion): `TestBuildAppRecipe_NoSecrets` feeds an
  app whose `Env` carries synthetic secrets (an `ENC:` value + a token-shaped value) and asserts the
  emitted recipe contains NONE of those values and NO credential-shaped key;
  `TestBuildAppRecipe_AllowlistIsLoadBearing` is the red-proof (a guard-removed shape leaks the token, the
  production emitter does not); `TestAppStorageBindings` (+ `_NoHDD`) pins the compose parse; and
  `TestBuildDRRecipeAppHalf` checks assemble-correctness (deployed/non-protected only) with a whole-half
  secret sweep. Red-proofed live: forcing the emitter to dump `Env` makes the boundary test fail.
  `recipe_version=1`, ignore-unknown on read.

### v0.72.0 — FileBrowser converges on boot-recreate (2026-06-16)

Follow-up to v0.71.0: a host-reboot test found `processGuestBootChange` recreated the drive-backed app
stacks but **never re-synced FileBrowser**, so its drive mounts went stale after a reboot (FileBrowser
binds each drive's `userdata` but is base-infra with no `HDD_PATH`, so it is not in the recreate set).
Now, **after** `pollLiveBinds` confirms the live binds and the apps are recreated, the boot-recreate path
triggers `go s.SyncFileBrowserMounts()` so FileBrowser converges against the now-live drives (the sync
runs unconditionally so FileBrowser reflects the current bind state even if no app needed recreating).
Refactored the recreate loop into a pure, testable `recreateDriveBackedApps(stacks, present, recreate,
syncFB)`. Tests: FileBrowser sync invoked once, AFTER every recreate (red-proofed companion: pre-fix path
never synced); and synced even when nothing was recreated. Pairs with felhom-agent v0.37.0's host-reboot
remount-by-UUID fix. **Live-accepted with TWO real `felhom-pve` reboots:** both fired the boot-recreate
(`live bind confirmed — recreating … → re-syncing FileBrowser mounts → FileBrowser mounts synced — 3
storage path(s)`), all drive-backed apps recovered, FileBrowser non-stale — while the agent tolerated a
`/dev/sdb`↔`/dev/sdc` letter swap by mounting each drive by UUID.

### v0.71.0 — fix guest-reboot recovery of drive-backed apps (boot-race + the agent-path blocker) (2026-06-16)

A `pct reboot` of the guest left drive-backed apps (audiobookshelf, calibre-web, immich-server,
jellyfin, komga, radarr, romm, paperless-webserver) stuck `Exited` forever. On guest boot the in-guest
dockerd auto-starts the `unless-stopped` apps **~18s before** the agent re-binds the drive under the
stable parent; the create-time volume bind fails (`mkdir /mnt/felhom-drives/<drive>/userdata:
permission denied` on the empty fail-closed placeholder) and, being a create-time failure
(`RestartCount=0`), is **never retried**. The intended recovery (`processGuestBootChange`) did not fire.
Live diagnosis pinned **three** sub-causes, fixed together (harden the existing mechanism — no parallel
one):

1. **The agent-path blocker (the live root cause).** `agentClient()` returned **"agent not configured"**
   — `cfg.LocalAPI.Endpoint` was empty — so `processGuestBootChange` (and the **entire** drive gate)
   bailed at its first guard, never reaching any boot-id/bind logic. `bootstrap.json` *had* a complete
   `local_api` block, but `MaybeIngest` returned immediately on "already configured" (customer.id set),
   so a controller.yaml seeded before `local_api` existed never got the agent path merged. **Fix:**
   `MaybeIngest` now calls new **`ensureLocalAPI`** on the already-configured path — it merges `local_api`
   from bootstrap.json into the existing controller.yaml when missing (no hub re-pull, config preserved),
   idempotent + fail-safe.
2. **The boot-race readiness gate.** `processGuestBootChange` sampled the agent's `BoundUnderParent`
   **once** during fast startup, racing the ~18s rebind, recreated nothing, and burned its boot-id
   one-shot. **Fix:** it now gates on the **REAL live in-guest bind** — new `driveBindLive` checks
   whether `/mnt/felhom-drives/<drive>` is an actual mountpoint in the controller's own `/mnt` (rslave)
   `/proc/self/mountinfo` (true only once the agent's bind propagated, exactly when docker can recreate
   the app), and new `pollLiveBinds` **waits** for it (bounded ~120s, poll 2s) before recreating via the
   normal pipeline (`compose down`→`up -d`). `shouldRecreateOnBoot` is unchanged and state-independent,
   so a stuck-`Exited` create-time-failure app is included.
3. **The single-shot fragility.** `processGuestBootChange` ran only once at startup; right after a guest
   reboot the agent's local API can be briefly unreachable/stale, so the one attempt bailed and never
   retried. **Fix:** `driveGateLoop` now runs it on every periodic tick too — idempotent (boot-id
   gated), so it retries until the agent is reachable.

Apps on a drive that never goes live in the window are left to the normal gate. The host-reboot path the
earlier sweep validated is unaffected (same code path, strictly more robust); the **guest-only reboot
path** (never exercised by host-reboot sweeps) is now covered.

Tests (non-hollow, with pre-fix companions, red-proofed): `pollLiveBinds` waits through the rebind then
reports live (recreate fires) / never-live stays absent / a single early sample misses the not-yet-live
bind; `ensureLocalAPI` merges `local_api` into an already-configured controller.yaml that lacks it
(companion: pre-fix MaybeIngest left it empty) and no-ops when already present. Live-accepted with
**guest reboots ×2 AND host (`felhom-pve`) reboots ×2** — all 8 drive-backed apps recover automatically,
zero manual starts; the persisted boot-id now advances per boot (it had been frozen at the first-boot
value). Note: the agent path worked at the v0.68 acceptance (it surfaced a real bug there) and regressed
afterward — `controller.yaml` is reset to the golden's no-`local_api` baseline on each container recreate
and the old `MaybeIngest` never re-merged it; `ensureLocalAPI` closes that. The boot-race manifests on
host reboots too (not just guest), so both paths needed this fix.

### v0.70.0 — config-apply self-restart + geo-restriction UX fixes (2026-06-16)

Fixes found during live geo testing (rotating the Cloudflare API token).

- **Config-apply now self-restarts (core fix).** `POST /api/config/apply` previously wrote the new
  `controller.yaml` but logged "restart needed" and left stale in-process singletons — the Cloudflare
  client is built once at startup, so a rotated CF token kept 403'ing until a manual LXC restart. Now:
  if the pushed config is byte-identical to the current one, do nothing (no flap on idempotent
  re-push); otherwise write, respond 200 (flushed), then **gracefully self-restart** (`os.Exit(0)` after
  ~500ms; the container is `restart: unless-stopped`, so it comes back with fresh config). The exit is
  behind an injectable `Restarter` seam (`Router.restart`/`SetRestarter`) for unit testing. Removed the
  stale "restart needed" wording and the dead `OnConfigApplied` hook (Phase-1-retired infra-backup push).
- **Manual "Vezérlő újraindítása" button** on the settings page → `POST /api/selfrestart` (auth + CSRF
  via the `/api/` mount) using the same helper. Confirm dialog → POST → polls `GET /` every 2s until the
  controller answers → reloads. Self-serve restart without rebooting the whole guest.
- **Immediate hub report push on geo change.** A successful geo settings save and a successful manual
  geo sync now fire an out-of-band, non-blocking report push (`Router.reportPushNow`), so the hub
  reflects the new geo state / clears a stale `last_sync_error` within seconds instead of after the
  next ~15-min cycle. (Pattern can extend to other settings later; scoped to geo handlers for now.)
- **Always report `geo_restriction`.** `BuildReport` now always populates the field (Enabled=false,
  empty countries when never configured) instead of omitting it when nil — so the hub always renders
  the geo section ("Inaktív" when off) rather than hiding it.
- **Country autocomplete fixed.** Root cause (diagnosed live): `filterCountries` populated the list
  correctly but revealed it with `style.display = ''`; the `.geo-country-list` CSS default is
  `display:none`, so clearing the inline style kept the populated dropdown hidden — no console error,
  just an invisible list. Latent since the geo feature's first commit (not the hypothesised JS throw).
  Fix: reveal with `display = 'block'`.

### v0.69.0 — remove dead infra-backup stubs + the unused restic-password report field (2026-06-16)

Controller half of the Phase-1 Infra Backup retirement (hub v0.12.0; see
`felhom.eu/documentation/audits/SPIKE-infra-backup-2026-06-15.md`). Pure dead-code removal — no
behaviour change (everything removed was already caller-less).

- **Removed `report.Pusher.PushInfraBackup`** — pushed the infra-backup payload to the now-removed hub
  endpoint `POST /api/v1/infra-backup`. Dead since slice 8C; no callers.
- **Removed `notify.Notifier.NotifyBackupCompleted`** (the `backup_completed` event) — no callers
  since whole-guest backup moved to the agent in slice 8C. The hub's backup-deadline check now reads
  the agent host-report's PBS snapshots instead of this event. `NotifyBackupFailed` and the DB-dump
  notifiers are untouched and still used.
- **Removed `report.BackupReport.ResticPassword`** (`json:"restic_password"`) — the live report
  builder (`buildBackupReport`) has left it empty since slice 8C, but the field historically leaked
  the restic password into the hub's plaintext `reports` store. Confirmed via pushed source (builder
  never sets it) **and** live data (current reports carry no `restic_password`) before removal.

### v0.68.3 — fix Beállítások page endless-refresh loop after a migration (2026-06-15)

Found while live-validating the M3 migration: once any data migration finished, the **Beállítások
(settings) page reloaded itself every ~1.5 s, forever**. The migration journal keeps returning the
last completed job indefinitely (`MigrationStatus` is not cleared on `done`); the page's resume-view
IIFE called `migWatch()` for *any* returned job, and `migWatch`'s `done` branch does
`setTimeout(location.reload, 1500)`. So every load saw the persisted `done` job → watched it →
reloaded → saw it again → looped endlessly.

Fix (settings.html): the resume-view now starts the watcher **only for an in-progress job**
(`phase !== 'done' && phase !== 'aborted'`). The one-time post-completion reload still fires from the
*active* watcher started by `storageMigrateAll`, so a real migration still refreshes drive state once
when it finishes — but a stale terminal job in the journal no longer triggers the loop. Template-only
change.

### v0.68.2 — fix stack-card state-badge clipping on unhealthy apps (CSS) (2026-06-15)

The `.stack-detail-header` is a `flex` / `space-between` row holding the `.stack-title-row` (logo +
title + subdomain link + the `route-unpublished` warning) and the `.stack-state-badge`. On an
**unhealthy** app the long "⚠ URL nem elérhető – útvonal nincs publikálva" warning inflated the
title-row; because the title-row had no `min-width:0` it refused to shrink below its content, and
because the `white-space:nowrap` badge had no `flex-shrink:0` the flexbox compressed the BADGE
instead — clipping "Nem egészséges" to "Ner…". Healthy / not-deployed cards don't render that
warning, so only unhealthy cards clipped.

- `.stack-title-row` → `flex: 1; min-width: 0;` (allowed to shrink + wrap its own content).
- `.stack-state-badge` → `flex-shrink: 0;` (never compressed).

Pure CSS; no behavior change. Browser-verified on /stacks: komga's badge now reads the full "Nem
egészséges" and the warning wraps within the title column; healthy ("Fut") and not-deployed cards
unchanged.

### v0.68.1 — boot-id recreate ALL deployed drive-backed apps (state-independent) (2026-06-15)

Fix caught live in the E1 host-reboot test: `shouldRecreateOnBoot` filtered on container state
(`State != stopped`), so apps docker hadn't auto-restarted yet at the one-shot boot-id instant were
MISSED (5 apps stayed exited after a host reboot). The boot-id recreate now recreates EVERY deployed
drive-backed app whose drive is present, independent of current state (`app.yaml` deployed = should run)
— truly deterministic. Test updated.


### v0.68.0 — storage lifecycle on the intermediary model: H2/H3/M1/M3 + deterministic boot-id (2026-06-15)

Pairs with agent v0.36.0. Finishes the storage lifecycle on the new mount model.

- **Boot-id determinism (kills the 1/8 race).** `processGuestBootChange` replaces the fragile
  container-uptime sample: the agent reports `guest_boot_id` (changes per guest boot, stable across a
  controller-only restart), persisted in settings (`LastGuestBootID`). On a change, every deployed
  drive-backed app whose drive is present and that docker brought back (`shouldRecreateOnBoot`: state not
  stopped/not_deployed) is DETERMINISTICALLY recreated onto the populated path. Respects user-stop;
  gate-stopped apps stay the gate's job.
- **H2 — decommission UI button** (settings.html): a "Leszerelés" button on every connected drive →
  migrate-then-decommission (uses the inline target select) OR decommission-anyway (type-to-confirm the
  drive name). Both modes were already server-side; the new model never touches the parent mp.
- **H3 — one-click re-enroll/reconnect.** `handleStorageReconnect` now also handles a DECOMMISSIONED
  drive: clears the soft marker + schedulable, re-attaches under the parent, restarts apps (re-discovered
  via `appsOnStoragePath` since decommission-anyway doesn't persist StoppedStacks). New
  "Visszacsatlakoztatás" button on decommissioned drives.
- **M1 — default reassignment.** `defaultPromotionTarget` + `finalizeDecommissionWith`: decommissioning
  the DEFAULT auto-promotes another schedulable drive (preferring the migrate target); if NONE exists the
  decommission is BLOCKED with a clear message (never zero default).
- **M3 — userdata setgid on migrate.** The merge-walk now RE-ASSERTS 2775-setgid/gid-1000
  (`EnsureUserdataDir`) on the userdata tree (`isUserdataDir`) instead of merely preserving the source
  mode — so a pre-existing stale 755 target dir (e.g. import/calibre) is corrected.
- Fix: the H1 disconnect/reconnect/restart-apps JS sent `{path}` but the handler decodes `{where}`
  (always 400); response keys realigned (`restarted`). New buttons use `{where}`.

Tests (non-hollow + companions): `TestShouldRecreateOnBoot` (old sample missed a healthy-stale app),
`TestDefaultPromotionTarget`, `TestIsUserdataDir`.


### v0.67.5 — gate: startup recreate waits for stack scan + handles exited apps (2026-06-15)

Adds a bounded wait for the stack scan (GetStacks is empty at NewServer time, so the recreate found no
apps) before the one-time boot-stale recreate, and recovers exited/restarting/unhealthy drive-backed
apps (not only recently-started). The deterministic guest-reboot convergence.

Refines v0.67.3's `recreateBootStaleApps`: recreate a present drive-backed app when it is boot-stale
(recently started) OR currently `exited`/`restarting`/`unhealthy` (came up wrong on the empty bind and
bailed) — the recency-only gate missed apps that had already exited. Still skips healthy long-running
apps (no bounce on a controller-only restart) and cleanly user-stopped apps.

### v0.67.3 — gate: startup recreate of boot-stale drive-backed apps (2026-06-15)

Completes guest-reboot convergence (caught in the live migration). On a guest reboot docker auto-starts
the app containers (restart:unless-stopped) potentially BEFORE the agent re-propagates the drive under
the parent, so they bind the empty fail-closed stable dir and (leaf-bind pinning) never pick up the
later propagation. `driveGateLoop` now runs a one-time `recreateBootStaleApps` at startup (the controller
restarts with the guest): for each deployed drive-backed app whose drive is NOW present
(BoundUnderParent) and whose containers started recently (a fresh boot, not a controller-only restart —
`stackStartedRecently`), it recreates the app (down+up) onto the populated path. Apps whose drive is
still absent are left to the normal stop→return→restart gate. Paired with agent v0.35.0 (the drive
re-propagation).

### v0.67.2 — gate: key "present" on BoundUnderParent (reboot convergence) (2026-06-15)

The drive-absent gate now treats a stable path as usable only when the agent reports it BOUND UNDER THE
PARENT (`BoundUnderParent`), not merely host-mounted (`State==attached`). This makes a host reboot
converge correctly: at boot the raw drive mounts early but the agent binds it under the parent slightly
later, so until then the apps' stable-path binds are empty — the gate keeps the apps stopped and
restarts (recreates) them once the bind is live. Legacy raw paths still use the host-mount signal.

### v0.67.1 — gate: only act on external drives under /mnt/felhom-drives/ (2026-06-15)

Fix (caught live on the v0.67.0 deploy): `planDriveGates` marked the internal SSD path
`/mnt/sys_drive/felhom-data` "disconnected" because the agent never reports it as a drive — which would
have blocked starting SSD-resident apps. The gate now only considers EXTERNAL drives registered under
the stable parent `/mnt/felhom-drives/<name>`; always-present SSD/system paths are skipped. Regression
case added to `TestPlanDriveGates`. (No apps were stopped — no app depended on the SSD path.)

### v0.67.0 — intermediary-mount: HDD_PATH repoint + drive-absent gate + H1 routes (2026-06-15)

Controller half of the intermediary-mount re-architecture (pairs with agent v0.34.0). Drives are now
visible in the guest ONLY at the STABLE path `/mnt/felhom-drives/<name>` (the host swaps the backing
drive underneath it; no per-drive `pct` mp, no guest reboot).

- **Repoint** (`internal/web/intermediary.go`): the registered storage path + every app's HDD_PATH +
  FileBrowser source = the stable `/mnt/felhom-drives/<name>` (`stablePathForName`); the AGENT still
  operates on the raw `/mnt/<name>` host mount, so controller→agent `where` is mapped back via
  `agentWhere()` at the assign/attach/eject/decommission call sites. Enroll now binds-under-the-parent
  BEFORE register/skeleton (the controller can only see/write the drive at the stable path post-attach).
  `agentapi.DiskInfo` gains `GuestPath` + `BoundUnderParent`. New `settings.RepointStoragePath` for the
  migration. FileBrowser + monitoring follow `sp.Path` automatically.
- **Drive-absent GATE**: `ReconcileDriveGates` (pure decision `planDriveGates` + executor) on a 30s loop
  (`driveGateLoop`, replacing the retired slice-8C watchdog) — an ABSENT drive's apps are STOPPED +
  recorded (`StoppedStacks` = the gate-stopped set, distinct from a user stop); a RETURNED drive is
  re-attached under the parent and its gate-stopped apps AUTO-RESTARTED. Start-gate in `actionStack`:
  refuses to start an app whose drive is disconnected/decommissioned (clear "tárhely nem elérhető"
  message) — so it can't write to the empty fail-closed stable path.
- **H1 endpoints routed** (were 404): `POST /api/storage/{disconnect,reconnect,restart-apps}` →
  host-side eject/reconnect (stop→agent-detach→fail-close / agent-attach→restart→clear) — no guest
  reboot.

Tests (non-hollow + companions): `TestPlanDriveGates` (4 states; trivial impls fail), `TestAgentWhere`
(stable↔raw idempotent mapping), `TestRunStorageInit_Success` (agent gets RAW, registry gets STABLE).

### v0.66.2 — FileBrowser umask 002 (customer folders group-writable) (2026-06-15)

FileBrowser (uid 1000) created folders with umask 022 → mode 2755 (setgid from the parent, but
group-READ only), so a folder a customer made in FileBrowser could not be written by the content apps
in group 1000. The gtstef/filebrowser image is a single Go binary (`entrypoint ./filebrowser`) and does
NOT honor a `UMASK` env (verified live: `-e UMASK=002` leaves PID1 at 0022), so `RenderFileBrowserCompose`
(`internal/infra/infra.go`) now wraps the entrypoint:
`["sh","-c","umask 002; exec /home/filebrowser/filebrowser"]`. Customer-created folders now come out
**2775** (group-writable) so all group-1000 apps can use them. Test asserts the rendered compose carries
the wrapper. (Pre-existing pre-fix folders stay 2755 — recreated on the demo; no data.)

### v0.66.1 — fix USERDATA_PATH on first deploy (2026-06-14)

The initial deploy path (`DeployStack` → `composeExecWithEnv`) builds its compose env from the deploy
values, not from app.yaml via `stackEnv` — so v0.66.0 injected `USERDATA_PATH` only on start/redeploy,
NOT on the FIRST deploy. A freshly-deployed app resolved `${USERDATA_PATH}` to `""` and Docker bound a
bogus root-owned dir at the container root (e.g. `/media/movies`) instead of `<drive>/userdata/...`
(found live: radarr's media mount was `0:0 755` at the container root). Fix: a shared `withUserdataPath`
injector used by BOTH `stackEnv` and `composeExecWithEnv`. Regression test asserts injection on/off by
HDD_PATH presence.

### v0.66.0 — userdata layout + shared-storage ownership convention (2026-06-14)

Customer-facing `userdata/` tree (sibling of appdata/backups under each drive's felhom-data namespace)
with a shared-ownership convention so FileBrowser + content apps collaborate without permission
collisions. Spike: `felhom.eu/documentation/audits/SPIKE-userdata-layout-2026-06-14.md`. Pairs with the
app-catalog commit that repoints media mounts to `${USERDATA_PATH}`.

- **Convention helper** (`internal/appbackup/userdata.go`): `EnsureUserdataDir`/`EnsureDirOwned` =
  MkdirAll → explicit `Chmod(ModeSetgid|0775)` (MkdirAll's mode is umask-masked AND drops setgid) →
  chown group to `SharedContentGID` (1000). `UserdataDir`, `UserdataSkeleton` (media/{movies,tv,music,
  audiobooks,books,comics,photos}, downloads, import/{paperless,calibre}, roms, documents),
  `EnsureUserdataSkeleton`. Linux chown via `chownGID`/`StatGID` (`userdata_linux.go`); no-op stub
  off-Linux (`userdata_other.go`).
- **USERDATA_PATH injection** (`stackEnv`, manager.go): injects `USERDATA_PATH = <HDD_PATH>/userdata`
  (HDD_PATH is the namespace root) alongside HDD_PATH, so the catalog's `${USERDATA_PATH}/...` mounts
  resolve.
- **Skeleton pre-create**: `registerStoragePath` + `syncFileBrowserMounts` ensure the full skeleton on
  every storage path (system + additional drives) with the convention.
- **Deploy belt**: `composeExecCustomEnv` (gated on `up`) pre-creates every `${USERDATA_PATH}/...`
  bind source the stack declares (`ParseComposeUserdataMounts` + `ensureUserdataMounts`) so Docker
  never auto-creates a userdata dir as guest-root — covers apps not in the skeleton.
- **FileBrowser mount switch** (`syncFileBrowserMounts`): mounts `<drive>/userdata` (was `appdata`) →
  `/srv/<name>`. FileBrowser runs as uid 1000 → can now create folders + upload into the 2775 setgid
  userdata (fixes the permission-denied); app internals (appdata/) are no longer browsable.
- **#8 migration fix** (`migrate.go`): the non-app merge walk now preserves the SOURCE dir's full mode
  (incl. setgid via `preserveDirOwnership`) + group, and `copyFile` preserves the full file mode
  (`fi.Mode()`, not `.Perm()`) + group — so the ownership convention survives a whole-drive `MigrateAll`.
- Non-hollow tests: `EnsureDirOwned` produces 02775+setgid+gid (Linux companion proves a plain MkdirAll
  has NO setgid); skeleton structure; `ParseComposeUserdataMounts` selectivity; deploy belt creates the
  declared dirs; **migration preserves setgid+group** (Linux; mutation-proven against the pre-fix
  0755/.Perm() path).

### v0.65.0 — data migration + self-serve decommission (B1+B2) (2026-06-14)

Customer-self-serve storage **migration** (move app data between drives) and **decommission** (retire
a drive), implemented trunk-based with the locked spike design
(`felhom.eu/documentation/audits/SPIKE-decommission-migration-2026-06-14.md`). Pairs with agent
v0.32.0 (the self-serve `/disks/decommission` endpoint + intent-aware re-assert). Built + deployed to
demo guest 9201. **Live decommission/migration of real data is NOT yet validated — that is the
supervised B3 session.**

- **B1 — migration engine** (`internal/stacks/migrate.go`). In-process over the controller's
  `/mnt:/mnt:rslave` RW mount; crash-safe + resumable via a single journal (`<dataDir>/migration.json`).
  Two entry points share one pipeline: `MigrateAll` (whole namespace — every app + a conflict-merge
  walk for non-app/customer content) and `MigrateApp` (one app subtree; handles drive→drive AND
  SSD→drive). Pipeline: validate → stop → copy (`rsync -a --checksum`, additive, NO `--delete`) → verify
  (`rsync -ani --checksum`, zero pending) → flip+redeploy (`RedeployFromEnv`, one idempotent unit) →
  cleanup. **CLEANUP is the only destructive step and is gated on every unit verified AND every app
  redeployed.** Conflict-merge: skip-identical (checksum vs the target file AND its `(N)` siblings),
  rename-on-differ to the lowest-free `<base>(N)<ext>`, never overwrite; idempotent (no `(1)(1)`).
  Single-flight; **mutual exclusion with the backup orchestrator** (Change 3 — migration refuses while a
  backup runs; the scheduled DB-dump/Tier-2 skip while a migration runs).
- **B1 UI** — `POST /api/storage/migrate` (whole-namespace), `POST /api/storage/migrate-app` (per-app),
  `GET /api/storage/migrate/status` (poll). The greyed migrate-all `<span>` in settings.html is now a
  real target-select + button; app_info.html gains a per-app "Áthelyezés másik tárhelyre" control; both
  share a Hungarian progress panel.
- **B2b — decommission orchestration** (`handleStorageDecommission`, `POST /api/storage/decommission`).
  Two choices, no partial (Change 2): **migrate-all-then-decommission** (runs `MigrateAll`; the
  migration done-hook soft-marks the source + calls the agent once every app has moved) or
  **decommission-anyway** (type-to-confirm; stops the apps but KEEPS their `HDD_PATH` so they show
  "missing storage"). `agentapi.Decommission` added; both branches end at `SetDecommissioned` (soft
  marker retained — blocks A1 resurrection) + agent `Decommission`.
- **"Hiányzó tárhely" indicator** — a deployed app whose `HDD_PATH` resolves to a decommissioned/
  disconnected/absent registry path now shows a distinct warning badge on the dashboard, stacks page,
  and app card (label via `GetStorageLabel`); persists until re-enroll or migrate.
- **Change 4 — re-enroll clears the marker.** `registerStoragePath` now un-retires a re-plugged
  decommissioned drive (`ClearDecommissioned` + restore `Schedulable`) — previously `AddStoragePath`
  deduped the re-register into a no-op and the soft marker (and the apps' missing-storage badge) would
  persist forever. (`ClearDecommissioned` had zero callers before this.)
- Non-hollow tests across `internal/stacks` (engine: collision-refuse, merge dedup/idempotency,
  cleanup-only-after-redeploy, verify-catches-corruption, resume, single-flight, SSD→drive, backup
  exclusion), `internal/backup` (scheduled backup skipped while migrating), and `internal/web`
  (finalize soft-mark+agent, re-enroll clears marker, missing-storage label). Companions for the
  collision guard, cleanup gate, and Change-4 clearing were mutation-proven to fail on the pre-fix code.

### v0.64.0 — storage-lifecycle cleanups (2026-06-14)

Two settings-layer cleanups from the F9 storage-registration diagnosis
(`felhom.eu/documentation/backlog/DIAGNOSIS-f9-storage-registration-gap-2026-06-14.md`), trunk-based on
`main`, each with table-driven tests that fail on the pre-fix code.

- **A1 — `AutoDiscoverStoragePaths` is now ADDITIVE** (`internal/settings/settings.go`). It previously bailed
  early (`if len(s.StoragePaths) > 0 { return }`), so a drive a deployed app referenced but that was missing
  from the registry was never picked up after first run. It now registers only the discovered paths NOT
  already present, while honouring strict invariants: never removes/modifies a manually-added path; SKIPS any
  path already in the registry IN ANY STATE — including a `Decommissioned` soft-marked entry — so it can't
  re-add or reactivate it; never flips `IsDefault` (a newly-discovered path becomes default ONLY if the
  registry currently has no default at all, and only the first such new path). NOT auto-register-on-attach —
  it only picks up paths deployed apps already reference (that recommendation was rejected; manual enrollment
  is by design). New `internal/settings/storage_discovery_test.go` covers it, incl. a companion test that
  FAILS if the skip-by-presence guard is removed (verified: removing the guard re-adds the decommissioned
  path).
- **A2 — internal-SSD label disambiguation** (`InferStorageLabel`, `internal/settings/settings.go`). A path
  whose basename is the `felhom-data` namespace dir (the internal system volume, e.g.
  `/mnt/sys_drive/felhom-data`) previously labelled as `Tárhely (felhom-data)`, colliding with the per-drive
  felhom-data namespace. It now reads **`Belső SSD (rendszer)`**. Discriminator is `base ==
  appbackup.FelhomDataDir`; Model-A user drives register their MOUNT ROOT (e.g. `/mnt/felhom-usb`), never
  `.../felhom-data`, so this can't mislabel a user drive. Still overridable via `SetStorageLabel`. The demo's
  already-seeded `settings.json` label for that path on guest 9201 was updated out-of-band (the seeded value
  doesn't auto-change). Separate host-metrics label in `web/agent_host_metrics_handler.go` was intentionally
  left untouched.

### v0.63.0 — reflect agent F9/F20-BUG2 disk fields (2026-06-14)

Pass through two new fields the host agent (v0.31.0) now returns on `/disks`, so they reach
`/api/disks` and the dashboard (the controller previously dropped them when re-marshalling the agent
response). Additive only — `agentapi.DiskInfo` gains:
- **`wipe_durable_id`** (F20-BUG2) — the device's wipe-binding id in the gate's scheme
  (`byid:`/`byuuid:`), distinct from `durable_id` (`uuid:`, used for assign). A customer-confirmed
  data-bearing wipe must carry THIS id; confirming with the `uuid:` id was rejected (binding_mismatch).
- **`guest_attached`** (F9) — whether the drive is actually bound into this guest (usable in-guest) vs
  merely present on the host — the signal whose absence let an unattached HDD look available.

No behaviour change in the controller itself; the agent owns the fix. (Agent v0.31.0: F9 startup
bind re-assert, F20-BUG2 single wipe-id scheme, F20-BUG3 detached/restart-surviving format.)

### v0.62.0 — M18 + M19 backlog fixes (2026-06-14)

Two verified-LIVE backlog bugs (preserved fix-plans in `felhom.eu/documentation/backlog/`), implemented
trunk-based on `main`, each with a regression test that fails on the pre-fix code. Built, deployed to demo
guest 9201, and both verified live.

- **M19 — `deriveStackName` DB-container misattribution (correctness)** — commit `6bab68b`.
  `deriveStackName` pure-suffix-stripped on `-` (postgres/db/mariadb/mysql/database/redis/cache), so a
  stack whose slug *ends* in a role token (e.g. `my-cache`) was misattributed (stripped to `my`), filing
  its DB dump under the wrong/nonexistent stack. Now threads the set of deployed stack names
  (`m.knownStackNames()` ← `ListDeployedStacks`) into `DiscoverDatabases` and cross-references: use the
  suffix-strip candidate if it's a known stack, else the container name if it IS a known stack (don't
  strip), else the longest known stack that is a `-`/`_`-bounded prefix (handles `<stack>_postgres`,
  `<stack>-1`), else the legacy strip. `nil`/empty known = legacy behaviour (appexport passes nil).
  **Live:** romm-db → `romm-mariadb.sql` (correct). Table test incl. the `my-cache` case (fails pre-fix).
- **M18 — DB-dump validation re-run every cycle (performance)** — commit `f8afe5c`.
  `ListDumpFiles` ran `ValidateDump` (line-by-line scan) for every dump on every ~5-min `RefreshCache`
  cycle — wasted I/O+CPU on large customer dumps. `ListDumpFiles` now takes an optional
  `cached(name,size,mod)` lookup; on a size+modtime match it reuses the prior result and skips
  `ValidateDump`. `settings.DBValidationCache` gains `Size`+`ModTime`; `listAllDumpFiles` builds the
  lookup from the persisted cache and writes back only fresh validations (cache miss) — so an unchanged
  dump triggers neither a re-validation nor a `settings.json` write each cycle. `nil` cached = legacy
  validate-always (back-compat). **Live:** the cache now persists `size`+`mod_time`. Tests: cache-hit
  skips validate (sentinel), cache-miss validates, nil validates.

### v0.61.0 — live-drive Batch 1 (+F17) fixes (2026-06-14)

Controller-side fixes triaged in `LIVE-DRIVE-FIXSPEC-2026-06-14.md` from the 2026-06-14 live-drive
findings. Each fix has a regression test that fails on the pre-fix code. Built, deployed to demo guest
9201, and the key fixes live-verified. (F9, F20-BUG2, F20-BUG3 are the SUPERVISED agent/golden next
session — not in this batch.)

- **F17 (CRITICAL) — per-app restore now replays the captured `.sql` DB dump.** `RestoreFromRecoveryUnit`
  (and the `RestoreApp` fallback) repopulated Docker volume tars but NEVER replayed the captured
  `<stack>-<dbtype>.sql`, so DB-resident data did not come back. New `appbackup.ImportDump` (read-side
  counterpart to `DumpOne`, reuses `DiscoveredDB`'s own discovered credentials) + `backup.reimportDBDumps`
  replay the dump AFTER volume restore + stack bring-up, so the logical dump **wins** over any volume-tar
  copy of the DB (operator-chosen precedence). Volume-restore and DB-import failures now **surface** (the
  restore returns an error) instead of a swallowed WARN. **Live-validated** on guest 9201: a marker row
  dropped after backup was restored by `/backup/restore` (log: "replayed 1 DB dump(s)"). Reuse note:
  `ImportDump` lives in `appbackup` (the DB-domain package) — `appexport→appbackup` already exists so
  reusing appexport's unexported copies would cycle; appbackup is the clean shared home.
- **F1 (HIGH) — guest RAM cap read from the Docker daemon; deploy guard uses committed memory.** The
  controller container reported the Proxmox host's 16 GB (no lxcfs in the container; its own cgroup is
  unlimited — the 2 GB cap is on the LXC ancestor), defeating the deploy memory-headroom hard-block.
  `system` now sources the cap from `docker info` MemTotal (the daemon runs in the LXC → reports the
  guest's real RAM; cgroup limit still preferred when present). The deploy guard now uses the controller's
  own committed-app memory (sum of running mem requests) for "used" — accurate and cheap — instead of
  host RSS. `/api/system/info` reports the guest cap + committed used. **Live-verified:** `total_mem_mb`
  2048 (was 15771).
- **F20-BUG1 (HIGH) — `agentapi.FormatDisk` surfaces the agent's error.** A failed format (agent 502
  "device is mounted", `ok:false`, `data:null`) fell through to `return out, nil`, so the web layer
  reported a zero-value result as `ok:true` — a failed DESTRUCTIVE op read as success. Now returns a
  non-nil error on any non-2xx/`ok:false` that is not a recognized refusal (403/needs-confirmation).
- **F5 (HIGH) — broken healthcheck → 404, two parts.** (catalog, `app-catalog-felhom.eu`) uptime-kuma's
  healthcheck pointed at a v1-era `node /app/extra/healthcheck.mjs` absent in `:2`, so the container
  stayed unhealthy and Traefik withheld the route (404 though running) — fixed to the v2 compiled
  `extra/healthcheck` binary + 180s start_period. (dashboard) new `routeUnpublished` helper + a distinct
  "URL nem elérhető – útvonal nincs publikálva" indicator on the dashboard/stacks cards for
  unhealthy/restarting deployed apps (operator decision: keep gating the route, surface it distinctly).
  **Live-verified:** uptime-kuma healthy → route publishes → status URL 302 (was 404).
- **F8 (LOW-MED) — `controller.yaml` persisted 0600.** It holds infra credentials (cf/hub tokens) in
  plaintext; the Hub config-apply handler wrote 0644. New `writeConfig0600` enforces 0600 even on a
  pre-existing 0644 file.
- **F6 (LOW) — deploy POST reports "started", not "deployed".** The deploy runs async (UI polls); the
  POST now returns 202 Accepted + "Telepítés elindítva…" so API/script consumers aren't told a deploy
  finished before it has.
- **F7 (LOW) — dashboard state lag.** `status-refresh` tightened 30s → 10s (cheap docker-ps refresh).
- **F4 (TRIVIAL) — `GET /api/stacks/rescan`** now returns 405 + `Allow: POST` instead of the misleading
  "stack not found: rescan" fall-through.

### v0.60.0 — M25 data-race fix (backlog-Medium cleanup) (2026-06-13)

Backlog-Medium reconciliation from the 2026-06-13 BUGHUNT reconcile. M4/M5/M6 verified already FIXED
(no action). M18 (dump re-validation every 5 min — perf) and M19 (naive `deriveStackName` misattribution
— low-incidence correctness) verified LIVE but cross-package-entangled; prepared on branches
`fix/m18-dump-validation-cache` / `fix/m19-stackname-crossref` (notes + fix plan, pending review, not
deployed).

- **M25 (Server.integrationMgr data race) — FIXED.** `NewServer` launches the `SyncFileBrowserMounts`
  goroutine (which reads `integrationMgr`) from the constructor, *before* `main.go` calls
  `SetIntegrationManager` — so the init-only happens-before that covers the other `Set*` fields did not
  hold, making it a genuine data race (reads at `handlers.go:358/360/1433` vs the unsynchronized write).
  Converted the field to `atomic.Pointer[integrations.Manager]`; setter `Store`s, all readers `Load()`.
  Regression test reproduces the concurrent access and is clean under `-race` (verified on the build
  server); it flags on the pre-fix plain-pointer field.

### v0.59.0 — security/crash-safety fixes from the 2026-06-13 audit (2026-06-13)

Fixes the validated findings from the deep-sweep audit + BUGHUNT reconciliation
(records under `felhom.eu/documentation/audits/`). All shipped with permanent
regression tests.

- **CTRL-001 (path traversal on `.fab` import) — High.** `appexport.UnmarshalManifest`
  did zero validation; the attacker-controlled `manifest.AppName` / `HDDSubdirs` /
  `VolumeNames` reached `filepath.Join`+`MkdirAll`/`extractTar` (restore.go:339/606/678),
  so `../..` in any escaped the stacks / HDD destination dir (arbitrary write as the
  controller). New `appexport.ValidateSegment` + `validateManifestPaths`;
  `UnmarshalManifest` now fails the parse on a traversal segment, with defence-in-depth
  guards at the HDD-subdir and volume-name join loops. `ConfigFiles` intentionally not
  validated (holds dotfiles, never used in a restore join).
- **CTRL-T2-1 (ghost-deployed stack on crash) — High.** `DeployStack` wrote `app.yaml`
  `deployed:true` to disk *before* the async `docker compose up -d`; a crash during the
  image-pull window left a ghost-deployed stack with no containers that the app then
  refused to redeploy. The env is now persisted `deployed:false` (transitional) and
  flipped to `deployed:true` by `runComposeDeploy` only after `up -d` succeeds. The
  in-memory flag still goes true during the pull (no stale "Telepítés" button).
- **H10 (plaintext secret on encrypt failure) — fail-closed.** `SaveAppConfig` logged a
  WARN then fell through to persist the secret in plaintext on a `crypto.Encrypt` error.
  Now returns an error instead — never writes plaintext.
- **M2 (misleading lock).** `backup.Manager.SetStackProvider` was mutex-guarded while all
  reads were unlocked; it is init-only (one call before any goroutine), so the lock was
  removed and the contract documented. No behaviour change.
- **AGENT-001 (wrong-disk wipe race)** is fixed on the agent branch `fix/agent-001-wipe-durable-reresolve`
  (PENDING REVIEW — not deployed; stored out-of-band per the supervised-merge rule).

### v0.58.0 — infra-protection prevention layer for the OS/Docker-data split (2026-06-13)

Phase 2 of the storage-split slice (Phase 1 = felhom-agent golden + provision). The OS rootfs and
Docker data are split onto separate volumes for resilience; infra (controller/traefik/cloudflared/
filebrowser) shares the one Docker data-root and is protected by **prevention, not placement**.

- **Reserved-buffer headroom guard (`internal/system/dockervol.go`):** `GetDockerVolumeHeadroom()`
  measures the Docker-data volume via `statfs("/")` (the controller container's root overlay is backed
  by the guest's `/var/lib/docker` volume) and computes a reserved floor `DockerVolumeReserveGB` =
  `max(5 GB, 10% of total)`. Fail-open on a measurement error (the buffer is a safety net, not a
  security control).
- **Deploy-time hard gate (`internal/api/router.go` `deployStack`):** a new deploy is **refused** (HTTP
  507 + Hungarian message) when free space on the Docker-data volume is at/under the reserved buffer,
  so customer apps can't fill the volume the infra containers depend on.
- **Deploy-page surfacing (`deploy.html`):** for a new deploy, when below the buffer the page shows a
  clear Hungarian warning and **disables** the "Telepítés indítása" button (mirrors the memory-blocked
  pattern) — the customer sees it before clicking; the API gate is the hard backstop.
- **Runtime monitoring (2C):** confirmed `monitor/healthcheck.go` already watches `sysInfo.DiskPercent`
  = the Docker-data volume post-split (statfs `/`); warn 80% / crit 90% used trip ABOVE the 10%-free
  reserved buffer, so the customer is warned before the deploy gate engages. Comment added to make the
  "SSD disk" alert's target explicit.
- **Log rotation (2D):** baked into the golden's `daemon.json` (`max-size 10m`, `max-file 3`) in the
  felhom-agent golden build — every guest inherits it. Per-app xfs-project-quota caps deferred.
- Tests: `DockerVolumeReserveGB` floor/scale.

### v0.57.0 — UI fixes: stable host-storage list + per-app Tier-2 config panel (2026-06-13)

Part A of the UI-fixes/storage-spike spec (Part B is a build-nothing findings report).

- **A1 — host storage list no longer reorders (item 2):** the monitoring page's `#host-storage-bars`
  list (the client-side one filled from the agent's PVE-storage list — `local`, `local-lvm`,
  `felhom-pbs`, `felhom-usb` with thin-pool % + temperature) reordered on every 8 s poll because the
  agent enumerates `pvesm` in a non-deterministic order and the list never passed through a Go sort.
  Now `enrichHostStorageTargets` (`agent_host_metrics_handler.go`) sorts the `/api/host-metrics`
  response server-side (user-data → system+apps → backup → other; alphabetical by id within a tier)
  and attaches a **friendly Hungarian label + one-line purpose** per entry (e.g. `local-lvm` →
  "Belső SSD – rendszer és alkalmazások"). The raw PVE id is kept and shown muted — **display labels
  only; PVE storage ids are never renamed** (vzdump/PBS configs reference them by name). The
  monitoring JS renders the friendly label + the purpose sub-line. (Note: this is the JS-driven list,
  NOT the server-rendered user-data `buildStorageBars` list that v0.56.0's 4C already sorted.)
- **A2 — per-app Tier-2 config panel (item 4):** the "2. mentés" row's **Beállítás** button used to
  link to the app's deploy page, which has no backup-location setting (a dead end). New route
  `GET/POST /stacks/{name}/backup` (`tier2_config_handler.go` + `tier2_config.html`) is the real
  surface: it shows the current/effective off-drive target, whether it's the size-limited internal
  SSD, the last-run status, and lets the customer **pin a different registered drive** or **turn
  Tier 2 off**. The control is **always visible** — even when only the internal SSD qualifies (shows
  "automatikus: belső SSD — csak DB/konfiguráció" + the rootfs-headroom note) and for non-HDD apps
  (shows honest "already in the PBS whole-guest snapshot; the off-drive copy is supplementary"
  context). The button is repointed on every "2. mentés" branch (incl. the unconfigured + disabled
  states).
  - Persistence: two preference fields on `settings.CrossDriveBackup` — `UserDisabled` and
    `PreferredTarget` — set via `SetTier2Preference` and **preserved across the runner's status
    writes** (`withTier2Prefs`). `selectTier2Target` now honors a valid pinned target (off-disk,
    registered) before the auto-pick; an invalid pin silently falls back to auto. `RunTier2` skips a
    customer-disabled app. Saving with Tier 2 on for an HDD app triggers an immediate run so the
    result shows on return.
- Tests: `enrichHostStorageTargets` order/labels/determinism; `selectTier2Target` honors/falls-back
  on a pin; status writes preserve the preference.

### v0.56.0 — Phase 4: FileBrowser scoping + deploy DB-on-SSD note + monitoring storage descriptions (2026-06-13)

Polish layer closing the slice.

- **4A FileBrowser scoping (safety):** the FileBrowser bind mount is now scoped to each drive's
  `appdata/` subtree (`<drive>/appdata:/srv/<name>`) instead of the whole drive root. The recovery
  units + Tier 2 copies under `backups/` are therefore **not mounted into FileBrowser at all** — the
  customer browses their userdata but cannot reach (or even see) the thing that restores them. The
  appdata dir is `mkdir`-ed before the bind so the source exists. (`syncFileBrowserMounts`.)
- **4B Deploy-UI communication:** the storage-selection step now states plainly (Hungarian) that the
  chosen drive holds the app's **files**, while its **database runs on the fast internal SSD** and is
  backed up alongside the app — so "the DB is on the SSD" stops being a surprise. (`deploy.html`.)
- **4C Monitoring storage list:** `buildStorageBars` now sorts deterministically (by path) and carries a
  **purpose description** explaining the user-data drives (rendered on the monitoring "Tárolók
  kapacitása" list). Note: this list is the controller's registered user-data drives only (the agent's
  local/local-lvm/pbs storage is not in this registry), so the role-tier sort/`local`-vs-`local-lvm`
  descriptions belong to the agent-backed storage-management page, not here.

### v0.55.0 — Phase 3: auto off-drive Tier 2 (rootfs-headroom guard, durable off-disk target) (2026-06-13)

Tier 2 = an **off-drive copy** of each HDD app's recovery unit + bulk userdata to a **different physical
disk** — the only off-drive protection browsable HDD userdata can get (PBS can't reach bind mounts).
Auto-enabled for every HDD app; the target is auto-picked and the dangerous case (the small guest
rootfs) is refused rather than filled.

- **Engine** `internal/backup/tier2.go` (`RunTier2`/`RunAllTier2`): rsync `-a --delete` of the recovery
  unit (`backups/primary/<app>/`) and the app's `appdata/<app>/` to `<target>/backups/secondary/<app>/`.
  restic is **not** revived — plain browsable mirror.
- **Auto target selection:** prefer another registered user-data drive on a **different physical disk**
  (can hold bulk userdata); else fall back to the internal SSD for **small units only**. Off-disk is
  enforced by `system.SamePhysicalDevice` (block-device identity; new exported helper, linux + stub) —
  defense-in-depth re-checked before the copy.
- **Rootfs-headroom guard (the key safety):** the SSD target is the ~8 GB guest rootfs, so a size-aware
  guard (`tier2FitsHeadroom`, unit-tested) **refuses** unless the unit fits while leaving a reserve free
  (`max(2 GB, 20% of total)`). When nothing fits, it records an **honest** "needs a 2nd HDD" status
  rather than silently doing nothing or endangering the rootfs.
- **Status + UI:** results persist via the surviving `settings.CrossDriveBackup` (rsync method, dest,
  last-run/status/size). The "2. mentés" card is now **populated** (`buildAppBackupRows`): real target
  ("belső SSD (csak DB/konfiguráció)" vs an external drive) on success, or the honest no-off-drive-target
  reason. Notifications via the surviving `NotifyCrossDrive{Completed,Failed}` hooks.
- **Scheduling + trigger:** daily `tier2-backup` job (03:30, after the DB dump); manual
  `POST /api/backup/tier2`.
- Fixed a stale pre-existing test (`TestBackupCopiesOnPath`) that still used the old
  `felhom-data/backups/secondary` layout — now the Model-A in-guest layout the Tier 2 copies actually use.

### v0.54.0 — Phase 2b: restore-from-recovery-unit + fail-closed data-key gate (2026-06-13)

Restore now recreates an app from its on-drive recovery unit **plus the guest's own secrets** — never
from secrets stored in the unit (there are none), and **regenerating nothing**.

- **Fail-closed data-key gate** (`reconcileRestoreSecrets`, `internal/backup/restore_unit.go` — a pure,
  exhaustively unit-tested function): merges the unit's non-secret env with the secret values recovered
  from the guest's live app.yaml. A missing/empty **data-encrypting key** (`data_key`) **aborts the
  restore** with a clear message (a PBS whole-guest restore is required) — because regenerating it would
  render stored data unreadable. A missing *resettable* secret (DB/admin password) is non-fatal (warn +
  proceed; the app may need a credential reset). Secrets are recovered, never regenerated.
- **`RestoreFromRecoveryUnit`**: reads the unit manifest → recovers secrets from the guest
  (`RecoverStackSecrets`) → applies the gate → restores named-volume data from the unit's tars →
  recovers the app definition from the unit and redeploys with the reconstructed env (re-pulling the
  pinned image). Falls back to the legacy volume-only `RestoreApp` if no unit exists. Wired into the
  `/backup/restore` web handler.
- **New seams:** `StackDataProvider.RecoverStackSecrets` / `RecreateStackFromUnit` (main.go
  `stackAdapter`, with the controller `encKey` for decrypting the live app.yaml); `stacks.Manager.
  RedeployFromEnv` (writes app.yaml from the full env incl. locked secrets, then `compose up -d`).
- **Tests:** the gate (all recovered / data-key missing → refuse / empty data-key → refuse / resettable
  missing → proceed+warn, recovered values used verbatim) and `data_key` parsing from `.felhom.yml`
  (`Metadata.DataKeyEnvVars()`).
- **Live-validated on guest 9201 (AdventureLog, a real data_key app):** its recovery-unit manifest
  correctly carries `data_key_env_vars: [SECRET_KEY]` (catalog→metadata→manifest flow proven live); and
  with `SECRET_KEY` made unrecoverable, `POST /backup/restore` **refused** with the exact fail-closed
  message ("…[SECRET_KEY] could not be recovered … a PBS whole-guest restore is required first…"),
  **before any compose-up** (no side effects). The demo has no dashboard password, so the API is open
  (auth + CSRF are both skipped in that mode) — this was driven via the public URL. Gate + reconciliation
  + orchestration + data_key parsing are also unit-tested.
- **One e2e not run (environment limit, not a code gap):** the full "deploy with data → restore →
  confirm data decrypts" — AdventureLog's images don't fit the **8 GB guest rootfs** (the deploy hit "no
  space left on device"). This is exactly the Phase 3 rootfs-headroom concern, now observed live.
  Key-preservation/regenerate-nothing is covered by the gate's verbatim-recovery unit test.

### v0.53.1 — Phase 2: recovery units refresh on the periodic cache cycle (idempotent) (2026-06-13)

The recovery-unit capture now also runs from `RefreshCache` (controller startup + every 5m), not only
the daily DB dump — so a unit exists shortly after startup and stays current with config changes
(redeploy / optional-config) without a 24h wait. `CaptureRecoveryUnit` builds the captured content in
memory and **skips all writes when the unit is already current** (same config checksums + dump set +
controller version), so the periodic refresh does not thrash a spinning USB drive. Added an idempotency
test (unchanged → skip; config change → rewrite).

### v0.53.0 — Phase 2: per-app self-contained recovery unit (capture side, SECRET-FREE) (2026-06-13)

Each app's on-drive backup becomes a complete, recreatable **recovery unit** — not just DB dumps +
volume tars, but the app's *definition* too, so it can be recreated. The unit is **secret-free by
design** (decided after reading the actual hub code: the hub is deliberately zero-knowledge and holds
no app secrets; app.yaml + the encryption key live on the guest rootfs → already inside the PBS
whole-guest snapshot). Secrets/data-keys are recovered at restore from the guest's own app.yaml (live,
or via PBS) — **never stored in the unit, never regenerated**.

- **Unit layout** (rooted at the existing `backups/primary/<app>/` — no risky dump-dir migration):
  `compose/` (docker-compose.yml + .felhom.yml + a **secret-stripped** app.yaml) + the existing
  `db-dumps/` + `volume-dumps/` + `manifest.json`. New path helpers `RecoveryUnitPath` /
  `RecoveryUnitComposePath` / `RecoveryUnitManifestPath` in `internal/appbackup/paths.go`
  (`AppDBDumpPath`/`AppVolumeDumpPath` refactored onto `RecoveryUnitPath` — identical resolved paths).
- **Secret-free manifest** (`internal/backup/recovery_unit.go`): app id, display name, controller
  version, timestamp, drive, namespace root, pinned **image tags** (image NOT stored — re-pulled on
  restore), the **NAMES** of secret env vars (values never stored), the `data_key` env-var names, the
  explicit `secret_source` note ("guest app.yaml (live) or PBS — never stored in this unit"), captured
  config-file list, enumerated dumps, and sha256 checksums of the captured config.
- **Capture has no secret access:** non-secret env is plaintext in app.yaml; the capture simply excludes
  the secret-named keys (plus a defensive `crypto.IsEncrypted` guard), so it reads no secret value. New
  `StackDataProvider.GetStackRecoveryInfo` + `RecoveryInfo` (in `appbackup`), implemented by the main.go
  `stackAdapter`; `ParseComposeImages` extracts the image pins.
- **`data_key` annotation** (`DeployField.DataKey`, `Metadata.DataKeyEnvVars()`): marks a
  data-encrypting key (e.g. AdventureLog's "Titkosítási kulcs", `SECRET_KEY`) — a **fail-closed** safety
  annotation for restore (refuse + warn rather than regenerate-and-corrupt), NOT a per-secret
  preserve/regenerate decision. Catalog: `adventurelog/.felhom.yml` `SECRET_KEY` marked `data_key: true`.
- **Wired into the dump flow:** `RunDBDumps` refreshes every deployed app's recovery unit after the DB
  dumps (best-effort per app; skips disconnected/decommissioned drives). Capture test
  (`recovery_unit_test.go`) proves the unit is secret-free (a secret in the source app.yaml never
  appears in the unit) and the manifest structure.
- **NOT in this increment (next):** the restore-from-unit *recreate* (re-pull + compose-up + secret
  recovery from guest/PBS) and its fail-closed `data_key` gate, with live AdventureLog readable-data
  validation. The README backup-paths section (stale restic/secondary) is rewritten when Tier 2 lands.

### v0.52.0 — Phase 1 GATE: deploy-side double-nest fix + path-agreement lock (2026-06-13)

Completes the Model-A double-nest reconciliation deferred in v0.48.0. v0.51.0 fixed the **backup
helper** side (`NamespaceRoot` provenance); the **deploy/compose** side still wrote one segment too
deep. On a Model-A in-guest drive the guest mount `/mnt/<drive>` already IS the host's
`<drive>/felhom-data` namespace, so the catalog templates' `${HDD_PATH}/felhom-data/appdata/<app>`
double-nested to `.../felhom-data/felhom-data/...` on disk — diverging from where the backup helpers
look (`AppDataDir(NamespaceRoot(HDD_PATH,true))`, single-nested).

- **Fix lives in the app catalog** (`app-catalog-felhom.eu`): all four HDD app templates
  (`romm`, `nextcloud`, `immich`, `paperless-ngx`) changed `${HDD_PATH}/felhom-data/appdata/<app>` →
  `${HDD_PATH}/appdata/<app>`. The controller passes `HDD_PATH` through verbatim and never appended
  the segment, so no controller runtime change was needed. Catalog change lands via git-sync /
  "Sablonok frissítése".
- **Agreement test (new):** `internal/stacks/hddpath_agreement_test.go` resolves a compose's
  `${HDD_PATH}` bind mounts via the real deploy-side `ParseComposeHDDMounts` and asserts they are
  byte-identical to the backup-side `AppDataDir(NamespaceRoot(HDD_PATH,true))` — no doubled
  `felhom-data`, deploy and backup locked together so they cannot drift again.
- **Live migration:** existing drive-resident apps whose data sat at the doubled
  `…/felhom-data/felhom-data/appdata/<app>` are migrated (stop → move → verify → redeploy) to the
  single-nested path (RomM confirmed on the demo guest).

### v0.51.0 — offsite-backup UI (felhom-pbs DR) + Model-A double-nest fix (2026-06-12)

Pairs with felhom-agent v0.28.0 (whole-guest backup re-targeted to the offsite PBS tier).

**Backups page — the whole-guest backup is now shown as real DR (separate hardware).** The
"Rendszermentés" section's target label calls out the offsite tier: `backupTargetLabel` returns
**"Biztonsági szerver – külön hardver (PBS)"** for a PBS-stored backup (detected via `backupIsPBS`
on the target id / archive volid), so the customer sees the backup survives a host hardware failure.
The app-data section's **"Távoli mentés"** card stops reading "nincs beállítva": a new
`guestBackupView.Offsite` flag drives it to **"külön hardveren (PBS)"** with a ✓ when the whole-guest
backup landed on PBS. The restore-test "Visszaállítás ellenőrizve" trust signal is unchanged.

**Model-A double-nest fix — drive-resident app backups land single-nested.** Under slice-10 Model A the
host agent binds `<drive>/felhom-data` onto the guest mountpoint, so an enrolled drive's in-guest mount
IS the felhom-data namespace root (basename need not be `felhom-data`, e.g. `/mnt/felhom-usb`). The
backup path helpers were re-prepending `felhom-data`, producing `.../felhom-data/felhom-data/...` on the
host. `appbackup` path helpers now take a NAMESPACE ROOT (no internal `felhom-data` join) plus a new
`NamespaceRoot(drivePath, inGuestDrive)`; `backup.Manager.namespaceRoot`/`AppNamespaceRoot` resolve
provenance (a drive-resident app's mount is the root as-is; only the SSD-only `systemDataPath` fallback
appends `felhom-data`). All parallel constructions updated coherently so writes, deletion
(`GetStackBackupData`, `RemoveStack` backups-base + `ProtectedHDDPaths` — legacy double-nest dirs kept
protected), the wipe-warning secondary scan, and export all agree. `api.router` passes the namespace
root across the package boundary. New `appbackup` test asserts no doubled `felhom-data` segment for an
in-guest drive and exactly one for the system fallback.

### v0.50.0 — slice 10 P4: dual-role drives + backup-aware wipe warning (2026-06-12)

Pairs with felhom-agent P3 (self-heal). Establishes the dual-role MODEL + the backup-aware wipe
warning; the cross-drive backup ENGINE (restic USB1↔USB2) is a follow-on slice (needs a 2nd physical
drive to validate) and is deliberately NOT built here.

- **4A dual-role eligibility:** a user-data drive is appdata AND backup-target-eligible (it may hold
  cross-drive backup copies of *other* drives) — it is not locked to a single role. Surfaced in the
  drive overview's per-card purpose note ("Más meghajtók biztonsági mentési céljaként is szolgálhat").
  `felhom-pbs` stays the dedicated whole-guest backup datastore (operator-signature); system/backup
  roles unchanged.
- **4B backup-aware wipe/eject warning:** `handleStorageImpact` now also returns `backup_copies` — the
  apps whose cross-drive (secondary) backups are stored on the drive (`backupCopiesOnPath` scans
  `felhom-data/backups/secondary/<app>`, skipping the shared restic repo / `_infra`). The type-to-
  confirm modal names them ("Ez a meghajtó más alkalmazások biztonsági másolatait is tárolja — a
  törlés ezeket is eltávolítja"). The wipe stays **customer-confirmable** (the copies are redundant —
  originals live on the source drive), not operator-signature. Forward-compatible: empty until the
  cross-drive engine writes there. Test: `TestBackupCopiesOnPath`.

### v0.49.0 — slice 10 P2 activation: pending-drive detection + "Újraindítás most" (2026-06-12)

A drive enrolled into a running guest activates only at the next guest boot (the host-side live inject
is blocked on unprivileged LXC — see felhom-agent v0.26.0). Per the decision: enroll persists (no forced
reboot), and the customer activates pending drives with one batched restart.

- **Pending detection** (`pendingActivationDrives`): a registered StoragePath whose backing drive the
  agent reports present+attached but which is NOT a live mount in this container → "pending activation".
- **Settings UI:** a banner ("N meghajtó aktiválásra vár") with an **"Újraindítás most (~30 mp)"** button
  (one restart batches all pending drives). `POST /api/storage/activate` → `agentapi.GuestReboot` →
  agent `POST /guest/reboot`. The reboot takes the controller down too, so the JS reloads after the
  restart window rather than awaiting the (cut-short) response.

### v0.48.0 — slice 10 P2C: enroll passes the drive into the guest (passthrough) (2026-06-12)

Pairs with felhom-agent v0.25.0 (`POST /disks/guest-attach`) + the golden's `/mnt:rslave` controller
bind. Closes the diagnosed Branch-A gap: enrolling an external drive now makes it actually usable in
the guest, not just mounted on the host.

- **agentapi:** new `GuestAttach(where)` → `POST /disks/guest-attach` (idempotent on the agent side).
- **Enroll triggers attach:** `runStorageInit`, `runStorageAttach`, and `handleStorageRegister` call
  `attachIntoGuest` after recording the StoragePath. Best-effort (logged, non-fatal) — the registration
  is the durable intent; a transient attach failure is healed by P3 self-heal (next slice). Test:
  `TestRunStorageInit_Success` now asserts the drive is guest-attached.
- Note: app data on these drives is written via `HDD_PATH` (the registered `/mnt/<name>`), which Model A
  binds to the drive's `felhom-data` namespace — so app bytes land on the external drive, and the
  controller's storage probe (os.Stat + IsMountPoint) sees a real mount → the "nem elérhető" banner
  clears. (The controller's own backup-path helpers' `felhom-data` level is reconciled when app-data
  backup-to-drive is wired; not P2.)

### v0.47.0 — backups page: whole-guest backup visibility + manual trigger (agent-sourced) (2026-06-12)

The backups page previously showed only the app-data (DB-dump) tier and had **zero** view of the
agent's whole-guest PBS/vzdump backup. Adds visibility + a manual trigger over the agent's existing
per-guest backup API (no agent change). Cadence/retention CONFIG stays out (hub-served policy, slice 10).

- **agentapi (2A):** `StatusResponse` gains `Backup *BackupRecord` (the agent's latest recorded
  whole-guest backup — target/archive/mode/size/success/started-at); `DueResponse` gains `age_seconds`;
  new `RestoreTestStatus()` → `*RestoreTestRecord` (the "verified restorable" signal, nil until one
  runs). Non-hollow client tests (`backup_test.go`): parse the documented JSON + assert `StartBackup`
  POSTs to `/backup`.
- **Section "Rendszermentés (teljes mentés)" (2B):** new read-only cards above the app-data section —
  last whole-guest backup (time + size + **target: PBS vs Helyi (local)**, surfaced from the archive
  volid/target-id), next-due (from `/backup/due` age vs cadence), restore-test result, and the running
  phase. Agent-unconfigured/unreachable degrades to a note, page still renders.
- **Manual trigger "Mentés most" (2C):** **the controller owns quiescing** (confirmed: the
  `quiesce.Loop` stops stacks → `POST /backup` → polls → resumes; the agent's vzdump is crash-consistent
  only). The button therefore goes **through the loop**, not a bare agent call. `quiesce.Loop` gains a
  mutex + `TriggerNow()` (single-flight via `TryLock` + the existing marker; `ErrBackupInProgress` on
  overlap) that runs the same stop→backup→resume cycle async, bypassing the due-check. New
  `POST /api/guest-backup/trigger` + `GET /api/guest-backup/status` (distinct prefix from apiRouter's
  app-data `/api/backup/{run,status}` to avoid shadowing). The button warns per mode (snapshot ≈ a few
  seconds' downtime on lvm-thin; stop = full downtime).
- **App-data section (2D):** the existing per-app DB-dump rows/table are now under a clear
  "Alkalmazás-mentések (adatbázis + konfiguráció)" divider, distinct from the whole-guest tier above
  (whole-guest = appliance restore; app-backup = granular per-app). No structural change.
- **Config (2E):** OUT OF SCOPE — whole-guest cadence/retention is hub-served policy (slice 10), so it
  survives re-provision; no agent config surface added.

### v0.46.0 — fix: /backups 500 (template referenced disk-tier fields stripped in 8C) (2026-06-12)

`GET /backups` returned **HTTP 500**. Root cause (from the live log, not guessed):
`backups.html:64: executing "backups" at <.Backup.RepoStats>: can't evaluate field RepoStats in type
interface {}`. The 8C de-privileging slimmed `FullBackupStatus` to **app-data only** (DB dumps +
Docker-volume tars; the disk-tier restic/cross-drive backup moved to the host agent), but
`backups.html` still carried the full pre-8C restic UI. It referenced `.Backup.X` struct fields that no
longer exist: `RepoStats, LastBackup, ResticSchedule, NextBackup, PruneSchedule, Retention,
SnapshotHistory, LastCheckTime, LastCheckOK`. While those fields existed-but-nil, `{{if .Backup.X}}`
short-circuited safely; once the fields were *removed from the struct*, the field access itself errors →
500. (Not a panic, not a funcmap/nil-subfield issue; root-level map keys like `.PerDriveRepoStats` are
map lookups → nil on miss → safe, and the `Tier1*/Tier2*` fields are on `AppBackupRows`, still supplied.)

Fix — removed the dead disk-tier UI from `backups.html`, keeping the app-data backup view:
- Section 0 storage-stats: dropped "Mentési tároló" + "Pillanatképek" (RepoStats); kept "DB mentések".
- Section 1 cards: the status card now keys on `.Backup.LastDBDump` (was `.Backup.LastBackup`); removed
  the "Tároló méret" card.
- Section 2 schedule: removed the "Restic pillanatkép" + "Karbantartás" rows and the
  restic-last-backup/retention summary; kept the DB-dump schedule + a DB-dump last-run summary.
- Section 5 "Pillanatképek" (restic snapshot history): removed entirely.
- Section 6 "1. szint" tier: removed the per-drive/repo-stats + integrity rows (relabeled "(restic)" →
  "(adatbázis + konfiguráció)"); kept the DB-dump-count row.

No Go change (`FullBackupStatus` was already correct); template-only. `settings.html`'s `.ResticSchedule`/
`.LastCheckTime` are unaffected — they're root-map lookups (nil-safe), not struct-field access.

### v0.45.0 — storage UX polish: deterministic order, init filter, register shortcut, system-storage clarity (2026-06-12)

Builds on v0.44.0's role-aware drive management. Pairs with felhom-agent v0.24.0 (the eject role-gate
lives at the agent — see its CHANGELOG). This release is the controller-side clarity/ordering polish.

- **Deterministic disk order (B1)** — `GET /api/disks` now sorts the agent's drive list server-side:
  **user-data → system → backup** (then unrecognized), alphabetical by storage name within each tier.
  The agent's storage view iterates an unordered Go map, so the list previously reordered on every
  reload (CLAUDE.md lesson #3). The customer's manageable drives are now always on top, stably.
  `sortDisksForView` in `agent_disk_handlers.go` + `TestSortDisksForView`.
- **Init wizard excludes mounted drives (B2)** — `storage_init.html`'s formattable filter gained
  `&& !d.mount_path`, matching the attach wizard: an already-mounted drive (e.g. `felhom-usb`) no
  longer appears as an "initialize" candidate. Eject it first to make it an init target.
- **Register shortcut (B3)** — a mounted, unregistered **user-data** drive now offers **Regisztrálás**
  as its PRIMARY per-card action (Leválasztás/Törlés stay secondary). It records the existing mount
  into the `StoragePath` registry (no format, no eject) via the new `POST /api/storage/register` →
  `registerStoragePath`, then FileBrowser syncs. The natural "use this drive" intent, not "wipe it".
- **System-storage clarity (B4)** — `local` and `local-lvm` are both kept (not collapsed); each
  storage card now carries a plain-Hungarian **purpose description** keyed on the agent's role/type,
  the app-backing storages (`local-lvm` → "Alkalmazás-rendszer"; user-data → "Alkalmazás-adatok") are
  tagged, and a one-line tiering note above the list answers "which storage do the apps use?". Pure
  controller-side presentation — no agent contract change; role/type stay authoritative from the agent.
- **Eject impact (B5)** — the eject confirmation already lists, by name, the deployed apps that lose
  their storage (via `/api/storage/impact`), at parity with the wipe warning — verified, no change.

### v0.44.0 — role-aware drive management: protected lockout + customer type-to-confirm wipe + drive-list restyle (2026-06-11)

The controller half of the storage-authorization redesign. The drive UI is now driven by the agent's
authoritative **role** (`system` | `backup` | `user-data`, from `GET /api/disks`): the appliance's own
system storage and the backup safety-net are visibly protected with NO destructive controls; the
customer manages their own data drives with informed consent instead of a support ticket.

- **`agentapi` client** — `DiskInfo` gains `role` + capacity (`total_bytes`/`used_bytes`/
  `used_fraction`); `FormatResult` gains `role`/`needs_confirmation`/`durable_id`. `FormatDisk` now
  takes `confirmed` + `durableID` and returns a new `ErrNeedsConfirmation` (user-data, awaiting the
  customer's confirmation) distinct from `ErrFormatRefused` (system/backup, operator signature).
- **Role-aware overview** (`settings.html`, "Meghajtók (ügynök nézet)") — restyled from a raw
  `<table>` to **cards** in the house style: prominent storage name, mono device/mount detail, badges
  for class (gyors/lassú), data ("Adatot tartalmaz"), **role** (🔒 Rendszer / 🔒 Biztonsági mentés —
  védett / Felhasználói adat) and registered state, plus a **capacity bar** (the monitoring page's
  green→amber→red `system-bar`). Destructive controls (Leválasztás / Törlés) render **only** for
  user-data drives mounted under `/mnt`. System/backup get the lock badge and no controls.
- **Type-to-confirm + name-the-apps** — a modal that (1) lists, **by name**, the deployed apps whose
  data lives on the drive (`GET /api/storage/impact` → `appsUsingPath`), and (2) requires the customer
  to **type the mount name** before the destructive button enables. No reflex-clickable destructive
  action. Applies to both eject and wipe.
- **Customer wipe** (`POST /api/storage/wipe`) — eject (unmount + deregister) then a server-side
  two-step customer-confirmed format (learn the agent's durable id, then re-submit `confirmed:true`
  bound to it). The mount name is re-checked server-side. A system/backup device is refused by the
  agent regardless of what the controller sends.
- **Init wizard** (`storage_init.html`) — the data-bearing path now uses the **customer-confirmation**
  flow (type-to-confirm → re-submit confirmed) instead of the `felhom-opsign` instruction; the disk
  selector is restyled to cards and lists only user-data targets. `storage_attach.html` likewise
  restyled (cards, user-data only). No raw `<table>` remains in the storage UI.
- **Tests** — `agentapi`: blank → ok, system/backup → ErrFormatRefused (+pending op), user-data → 
  ErrNeedsConfirmation (+durable id), confirmed → formatted. `web`: init surfaces NeedsConfirmation and
  does NO mount/register; confirmed init forwards the confirmation+durable id and proceeds; the
  dependency-impact (`appsUsingPathIn`) names the right deployed apps.

Pairs with **felhom-agent v0.23.0** (the authoritative role classifier + the tiered wipe gate).

### v0.43.0 — rebuilt storage management (guided init/attach/eject on the agent disk model) (2026-06-11)

After the 8C de-privileging, the storage UI's buttons pointed at deleted routes (`/settings/storage/init`,
`/attach`, `/migrate-drive`, per-stack `/migrate`) — all 404. Everything underneath already worked (the
agent owns disk execution + the data-bearing signature gate; the controller has the `agentapi` client +
`/api/disks/*` proxies + the `StoragePath` registry). This is a controller-only UI/orchestration layer
over those.

- **Storage overview** (`settings.html`, driven by `GET /api/disks`): the agent's live disk view — name,
  type, state, device, mount, class, and the **`data_bearing` badge** + registered cross-reference.
- **Guided init** (`/settings/storage/init` + `POST /api/storage/init`): pick a disk → format → resolve
  the new fs UUID from the re-listed disks → assign (mount) → register the `StoragePath`. **A data-bearing
  device is REFUSED** by the agent; the UI surfaces the exact `felhom-opsign -op storage_wipe -host … -durable-id …`
  command and stops — **there is no force-format path** (the gate is the agent's; the controller has no
  destructive authority).
- **Guided attach** (`/settings/storage/attach` + `POST /api/storage/attach`): non-destructive — resolve
  the existing fs UUID → assign → register.
- **Eject** (`POST /api/storage/eject`): benign unmount (data preserved) + deregister, surfacing the
  agent's dependent-guest warning.
- **`agentapi`**: `DiskInfo` gains `DurableID` (+ `FSUUID()` to strip the `uuid:` prefix — the assign
  key); `FormatResult` gains `PendingOp` (+ `OpsignCommand()`), now parsed from the agent's 403 body
  (the old path discarded it). Pairs with `felhom-agent` v0.22.0, which exposes `durable_id` in `/disks`.
- **Honest buttons**: init/attach are wired; migrate (drive + per-stack) is disabled "Hamarosan" — no 404s.
- **De-priv template debt (Phase 3)**: removed the dead `CrossDrive*` blocks in `deploy.html` (the "2.
  mentés" form + 3 JS fns) and `backups.html` (the run buttons + 2 JS fns) — they referenced fields the
  de-privileged handlers no longer provide (a `gt/eq` over a missing field 500s the page).
- Migration (controller-side rsync) is intentionally deferred to its own slice (the migrate buttons are
  disabled, not dead).
- Tests: the init refusal surfaces the `pending_op`/opsign and performs **no** assign/register; success
  assigns with the resolved UUID + registers the expected `StoragePath`; a template-parse test guards all
  pages.

### v0.42.1 — real Let's Encrypt cert: wildcard proactive issuance via the controller route (2026-06-11)

The base-infra traefik obtained **no** real cert (acme.json empty) — both routers relied on the
websecure entrypoint-default `certResolver`, which does not trigger proactive DNS-01 issuance, so
everything ran on traefik's self-signed default (masked externally by the tunnel's `noTLSVerify`).
This blocks LAN-direct (a LAN client TLS-handshakes straight to traefik and needs the real cert).

- **`infra.RenderControllerRoute(domain, wildcardTLS)`** — the always-present controller route is now
  the **wildcard-issuance anchor**: when DNS-01 ACME is configured it carries router-level
  `tls.certResolver: letsencrypt` + `tls.domains: [{main: "*.<domain>", sans: ["<domain>"]}]`, so
  traefik **proactively obtains `*.<domain>` + apex at startup** via Cloudflare DNS-01. Every other
  router (filebrowser, future apps) then serves that one wildcard by SNI match — **no per-app
  certresolver labels**, real cert ready before the first client connects. `stacks.wireController`
  passes `wildcardTLS = (CFAPIToken != "" && Email != "")`.
- **Empirically established (staging on 9201):** traefik v3 issues from a **router-level** `tls.domains`
  but **NOT** from the entrypoint-level `http.tls.domains` (acme.json stayed empty with the latter). The
  v0.42.0 attempt (entrypoint `domains` + `TraefikData.Domain`) was reverted accordingly.
- Validated staging→prod on guest 9201 (Fake LE wildcard → real LE wildcard), then GATE: `felhom.<domain>`
  + `files.<domain>` return `200 0` (real wildcard cert, TLS verify OK) direct-to-guest from a real LAN host.

### v0.41.2 — fix controller-route auto-connect + dead dashboard cross-drive block (2026-06-11)

Two fixes found while live-validating v0.41.1 routing on guest 9201:
- **`containerOnNetwork` false-positive (v0.41.1 regression):** the membership check used
  `{{index .NetworkSettings.Networks "traefik-public"}}`, whose output for an absent key is `<nil>`
  (non-empty) — so `wireController` thought the controller was already attached and **skipped the
  `docker network connect`**. traefik then matched the route but 502'd (backend unresolvable). Fixed by
  listing the network names and matching exactly. Live: `felhom.<domain>` now reaches the controller.
- **Dead cross-drive dashboard block (pre-existing, slice-8C leftover):** `dashboard.html` still
  referenced `.CrossDriveTotal/.CrossDriveConfigured/.CrossDriveFailed`, which the de-privileged
  dashboard handler stopped providing — so `gt <nil> 0` **500'd the entire dashboard**. Only surfaced
  now because v0.41.1 finally made the dashboard reachable. Removed the dead block (cross-drive backup
  is the host agent's job since 8C).

### v0.41.1 — wire the controller dashboard into traefik (`felhom.<domain>` routing) (2026-06-11)

Completes v0.41.0: the base-infra bring-up stood up traefik/cloudflared/filebrowser but nothing routed
the **controller itself** through traefik, so `felhom.<domain>` 404'd (live-confirmed: controller on
`bridge` only, no traefik labels, empty `dynamic/`). filebrowser self-registers via Docker labels +
network membership baked into its compose; the controller can't — it's started by the golden bootstrap
*before* `traefik-public` exists, and the v2 `bootstrap.json` carries no domain (it comes from the hub
pull). So the wiring must happen post-pull.

- `infra.RenderControllerRoute(domain)` — a traefik file-provider dynamic route:
  `Host(felhom.<domain>)` → `http://felhom-controller:8080` on websecure (`tls: {}` inherits the
  entrypoint's default `letsencrypt` resolver when ACME is configured, else self-signed).
- `EnsureBaseStack` now calls `wireController`: writes `dynamic/controller.yml` (write-if-changed, so the
  traefik file watcher doesn't reload every health tick) and `docker network connect traefik-public
  felhom-controller` (idempotent — skipped when already attached) so traefik can resolve the controller
  by name. Runs on first boot and every self-heal tick. The Section-G shared `/opt/docker/stacks` mount
  means traefik picks up the dynamic file live.
- Diagnostic confirmed the tunnel chain was already healthy (token tunnel-id matches the DNS tunnel;
  CF ingress `*.<domain> → https://traefik`); the only gap was this controller wiring.

### v0.41.0 — first-boot base-infrastructure bring-up + self-heal (+ Section-G mount fix) (2026-06-11)

Lockstep with `felhom-agent` v0.20.0 + a golden rebake. A freshly-onboarded controller came up ONLINE
but **Health = FAIL: protected containers not running — traefik, cloudflared, filebrowser**: nothing
ever deployed the base stack on a Proxmox bootstrap (it was only ever created by the bare-metal
`scripts/docker-setup.sh`), and the health loop only *detected* the gap. This release makes the
controller stand up its own base infrastructure.

- **New `internal/infra` package** — pure renderers (`//go:embed` templates lifted verbatim from
  `scripts/docker-setup.sh`) for traefik (`traefik.yml` + compose + a 0600 `.env` carrying the CF DNS
  token only when set), cloudflared (compose; `TUNNEL_TOKEN`), and filebrowser (compose + `config.yaml`).
  **Image tags are PINNED here as the single source of truth** — `traefik:v3.6.7`,
  `cloudflare/cloudflared:2026.6.0`, `gtstef/filebrowser:1.3.3-stable` (no `:latest`). The web
  FileBrowser sync path now **delegates** to `infra` so the pins can never diverge.
- **`stacks.Manager.EnsureBaseStack`** (`internal/stacks/infra.go`) — creates the `traefik-public`
  network, then deploys traefik → cloudflared → filebrowser under `${stacks_dir}/<name>`. **Single-flight**
  (TryLock — it's fired from both first-boot and every health tick), **idempotent** (skips a stack whose
  container is already running), **non-fatal** (logs, never crashes). cloudflared is deployed only when a
  tunnel token is configured; filebrowser is not overwritten if its compose already exists (preserves the
  storage mounts the web sync path manages).
- **Triggers** (`cmd/controller/main.go`): first-boot bring-up after stack init (goroutine, non-fatal);
  self-heal calls `EnsureBaseStack` unconditionally on every `system-health` tick (decoupled from the
  issue strings — safe because of the single-flight + idempotency).
- **Dynamic protected set** (`monitor.EffectiveProtected`): cloudflared counts as a protected container
  only when a tunnel token is configured, so a LAN-only node doesn't report FAIL forever for a stack it
  intentionally skips. Detection and the bring-up condition agree.
- **Section-G fix (in `felhom-agent` build-golden.sh):** the controller writes compose stacks under
  `/opt/docker/stacks` inside its container, but the bootstrap `docker run` never bind-mounted that path,
  so the guest daemon resolved every relative bind source on the guest filesystem (empty dirs) — breaking
  **all** bind-mounted stacks (base infra + customer apps). Fixed with a same-path host bind
  (`-v /opt/docker/stacks:/opt/docker/stacks`). Empirically confirmed on guest 9201 (probe printed
  `cat: read error: Is a directory` before, `hello-from-controller` after).
- Tests: non-hollow `infra` render tests (customer params present, no `:latest` survives, both ACME/CF
  branches render, `.env` 0600, rendered YAML parses), `EnsureBaseStack` single-flight, and
  `EffectiveProtected`.

### v0.40.0 — bootstrap pull+merge onboarding (controller pulls its config from the hub) (2026-06-11)

Lockstep with `felhom-agent` v0.19.0. Fixes the onboarding 401: a freshly provisioned guest used to
seed a "configured" controller.yaml from the agent's **host** hub key, which the hub's `/api/v1/report`
(customer-scoped auth) rejects → the controller could never report ONLINE. Now the controller **pulls**
its full controller.yaml from the hub on first boot (the hub mints the **customer-scoped** key) and
**merges in** the per-guest `local_api` block.

#### Changed — bootstrap contract `v1 → v2` (`internal/bootstrap`)
- `SchemaV1 → SchemaV2 = "felhom.bootstrap/v2"`. `BootstrapCustomer` drops `name`/`domain`/`email` (keeps
  `id`); `BootstrapHub` drops `api_key`/`host_id`, adds **`retrieval_password`** (SECRET). `local_api`
  unchanged. A non-v2 schema → setup mode.
- **`MaybeIngest(configPath, cfg, logger, pull PullFunc)`** — new injected `pull` arg (decision (b): keeps
  `bootstrap` from importing the heavy `internal/report` package; wired in `main.go` to `report.PullConfig`).
  Flow: idempotent (configured → return, **no pull**) → parse + validate v2 → **pull** the hub config with
  bounded retry (1 + 3 backoff attempts on transient `ErrPullTransient` only; auth/not-found fail fast) →
  **merge** the per-guest `local_api` at the YAML-map level (preserves every hub-emitted field — assets,
  CF, backup) → write 0600 atomic → reload. Fail-safe throughout: a hub outage at first boot leaves the
  guest in setup mode (the manual wizard remains the fallback), never crashes.
- New sentinel **`ErrPullTransient`**; `main.go`'s pull adapter maps `report.ErrHubUnreachable` onto it
  (transient/retryable) and passes auth/not-found through as permanent. Removed `configFromBootstrap`
  (the host-key-seeding path) and the struct-marshal writer.

#### Tests (`internal/bootstrap`)
- Pull+merge (asserts the merged controller.yaml carries the **customer** key + identity + a preserved
  unmodeled `assets.source_url` **and** the bootstrap's `local_api`, with **no host key**); idempotency
  (pull **never invoked** when configured); transient-retry (N attempts then setup); permanent-no-retry;
  non-v2 schema reject; missing-required reject; malformed/absent. Cross-repo render→ingest round-trip
  verified against the agent's v2 renderer. `go build ./... && go test ./...` green.

### v0.39.1 — 8C orphan-template cleanup (source hygiene) (2026-06-11)

Dead-template removal — no behaviour change. Slice 8C de-privileged the controller and retired the
disk/storage/restore web handlers (`storage_handlers.go`, `handler_restore.go` and the `/api/storage/*`
+ `/api/restore/*` routes), but five HTML templates that those handlers rendered were left behind.
They have **zero** `.go` references, **zero** cross-template `{{template …}}` references, no route, and
no nav entry; the embed is a glob (`//go:embed templates/*.html templates/*.css`), so deleting them is
safe and the remaining 14 templates still embed cleanly.

#### Removed (`internal/web/templates`)
- `storage_init.html`, `storage_attach.html`, `migrate.html`, `migrate_drive.html`, `restore.html` —
  orphaned pages for removed endpoints. Re-confirmed unreferenced before deletion
  (`grep -rn` over `internal/`: only the templates' own `{{define}}` lines matched).

#### Noted, not changed (dead-but-harmless restic/cross-drive remnants)
- Two never-called notifier methods `NotifyCrossDriveCompleted`/`NotifyCrossDriveFailed`
  (`internal/notify/notifier.go:353,359`) and a vestigial `crossdrive_failed` entry in the
  notification-events list (`internal/web/handlers.go:937`) that still renders a settings toggle for an
  event that can no longer fire. Plus restic config fields/comments in `config/config.go`,
  `settings/settings.go`, `report/types.go`. None are live emitters — left in place, flagged for a
  future dedicated cleanup.

### v0.39.0 — slice 9: host metrics in the controller (customer host-health view) (2026-06-10)

The customer-facing half of slice 9. Pairs with `felhom-agent` v0.14.0. The de-privileged controller (slice 8C) sees only its own cgroup, so it can't read the host. The monitoring page now shows the **real Proxmox box** — CPU% + load, memory used/total, **CPU/chassis temperature** (or "n/a" when the hardware exposes none), uptime, and **per-storage capacity** (used/total bar, thin-pool fill, disk temp/wear) — proxied from the agent's new `GET /host/metrics`.

#### Added (`internal/agentapi`)
- **`Client.HostMetrics(ctx)`** — calls the agent's `GET /host/metrics` over the leaf-pinned, per-guest-token channel (same client as the 8C disk proxy) and returns `HostMetricsResponse` (host block + per-storage targets). New mirror structs `HostMetrics` (with nullable `CPUTempC`), `StorageTarget`, `ThinPoolFill`, `SmartSummary` (subset — only the fields the UI renders; unknown wire keys ignored).

#### Added (`internal/web`)
- **`ServeHostMetricsAPI`** (`agent_host_metrics_handler.go`) — a thin read-only proxy: `GET /api/host-metrics` → agent `GET /host/metrics`. Returns the `{ok,data,error}` envelope; 503 when the local API is not configured (unprovisioned guest), 502 on an agent error. Wired in `main.go` behind `RequireAuth` (GET-only → no CSRF wrapper).
- **Monitoring view** (`templates/monitoring.html`): a new **"Szerver állapota (gazdagép)"** card at the top renders the agent's host block + per-storage capacity bars (reusing the existing `system-bar`/`storage-item` styling). `cpu_temp_c: null` renders as **"n/a"** cleanly. Polls `/api/host-metrics` every **8 s** while the page is open (the host view is a live snapshot, distinct from the controller's own 60 s metric charts); shows a yellow "nem elérhető" banner when the agent is unreachable.

#### Tests
- `agentapi/host_metrics_test.go`: decodes host + storage (thin-pool, SMART temp + NVMe wear), USB drive's null SMART, and a null `cpu_temp_c` → nil pointer.

### v0.38.0 — slice 8B.2: quiesce downtime optimization (resume at `snapshotted`) (2026-06-10)

The controller half of slice 8B.2. Pairs with `felhom-agent` v0.13.0. The quiesce loop now resumes
the app at the **`snapshotted`** phase (storage snapshot taken) instead of `done` — app downtime
drops from *whole-backup* to *until-snapshot* (seconds), with no loss of app-consistency (the
snapshot froze the app-stopped state).

#### Changed (`internal/quiesce`)
- The status-poll loop **resumes (`StartStack` + clears the marker) at `snapshotted`**, then **keeps
  polling to `done`/`failed`** — so a new backup isn't started until this one truly finishes, and a
  post-snapshot failure is observed (the backup isn't "successful" until `done`; resuming early does
  not mark it done).
- **Fallback preserved:** if `snapshotted` never arrives (stop/downgraded mode), it resumes at `done`
  exactly as 8B. **Crash-safety unchanged:** marker written before stop; guaranteed unquiesce;
  startup `Recover()`. A backup that fails *after* `snapshotted` is harmless — the app is already up.

#### Tests
- resume at `snapshotted` (RESUME event before `done`, marker cleared, then tracked to `done`);
  stop-mode fallback (resume at `done`, no `snapshotted`); fail-after-`snapshotted` (one resume, app
  stays up); the 8B crash-safety tests stay green.

### v0.37.0 — slice 8C: controller de-privileging + disk management via the agent (2026-06-10)

The in-guest controller half of slice 8C (closes slice 8). The disk-execution subsystem moves to
the host agent (`felhom-agent` v0.12.0); the controller becomes **Docker-only with no disk
privileges** and drives disk management through the agent's local API. ~12.3k LOC retired.

#### Added
- **`internal/web/agent_disk_handlers.go`** — agent-backed disk API (`ServeDiskAPI`): `GET
  /api/disks` (list + data-bearing flags), `POST /api/disks/assign` (mount), `POST /api/disks/eject`
  (unmount + dependent-guest warning), `POST /api/disks/format`. Thin proxies over the slice-8A
  `agentapi` client (leaf-pinned, own token). **Execution is the agent's**; the UX stays here.
  A data-bearing format refusal (`agentapi.ErrFormatRefused`) is surfaced as **HTTP 409 "operator
  authorization required"** (the 8C invariant — the agent inspects the device; the controller's
  claim is irrelevant).
- **`internal/agentapi`**: `Disks`/`AssignDisk`/`EjectDisk`/`FormatDisk` + `ErrFormatRefused`.

#### Retired (moved to the host agent / obsolete)
- **`internal/storage/`** — the entire package (scan/format/attach/migrate/safety, DriveMigrator).
- **`internal/backup/`** — restic (`ResticManager`), `crossdrive` (`CrossDriveRunner`),
  `restore_drives*`, `disk_layout`, `local_infra`, `restore_scan`, the restic path helpers, and the
  drive-restore `restore_app*`. **`backup.Manager` surgically split to app-data only**: kept DB
  dumps, Docker-volume tars, and per-app restore; dropped restic snapshots, cross-drive, per-drive
  repo stats, integrity check, snapshot history. `RestoreApp` now restores from the on-disk
  volume-tar dumps (snapshot/restic restore is the agent's domain).
- **`internal/report/infra_backup*` + `infra_pull`** (kept the setup fresh-install config download as
  `config_pull.go`); **`internal/setup/scanner.go`** + the wizard's drive-recovery flows (restore is
  the agent's job now); **`internal/monitor/watchdog.go` + `pinger.go`** (storage watchdog →
  agent; Healthchecks.io pinging → the Hub owns monitoring); **`web/storage_handlers.go` +
  `handler_restore.go`** (replaced by the thin agent-backed disk API).
- Wiring dropped from `main.go` / `api/router.go` / `web/server.go`: CrossDriveRunner, DriveMigrator,
  storage watchdog, infra-backup push, the restic backup scheduler jobs (kept the **db-dump** job).

#### De-privileged
- `scripts/docker-setup.sh` controller compose template: dropped `privileged: true`, the `/mnt`
  rshared bind, `/sys`, `/dev`, `/etc/fstab`, `/run/udev`. The golden's bootstrap `docker run`
  (felhom-agent `build-golden.sh`) was already minimal (bootstrap config + data + docker socket).

#### Tests / build
- `go build ./...` + `go test ./...` green (app-data backup / stacks / quiesce / bootstrap / agentapi
  / disk-client tests pass). The data-bearing-format refusal is proven in `agentapi` tests.

### v0.36.0 — slice 8B: app-consistent backup quiesce loop (stack-stop) (2026-06-10)

The in-guest controller half of slice 8B (doc 03 §6/§8). Pairs with `felhom-agent` v0.11.0. An
agent-initiated vzdump is crash-consistent only (an LXC has no fsfreeze); this makes app-consistency
the controller's job — it stops its app stacks around the backup so the captured state is
clean-shutdown-consistent.

#### Added
- **`internal/quiesce`** — the background quiesce loop: poll the agent's `GET /backup/due` → when
  due, **quiesce** (stop deployed, non-protected, running stacks) → `POST /backup` → poll
  `GET /backup/status` to `done`/`failed` → **unquiesce** (restart exactly the stacks it stopped).
  - **Crash-safety (the centerpiece — a stranded-down app is worse than a crash-consistent backup):**
    a persisted **marker** (atomic, `0600`) written **before** stopping anything; **guaranteed
    unquiesce** (a deferred closure restarts the stacks on a backup error, a status-poll error, the
    max-quiesce bound, or context cancellation); a **max-quiesce-duration** hard bound that restarts
    the app no matter what (the backup continues on the agent); **crash recovery** at startup
    (`Recover()` restarts stacks left stopped by a mid-quiesce crash, then clears the marker); and the
    marker as a **single-flight** guard.
- **`agentapi`**: `BackupDue` / `StartBackup` / `BackupStatus` methods + a `post` helper.
- **`stacks.Manager.RunningAppStacks()`** — deployed, non-protected, currently-up stacks (protected
  infra — traefik/cloudflared/felhom-controller — is never stopped), sorted for deterministic order.
- **`config.QuiesceConfig`** (`quiesce`: enabled, poll_interval, status_poll_interval,
  max_quiesce_duration). Wired in `main.go`: `Recover()` at startup, then the loop goroutine, gated on
  the local API being configured (a provisioned guest) + quiesce enabled.

#### Tests
- happy path (stop → backup → poll done → restart exactly those, in order; marker cleared);
  **backup-start failure → stacks STILL restarted**; failed phase → restarted; **max-quiesce guard →
  restarted at the bound**; **crash recovery → marker stacks restarted + cleared**; single-flight (no
  second backup while a marker is active); **only the stacks we stopped are restarted** (an
  already-stopped stack is never started); and **marker-written-before-stop** ordering.

### v0.35.0 — slice 8A: bootstrap.json ingestion + pinned agent local-API client (2026-06-10)

The in-guest controller half of slice 8A (doc 03 §6). Pairs with `felhom-agent` v0.10.0. No
behaviour change for an already-configured controller; adds the first-run provisioning path.

#### Added
- **`internal/bootstrap`** — first-run **`bootstrap.json` ingestion** (config-contract decision (c)).
  On startup, if the controller is NOT yet configured AND the host agent's back-half attached a
  `bootstrap.json` config mount, the controller **seeds `controller.yaml` from it and comes up
  configured, skipping the setup wizard**. Idempotent (an existing `controller.yaml` is **never**
  clobbered) and fail-safe (a malformed/absent/missing-identity/unsupported-schema bootstrap leaves
  the controller in setup mode — logs, never crashes). The agent emits the stable contract; the
  controller owns the translation (the two stay decoupled).
- **`internal/agentapi`** — a minimal **pinned client** for the agent's local API. It reaches the
  agent over the bridge, **pinning the agent leaf-cert SHA-256** from the bootstrap (fails closed on
  mismatch — `VerifyPeerCertificate` exact leaf-DER match, the same pin convention the agent uses for
  the Proxmox/PBS host certs), and authenticates with the per-guest bearer token. In 8A it exercises
  `GET /storage` (connectivity + the controller learning its mounts); the `/backup/due` quiesce loop
  is 8B.
- **`config.LocalAPIConfig`** (`local_api`: endpoint, fingerprint, token) — seeded from the bootstrap.
- **Startup probe** — when seeded with a local-API endpoint, the controller proves the channel at
  boot and logs this guest's mounts (non-fatal).

#### Tests
- bootstrap: seeds when unconfigured (reloads configured, skips setup); never clobbers a configured
  controller; stays in setup on malformed / missing-identity / unsupported-schema / absent bootstrap.
- agentapi: correct pin + token reaches `/storage`; a **wrong pin fails closed**; a bad fingerprint
  is rejected at construction; colon-separated fingerprints are accepted.

### docs: reflow CLAUDE.md; unify REPORT/CHANGELOG convention; add no-secrets rule (2026-06-08)

#### Changed
- **Reflowed `CLAUDE.md`** — removed hard mid-paragraph line wraps (prose, list items, blockquotes now single-line, soft-wrapped); code blocks and tables untouched; rendered output unchanged.
- **Added the uniform REPORT/CHANGELOG convention**: `CHANGELOG.md` is the cumulative log (newest on top); `REPORT.md` is overwritten with the most-recent implementation only. Added an explicit **no-secrets** rule (never write tokens/passwords/keys into committed files; reference them as stored out-of-band). Docs/meta only — no code change, no version bump.

### Repo rename — `deploy-felhom-compose` → `felhom-controller` (2026-06-08)

#### Changed
- **Gitea repo renamed** `admin/deploy-felhom-compose` → `admin/felhom-controller` (via API). Sibling rename: `admin/proxmox-controller` → `admin/felhom-agent` (docs repo, future agent code).
- **Reference rework (no functional change)**: updated every reference to the old repo name across docs and scripts — clone URLs, clone dirs (`~/git/felhom-controller`), the customer bootstrap URL in `scripts/felhom-wipe.sh` / `scripts/README.md`, `controller/build.sh`, `controller/BUILDING.md`, `controller/README.md`, `CLAUDE.md`, `CONTEXT.md`, `TASK.md`. Local working-copy dirs and the build-server source clone (`192.168.0.180:~/git/`) renamed to match.
- **Intentionally unchanged**: Go module path `gitea.dooplex.hu/admin/felhom-controller` (already matches new name), Docker image path `gitea.dooplex.hu/admin/felhom-controller` (registry is namespaced by owner, not repo), binary name `felhom-controller`. Historical CHANGELOG entries left as-is (they record what was true at the time).

### Refactor — extract app-data-backup primitives into `internal/appbackup` (no behaviour change) (2026-06-08)

#### Changed
- **New package `internal/appbackup/`**: extracted the stateless, keep-side app-data backup primitives out of `internal/backup/` — DB dump discovery/execution (`dbdump.go`: `DiscoverDatabases`, `DumpAll`, `DumpOne`, `ValidateDump`, `ListDumpFiles`), Docker-volume/app-data discovery (`appdata.go`: `StackDataProvider`, `DiscoverAppData`, `ParseComposeNamedVolumes`, `ResolveDockerVolumeNames`, `HumanizeBytes`), and keep-side path helpers (`paths.go`: `FelhomDataDir`, `PrimaryBackupPath`, `AppDBDumpPath`, `AppVolumeDumpPath`, `AppDataDir`). Pure move — logic unchanged.
- **backup/appbackup_bridge.go** (new): re-exposes the moved symbols to the `backup` package via type/const aliases and one-line function forwarders, so the still-present disk/host-side code (restic, cross-drive, drive-mount) and the both-side consumers (web, api, report) compile unchanged.
- **appexport/export.go, storage/migrate.go, storage/migrate_drive.go**: rewired to import `internal/appbackup` directly and dropped their `internal/backup` import — these keep-side consumers are now independent of the delete-side code.
- **Why**: Part-2 prerequisite for the Proxmox port. Isolating the keep-side now (as a separate green, behaviour-identical commit) means the disk/host-side code can later be removed without breaking app-data backup or `appexport`. `appbackup` has zero references to restic/cross-drive/drive-mount and does not import `backup` (no import cycle).
- **Not moved (documented coupling)**: the `*Manager` methods `RunDBDumps`/`DumpAppVolumes`/`DumpAppVolumesSafe` (share one mutex/running-flag + status state with the delete-side `RunBackup`) and `RestoreAppFromTier2` (intrinsically reads the cross-drive mirror via `copyFile`/`AppSecondaryRsyncPath`) stay on `Manager`; they delegate to `appbackup` and are left for the later re-platform step.

### v0.34.0 — Backup safety: stop-before-dump, streaming restore, health check, per-app restic, infra configs (2026-02-28)

#### Changed
- **backup/backup.go**: `DumpAppVolumesSafe()` stops stack before volume dump, restarts after — prevents inconsistent tars of live database volumes (PostgreSQL, MariaDB, SQLite)
- **backup/backup.go**: `backupDrive()` includes per-app stack config dirs instead of full StacksDir; `controller.yaml` only on system drive — reduces snapshot duplication across drives
- **backup/crossdrive.go**: `VolumeDumper` interface extended with `DumpAppVolumesSafe()`; cross-drive backup uses safe variant for pre-backup volume dumps
- **backup/restore.go**: Tier 2 DB dump copy uses streaming `copyFile()` (io.Copy + atomic rename) instead of `os.ReadFile`/`os.WriteFile` — eliminates full-file memory allocation for large dumps
- **backup/restore.go**: Post-restore health check via `waitForHealthy()` polls container state (with docker ps refresh) for up to 90s after restore

#### Added
- **backup/appdata.go**: `RefreshAndIsRunning()` on `StackDataProvider` interface for reliable post-restore state checks (forces docker ps refresh before reading state)
- **report/infra_backup.go**: `InfraStack` now includes `DockerComposeB64`, `AppYamlB64`, `FelhomYamlB64` — actual stack config files for disaster recovery (derived from `GetStackComposePath`, no signature change)

### v0.33.0 — Docker volume backup + Tier 2 restore + restore dropdown fixes (2026-02-27)

#### Added
- **backup/backup.go**: `DumpAppVolumes()` exports Docker named volumes to tar files using `docker run alpine tar`; `runVolumeDumpsInternal()` runs volume dumps for all stacks in nightly schedule (Phase 1b between DB dumps and restic); volume dump dirs included in per-drive restic snapshots
- **backup/appdata.go**: `ResolveDockerVolumeNames()` resolves full Docker volume names with project prefix (e.g., `mealie_mealie_data` instead of `mealie_data`); `GetDockerVolumes()` added to `StackDataProvider` interface; `HasVolumeData` field on `AppBackupInfo`, `HasVolumes` on `StackSummary`
- **backup/paths.go**: `AppVolumeDumpPath()` returns `<drive>/felhom-data/backups/primary/<stack>/volume-dumps/`
- **backup/restore.go**: `RestoreAppFromTier2()` restores from cross-drive rsync mirror (config, HDD data, DB dumps, Docker volumes via rsync); `restoreDockerVolumes()` populates Docker volumes from tar files after Tier 1 restore; `restoreDockerVolumesFromDir()` for Tier 2 volume restore
- **backup/crossdrive.go**: `VolumeDumper` interface + `SetVolumeDumper()` for pre-backup volume dumps; `copyStackVolumeDumps()` copies volume tars to `_volumes/` in rsync mirror
- **backup/backup.go**: `ListSnapshotsForApp()` returns snapshots only from the app's home drive primary repo
- **backup/restic.go**: `Source` field on `SnapshotInfo` ("restic" or "rsync")
- **api/router.go**: `backupSnapshots()` now accepts `?stack=` param to filter by app's home drive; appends synthetic Tier 2 entry from cross-drive config when backup succeeded
- **web/handlers.go**: `backupRestoreHandler()` routes `tier2-rsync` snapshot ID to `RestoreAppFromTier2()`
- **web/templates/backups.html**: Import from `.fab` bundle link in restore section; `data-has-volumes` attribute on restore app options; volume-aware restore type banners; "Konfig + Adatok" label for volume-backed apps

#### Fixed
- **Volume name resolution bug**: `ParseComposeNamedVolumes()` returned short names but Docker Compose V2 uses `<project>_<name>` — fixed in both backup and export adapters via `ResolveDockerVolumeNames()`
- **Double Tier 1 in restore dropdown**: snapshots from non-home drives appeared because stacks dir is in every drive's primary repo — now filtered by app's home drive via `ListSnapshotsForApp()`

### v0.32.8 — Move optional config to deploy/settings page (2026-02-27)

#### Changed
- **web/templates/deploy.html**: Optional config fields (metadata providers, API keys) now render on the deploy/settings page instead of the app info page — consistent with integrations and geo-restriction which already live there
- **web/handlers.go**: `deployHandler` now passes `OptionalConfig`, `CurrentValues`, `HasOptionalConfig` to the deploy template for deployed apps; `appDetailHandler` cleaned up to remove optional config data
- **web/templates/app_info.html**: Removed optional config section (HTML + JS) — no longer rendered here

### v0.32.7 — Fix FileBrowser config not being read on fresh deployments (2026-02-27)

#### Fixed
- **web/handlers.go**: `generateFileBrowserCompose()` now sets `FILEBROWSER_CONFIG=/home/filebrowser/config.yaml` environment variable — the `gtstef/filebrowser` image bakes in `FILEBROWSER_CONFIG=/home/filebrowser/data/config.yaml` which reads a stale initial config from the data volume instead of the controller-managed bind mount. This caused fresh deployments to show only a single "srv" source, ignore per-drive sidebar entries, and create the database outside the persistent volume (triggering the "new database was created" warning on every container recreation)

#### Changed
- **scripts/docker-setup.sh**: Initial FileBrowser compose template also includes the `FILEBROWSER_CONFIG` override for consistency

### v0.32.6 — Format empty partitions on system disk (2026-02-27)

#### Added
- **storage/scan.go**: New `FormatablePartition` struct and `FormatablePartitions` field on `ScanResult` — detects empty (no filesystem), unmounted, non-system partitions on system disks
- **storage/scan_linux.go**: New `getSystemPartitionPaths()` resolves actual system partition device paths from fstab (more granular than `getSystemDiskNames()` which returns parent disk names). `ScanDisks()` now populates `FormatablePartitions` after enrichment
- **storage/safety_linux.go**: New `IsSystemPartition()` — checks if a specific partition is a system partition (/, /boot, /boot/efi, swap) or is currently mounted; more granular than `IsSystemDisk()` which blocks the entire disk
- **web/storage_handlers.go**: Scan API response now includes `formatable_partitions` array
- **web/templates/storage_init.html**: Init wizard shows formatable system-disk partitions as a separate selectable section with info banner, conditional warning text, and hidden partitioning progress step

#### Changed
- **storage/format_linux.go**: `FormatAndMount()` now uses `IsSystemPartition()` for partition-only operations (`CreatePartition=false`) instead of `IsSystemDisk()` — allows formatting empty data partitions on the system disk while still blocking system partitions

### v0.32.5 — USB badge fix + graceful Tier2 backup on disconnected/inactive/removed destinations (2026-02-27)

#### Fixed
- **system/mounts_linux.go**: `IsUSBDevice()` and `diskModel()` now strip findmnt bind-mount suffix (`[/subdir]`) before parsing device path — fixes USB badge and disk model not showing for drives mounted via the attach wizard
- **backup/crossdrive.go**: Disconnected source/destination drives now silently skip with WARN log instead of returning error — prevents noisy error aggregation in `RunAllScheduled()` and false "failed" counts
- **web/handlers.go + backup/crossdrive.go**: Tier2 destination check now covers drives **removed** from storage (not just marked disconnected) — `IsStoragePathKnown()` detects when destination path is no longer in any registered storage, UI shows yellow "Cél meghajtó leválasztva" and scheduler skips silently
- **web/handlers.go + backup/crossdrive.go**: Tier2 destination check now also covers **inactive** (Schedulable=false) drives — `IsStoragePathSchedulable()` detects when destination drive is deactivated, UI shows yellow "Cél meghajtó inaktív" and scheduler skips silently

#### Added
- **settings/settings.go**: New `IsStoragePathKnown(path)` method — returns whether a path belongs to any registered storage (connected, disconnected, or decommissioned); paths removed entirely return false
- **settings/settings.go**: New `IsStoragePathSchedulable(path)` method — returns true only if path belongs to a registered, active (Schedulable), non-disconnected, non-decommissioned storage
- **web/handlers.go**: New `Tier2DestDisconnected` and `Tier2DestInactive` fields on `AppBackupRow` — detect when Tier2 destination is disconnected/removed/inactive, sets yellow status dot instead of green/red
- **web/templates/backups.html**: New template branches for disconnected ("Cél meghajtó leválasztva") and inactive ("Cél meghajtó inaktív") Tier2 destinations — grayed-out info, warning badge, no "Futtatás most" button

### v0.32.4 — Controller telemetry: include controller in hub app telemetry (2026-02-27)

#### Added
- **report/telemetry.go**: Include the `felhom-controller` container as a special entry in the `app_telemetry` array sent to the hub — reuses all existing hub telemetry infrastructure (memory trends, known issues, fleet aggregation) with zero hub-side changes
- **report/telemetry.go**: New `buildControllerTelemetry()` function collects controller container metrics (memory, CPU) and log scan results (warnings, errors, deduplicated issues)

### v0.32.3 — Logging cleanup: consistent tags, dedup, standardized prefixes (2026-02-26)

#### Fixed
- **All modules**: Standardized `[LEVEL] [module]` format across every log line — added missing module tags (`[stacks]`, `[backup]`, `[cloudflare]`, `[sync]`, `[scheduler]`, `[storage]`, `[monitor]`, `[metrics]`, `[report]`, `[settings]`, `[setup]`, `[api]`, `[integrations]`, `[selfupdate]`, `[assets]`, `[web]`)
- **Removed duplicate logs**: ScanStacks double completion, GetLogs INFO+DEBUG, LoadAppConfig WARN+DEBUG, copyStackDBDumps DEBUG+INFO, invalidateAllSessions INFO+DEBUG
- **Standardized stale prefixes**: `[CF]`/`[CF-DEBUG]` → `[INFO/DEBUG] [cloudflare]`, `[SYNC]` → `[LEVEL] [sync]`, `[SCHED]` → `[LEVEL] [scheduler]`, `[API]` → `[LEVEL] [api]`, `[STORAGE]` → `[storage]`, `[HEALTH]` → `[monitor]`, `[ROLLBACK]`/`[ROLLBACK-ERROR]` → `[LEVEL] [storage]`, `[DEBUG-SIM]` → `(simulation)`
- **Fixed wrong log levels**: Restic restore start WARN→INFO, ungated DEBUG lines in crossdrive/dbdump/onlyoffice/alerts removed or gated
- **Improved vague messages**: Settings SetDisconnected/SetDecommissioned now include storage path and migration target
- **Added missing logs**: `execCommand()` error, `DiscoverAppData()` completion, `BuildInfraBackup()` completion

### v0.32.2 — Comprehensive INFO/WARN/ERROR logging across all modules (2026-02-26)

#### Added
- **stacks/manager.go**: INFO logs for status refresh container/stack counts, log fetching, encryption migration, ScanStacks completion
- **stacks/deploy.go**: INFO logs for config updates, InjectMissingFields summary; ERROR logs for SaveAppConfig failures; WARN for LoadAppConfig errors
- **stacks/delete.go**: INFO log for ParseComposeHDDMounts result count
- **stacks/metadata.go**: Fixed LoadMetadata error to use `log.Printf` instead of `fmt.Fprintf(os.Stderr)`
- **backup/backup.go**: WARN for perDriveRepoStats failures; INFO for drive stats, aggregate stats, dump file count, snapshot history save
- **backup/crossdrive.go**: INFO for cross-drive backup start/completion with success/fail counts; ERROR for rsync failures; INFO for DB dump copy counts
- **backup/restic.go**: INFO for Snapshot and Check success
- **backup/dbdump.go**: INFO for DiscoverDatabases count; INFO for DumpAll start/completion
- **backup/restore_drives_linux.go**: INFO for fstab entry additions
- **backup/local_infra.go**: INFO for backup version pruning with kept/removed counts
- **cloudflare/geosync.go**: Standardized all `[GEO]` prefixed logs to `[INFO]/[WARN]/[ERROR] [cloudflare]` format
- **scheduler/scheduler.go**: Standardized all `[SCHED]` prefixed logs to `[INFO]/[WARN]/[ERROR] [scheduler]` format
- **sync/sync.go**: INFO for catalog sync start/completion; ERROR for git/network failures; WARN for file copy errors (replaced `[SYNC]` prefix)
- **report/pusher.go**: WARN for Push and InfraBackup push failures
- **report/builder.go**: INFO for BuildReport start
- **monitor/healthcheck.go**: WARN for CPU/memory/disk/temperature threshold breaches; INFO for health check result status
- **system/mounts_linux.go**: WARN for unsafe backup destinations and storage path probe failures
- **settings/settings.go**: INFO for settings load/save, storage path add/remove, disconnect/decommission, pending events; ERROR for save failures
- **storage/attach_linux.go**: INFO for disk attach start/success; ERROR for attach failures
- **storage/scan_linux.go**: INFO for disk scan start/completion with count
- **storage/format_linux.go**: INFO for format start/success; ERROR for format failures
- **storage/migrate.go**: INFO for migration start/completion; ERROR for migration failures
- **integrations/manager.go**: ERROR for integration apply failures; WARN for context build and env load failures
- **integrations/lifecycle.go**: Added `[integrations]` module tag to all logs; upgraded re-apply failure from WARN to ERROR
- **integrations/onlyoffice_filebrowser.go**: ERROR for all Apply/Revoke error paths
- **integrations/onlyoffice_nextcloud.go**: ERROR for all Apply/Revoke error paths
- **selfupdate/updater.go**: INFO for up-to-date and update-available results; INFO/ERROR for compose file updates
- **selfupdate/state.go**: INFO for state cleared
- **assets/syncer.go**: ERROR for manifest save failures (previously silent); changed sync failure log from WARN to ERROR
- **appexport/restore.go**: INFO for import start
- **web/auth.go**: INFO for logout/session invalidation/session cleanup; WARN for unauthorized API requests
- **web/server.go**: WARN for 404 Not Found on unknown routes
- **web/handlers.go**: INFO for default storage path and schedulable state changes
- **web/handler_restore.go**: INFO for restore-all initiation
- **web/handler_export.go**: ERROR for export/import start failures
- **web/storage_handlers.go**: INFO for disk disconnect/reconnect/restart-apps completion
- **api/router.go**: ERROR for stack action failures, backup snapshot listing failures, metrics query failures

#### Changed
- **stacks/healthprobe.go**: Summary log now always prints — WARN when unhealthy, INFO when all ok (was debug-only for all-ok)
- **backup/restore.go**: Changed RestoreApp start log from `[WARN]` to `[INFO] [backup]`
- **backup/restore_app_linux.go**: Changed restoreUserData/restoreDBDumps failure logs from `[WARN]` to `[ERROR]` where data loss could occur

### v0.32.1 — Comprehensive debug logging across all modules (2026-02-26)

#### Added
- **stacks/delete.go**: Debug logging for DeleteStack/RemoveStack with stack state, HDD mounts, compose output, path removal; GetStackHDDData/GetStackBackupData path scanning
- **stacks/manager.go**: Debug logging for ScanStacks per-stack discovery, refreshStatusLocked container resolution, Start/Stop/Restart pre-operation state, MigrateEncryption progress, getCatalogTemplateSlugs count
- **stacks/deploy.go**: Debug logging for UpdateStackConfig/UpdateOptionalConfig changed keys, InjectMissingFields per-stack checks, SaveAppConfig encryption counts, LoadAppConfig results
- **stacks/healthprobe.go**: Debug logging for per-target interval calculations and target collection summary
- **backup/restic.go**: `debug` field + `SetDebug()` method; debug logs for Snapshot/Prune/Check/ListSnapshots/LatestSnapshot/Stats/RestoreAppData with timing, sizes, and command details
- **backup/restore_scan.go**: Debug logging for ScanDrivesForBackups drive/app scanning with per-drive availability and backup component summary
- **backup/restore_app_linux.go**: Debug logging for RestoreAppFromBackup step timing, restoreUserData per-dir rsync, restoreDBDumps per-file copying
- **backup/restore_drives_linux.go**: Debug logging for MountDrivesFromLayout device discovery, mount strategy selection, fstab checks
- **cloudflare/geosync.go**: `debug` field + `SetDebug()` method; debug logs for Sync zone/ruleset resolution, existing/desired rule diffing, rule create/update/delete operations
- **cloudflare/waf.go**: Debug logging for GetCustomRulesetID/GetRules/GetFelhomRules counts, CreateRule/UpdateRule expression snippets
- **cloudflare/zone.go**: Debug logging for GetZoneID progressive domain lookup attempts
- **integrations/manager.go**: `debug` field + `SetDebug()` method; debug logs for Toggle validation/timing, ListForProvider counts, buildApplyContext details, ReapplyConfigForTarget per-integration progress
- **integrations/lifecycle.go**: Debug logging for OnStackStop/OnStackStart/OnStackRemove with integration counts, state checks, revoke/re-apply operations
- **integrations/onlyoffice_filebrowser.go**: Debug logging for Apply/Revoke config path, JWT secret presence, office URL
- **system/**: Package-level `DebugLogger` variable; debug logs for GetInfo timing/summary, readMemInfo/readDiskUsage/readLoadAvg/readTemperature raw values, CPU collector samples, GetDiskUsage/GetFSInfo/CheckBackupDestination/ProbeStoragePath/IsUSBDevice details
- **monitor/pinger.go**: `debug` field + `SetDebug()` method; debug logs for Ping/Fail/Start with UUIDs, send URL/attempts/response status
- **settings/settings.go**: `debug` field (json:"-") + `SetDebug()` method; debug logs for Load counts, save data size, AddStoragePath/RemoveStoragePath, SetDisconnected/SetDecommissioned, AddPendingEvent/DrainPendingEvents, SetGeoRestriction, SetIntegrationState, AutoDiscoverStoragePaths
- **scheduler**: `debug` field + `SetDebug()` method; debug logs for job registration, execution timing, daily job wait calculations
- **storage/**: Consistent `[DEBUG] [storage]` prefix; scan timing; drive migration debug logging
- **metrics/logscanner**: Debug logging for per-container scan timing, error/warning counts
- **api/router**: `debug` field + `SetDebug()` method; logs incoming API requests and handler entry points
- **selfupdate**: Expanded debug coverage with `dbg()` helper for TriggerUpdate preconditions, performUpdate step transitions, docker pull timing
- **assets/syncer**: Expanded debug coverage with `dbg()` helper for per-file hash comparison, download timing, manifest fetch details
- **web/auth.go**: Debug logging for RequireAuth middleware decisions, login attempts (IP, success/fail), session creation/cleanup
- **web/handlers.go**: Debug logging for deploy/restore/settings/storage handler entry points with key parameters
- **web/handler_restore.go**: Debug logging for restore page, status polls, restore-all execution per-app timing
- **web/storage_handlers.go**: Debug logging for all storage API operations (scan, init, migrate, disconnect, reconnect, attach, cleanup)
- **web/server.go**: Debug logging for NewServer initialization, template loading, ServeHTTP request routing
- **main.go**: Wire `SetDebug()` for settings, pinger, geoSync, integrationMgr, scheduler, apiRouter

### v0.32.0 — App export/import (.fab bundles) (2026-02-26)

#### Added
- **App export**: Per-app export to `.fab` bundles containing config, database dump, and all user data (HDD bind mounts or Docker named volumes)
- **App import**: Restore apps from `.fab` bundles — works for both existing and new apps (standalone import page)
- **Password protection**: Optional AES-256-CTR + HMAC-SHA256 encryption with scrypt key derivation for exported bundles
- **Pre-export estimation**: Size estimation with free space check before starting export
- **Export UI**: New export page accessible from app info header with drive picker, password field, stop-app checkbox, and real-time progress tracking
- **Import UI**: Standalone import page (`/import`) scans all registered storage drives for `.fab` files, shows manifest details, and handles encrypted bundles with password prompt
- **FileBrowser link**: After export, link to open the exports directory in FileBrowser
- **Bundle format**: `{appname}_{timestamp}.fab` — tar.gz internally with `manifest.json`, `config/`, `database/`, `data/` directories
- **New package**: `internal/appexport/` — export/import engine with provider adapter pattern (same as backup.StackDataProvider)
- **API endpoints**: `/api/export/estimate`, `/api/export/start`, `/api/export/status`, `/api/export/bundles`, `/api/export/manifest`, `/api/export/import`, `/api/export/import/status`

#### Changed
- **backup/appdata.go**: Exported `ParseComposeNamedVolumes` (was lowercase) for reuse by appexport package

### v0.31.7 — Infra backup retention + version picker (2026-02-26)

#### Changed
- **docker-setup.sh hub mode**: `--hub-customer` now generates a minimal `controller.yaml` (no `customer.id`) instead of installing the full hub config — this triggers the setup wizard on first run, giving the user a choice to restore from an infra backup or start fresh
- **docker-setup.sh**: Hub credentials are passed to the controller via `FELHOM_SETUP_CUSTOMER_ID` and `FELHOM_SETUP_PASSWORD` environment variables so the setup wizard auto-fills them
- **Local infra backup**: `WriteLocalInfraBackup()` now rotates previous backup into `history/` subdirectory before writing new files (keeps last 5 versions per drive)
- **Setup wizard scan results**: Table now shows app names/count, disk count, and "korábbi" badge for historical versions

#### Added
- **Setup wizard hub pre-seeding**: When deployed with `--hub-customer`, the wizard auto-detects pre-seeded credentials and auto-processes Hub API calls (no manual form entry needed)
- **Hub mode welcome page**: Shows three options instead of two — "Visszaállítás a Hub-ról" (auto-connects to Hub), "Helyi mentés keresése" (local drive scan), "Friss telepítés" (fresh config download)
- **Auto-process fallback**: If Hub auto-connect fails, the wizard clears the pre-seeded password and falls back to the manual form with the error displayed
- **Hub backup version picker**: When multiple backup versions exist on the Hub, the setup wizard shows a version picker page (date, controller version, app names, disk count) — user selects which version to restore
- **Local backup history restore**: Setup wizard can restore from historical versions found in `history/` subdirectory on local drives
- **`ReadLocalInfraHistory()`**: Scans `history/` directory for all retained backup versions with rich metadata (stack names, disk count, integrity status)
- **`ReadLocalInfraBackupFromHistory()`**: Reads a specific historical version by timestamp prefix
- **`PullRecoveryVersion()`**: Fetches a specific backup version from the Hub recovery endpoint via `?version=ID` parameter

#### Fixed
- **Bind mount write**: `atomicWriteFile()` now falls back to direct write when rename fails (fixes "device or resource busy" on Docker bind-mounted `controller.yaml`)
- **Drive mounting after restore**: Restore flow now calls `MountDrivesFromLayout()` to mount drives by UUID and add fstab entries — previously drives referenced in the infra backup were not mounted, causing "Adattároló nem elérhető" warnings
- **Post-restore redirect**: UI now polls until the controller is actually up instead of using a fixed 5-second timeout (which was too short for container restart)
- **FileBrowser DB reset scoped to restore**: `SyncFileBrowserMounts()` no longer resets the FileBrowser database volume on source changes — only the post-restore startup path (`SyncFileBrowserMountsReset`) does, preserving user accounts, permissions, and share links during normal storage operations

### v0.31.6 — UI: Brand-consistent button & card styling (2026-02-25)

#### Changed
- **Buttons**: Replaced traffic light colors (green/yellow/red) with brand-consistent palette — primary actions use blue gradient, secondary actions use ghost/outline, destructive actions show red tint on hover only (modal confirmations keep filled red)
- **Card borders**: Running apps now show a subtle blue glow instead of green top border; all other states have neutral borders
- **Status badges**: Running state badge uses brand blue instead of green
- **Button alignment**: Cards use flexbox column layout with `margin-top: auto` on actions — buttons always align to the bottom regardless of card content height
- **Dashboard cards**: Left border indicator changed from green to blue for running apps

### v0.31.5 — Fix Nextcloud-OnlyOffice callback URL + trusted_domains (2026-02-25)

#### Fixed
- **StorageUrl trailing slash**: `http://nextcloud` → `http://nextcloud/` — without trailing slash, Nextcloud's OO connector concatenates the hostname with `/apps/...` path, producing `http://nextcloudapps/...` (unresolvable hostname)
- **trusted_domains**: OO Document Server callbacks arrive with `Host: nextcloud` header; added `nextcloud` to Nextcloud's trusted_domains so these internal callbacks are not rejected

### v0.31.4 — Fix FB container not restarting + OO mixed content (2026-02-25)

#### Fixed
- `SyncFileBrowserMounts` now uses `--force-recreate` so the container always restarts and picks up config.yaml changes (bind mounts are invisible to `docker compose up`)
- OnlyOffice compose template: added Traefik `X-Forwarded-Proto=https` middleware to fix mixed content errors when OO generates `http://` URLs behind HTTPS proxy
- Nextcloud integration: added `StorageUrl=http://nextcloud` for internal file download callbacks from OO Document Server

### v0.31.3 — Fix FileBrowser integration config persistence (2026-02-25)

#### Fixed
- FileBrowser integration config (OnlyOffice URL, JWT secret) was lost after `SyncFileBrowserMounts` regenerated `config.yaml` — the async `OnStackStart` re-apply hook failed due to timing issues
- New `ReapplyConfigForTarget()` method applies integration config synchronously between config generation and container restart, ensuring it survives regen cycles

### v0.31.2 — Show FileBrowser URL on app card (2026-02-25)

#### Fixed
- Protected stacks (e.g. FileBrowser) now show their subdomain URL link on the app card — condition relaxed from `Deployed` to `Deployed OR Protected`

### v0.31.1 — Move integration & geo settings to deploy page (2026-02-25)

#### Changed
- **Integration toggles** and **geo-restriction settings** moved from app info page to deploy/settings page (user feedback: settings belong on the "Beállítások" page)
- Data wiring moved from `appDetailHandler()` to `deployHandler()` in handlers.go

### v0.31.0 — App-to-App Integration Framework (2026-02-25)

#### Added
- **Generic integration framework** (`internal/integrations/`) — Extensible system for connecting deployed apps to each other via toggle switches on the provider's app info page
- **OnlyOffice → FileBrowser integration** — Toggle enables document editing in FileBrowser by patching `config.yaml` with OnlyOffice URL and JWT secret
- **OnlyOffice → Nextcloud integration** — Toggle installs and configures the OnlyOffice connector app via `occ` CLI commands
- **Integration lifecycle hooks** — Integrations auto-suspend when provider or target stops, auto-re-enable when both are running again, permanently removed on app deletion
- **Integration API endpoints** — `GET /api/integrations/{provider}` (list), `POST /api/integrations/{provider}/{target}` (toggle)
- **Integration UI** — "Integrációk" section on app info page with toggle switches, status badges, and target availability indicators
- **`IntegrationDef`** in `.felhom.yml` metadata — Apps can declare integrations with target app slug, label, and description
- **`IntegrationState`** in `settings.json` — Persistent integration state with enabled/status/error tracking
- **SyncFileBrowserMounts re-apply** — After config regeneration (which overwrites config.yaml), active integrations are automatically re-applied

### v0.30.7 — Monitoring: Fix Memory Legend Overflow (2026-02-25)

#### Fixed
- **Memory legend overflow** — Legend items in the memory distribution chart now wrap properly instead of overflowing off-screen (`flex-wrap`, `white-space: nowrap`)

#### Improved
- **Sort by consumption** — Memory distribution bar and legend are now sorted by memory usage (descending), largest consumers first

### v0.30.6 — Telemetry: Better Log Deduplication (2026-02-25)

#### Fixed
- **ANSI escape code stripping** — Log scanner now strips ANSI color codes (e.g. `\x1b[35m`) before classifying and fingerprinting lines, preventing color codes from polluting error messages and breaking deduplication
- **Timezone offset in timestamps** — ISO timestamp regex now handles `+01:00`/`-0500` timezone offsets and optional trailing colons (fixes Vikunja-style log entries)
- **Mid-line timestamps** — Removed `^` anchor from both ISO and syslog timestamp regexes, so timestamps embedded after log-level keywords (e.g. `ERROR 2026-02-24T21:27:05`) are now stripped correctly

#### Improved
- **`cleanLine()` helper** — Consolidated ANSI + timestamp stripping into a single reusable function used by both message display and fingerprint deduplication

### v0.30.5 — Health Probe: Fast Initial Checking (2026-02-25)

#### Improved
- **Clear stale health probes on start/restart** — `StartStack` and `RestartStack` now clear the previous `HealthProbe` result, preventing stale "unhealthy" state from being re-applied by `RefreshStatus`
- **Fast 10s probing until healthy** — Stacks with no probe result (just started) or failing probes use 10-second intervals instead of waiting the full 5-minute default; reverts to normal interval once healthy
- **Scheduler frequency 1m → 10s** — Health probe scheduler runs every 10 seconds (interval logic inside `RunHealthProbes` skips stacks that don't need probing, so no extra overhead for healthy stacks)

### v0.30.4 — Deep Bug Hunt II: Concurrency, Security & Optimization (2026-02-25)

#### Fixed (Critical)
- **Watchdog mutex panic** — Wrapped `handleDisconnect` call in anonymous func with deferred re-lock to guarantee mutex re-acquisition even on panic (C1)
- **SetGeoAppOverride nil crash** — Added nil guard; passing nil override now correctly deletes the entry instead of panicking (C2)
- **SSD-only app DB restore** — `restoreDBDumps` now falls back to `app.DrivePath` when `HDDPath` is empty (C3)

#### Fixed (High)
- **Double deploy race** — Added atomic check-and-set of `Deploying` flag with `clearDeploying()` helper on all error paths (H1)
- **Delete/Remove during deploy** — Both `DeleteStack` and `RemoveStack` now reject operations while stack is deploying (H2)
- **ScanStacks overwrite** — Skips updating `Deployed`/`AppConfig` for stacks with active deploy in progress (H3)
- **FileBrowser mount race** — Added `fileBrowserMu` mutex to prevent concurrent `SyncFileBrowserMounts` calls (H5)
- **PushEvent history gap** — Added `recordHistory` calls on both success and failure paths in PushEvent goroutine (H6)
- **PushOnce silent failure** — Now returns error for non-2xx HTTP responses instead of nil (H7)
- **DB dump file corruption** — Added `tmpFile.Sync()` and `tmpFile.Close()` before rename in `DumpOne` (H8)
- **Restic retry timeout** — Creates fresh 30-minute context for retry after unlock instead of reusing near-expired original (H9)
- **Encrypt failure silent** — Added warning log when encryption fails in `SaveAppConfig` (H10)
- **Cross-backup path traversal** — Validates destination path against registered storage paths in both web and API handlers (H11)
- **deepCopyStack incomplete** — Now deep-copies `Meta.OptionalConfig`, `Meta.HealthCheck`, and `DeployField.Options` (H12)

#### Security
- **Constant-time API key** — Replaced `==` with `subtle.ConstantTimeCompare` for API key comparison, preventing timing attacks (M1)
- **Login rate limiting** — Added per-IP rate limiter (5 attempts/minute) to login handler (M8)
- **Git credential masking** — Applied `maskRepoURL()` in `runGitInDir` log output to prevent credential leakage (M23)
- **Path prefix traversal** — Fixed `storageAttachBrowseHandler` prefix check to require trailing `/`, preventing sibling directory matches (M24)

#### Concurrency & Logic
- **MigrateEncryption race** — Moved `encKey == nil` check inside the mutex lock (M5)
- **SubdomainInUse I/O under lock** — Collect stack dirs under RLock, release, then perform disk I/O outside (M4)
- **Scheduler late jobs** — Jobs registered after `Start()` now immediately get their goroutine launched (M10)
- **SQLite WAL verification** — WAL pragma now verified via `QueryRow` + `Scan` instead of silent `Exec` (M13)
- **Metrics shutdown** — `sampleContainers` now uses parent context instead of `context.Background()` for clean shutdown (M14)
- **Telemetry scan logging** — Row scan errors now logged instead of silently swallowed (M15)
- **Asset sync lock** — Refactored to hold mutex only for status updates, not during entire HTTP download (M22)

#### Optimization
- **DB dump copy** — Replaced `os.ReadFile`/`os.WriteFile` with streaming `io.Copy` via `copyFile` helper for large dumps (M16)
- **Restic stats dedup** — Per-drive stats now computed once and aggregated, eliminating duplicate restic subprocess calls (M17)
- **Infra config atomic** — `syncInfraConfig` controller.yaml copy now uses atomic write via `copyFile` (M20)

### v0.30.3 — Comprehensive Bug Hunt Fixes (2026-02-25)

#### Fixed (Critical — P0)
- **Encrypted env vars** — `UpdateStackConfig` now uses decrypted values when building compose env, preventing `ENC:...` literals in containers (C01)
- **Silent decrypt failures** — `DecryptMap` now logs warnings on decrypt failure instead of silently returning empty values (C02)
- **Deploy race condition** — `Deployed = false` flag now set inside the mutex lock in `runComposeDeploy` (C03)
- **Shared state mutation** — `GetStack`/`GetStacks` now return deep copies preventing callers from mutating cached state (C04)
- **Watchdog races** — Added per-state mutex to `pathProbeState` for thread-safe probe state access (C05)
- **Metrics double-start** — `MetricsCollector.Start()` guarded with `sync.Once` (C06)
- **Raw mount race** — `diskJobMu` now held across entire cleanup+mount+set operation (C07)
- **Encryption key race** — Added mutex to `SetEncryptionKey` (C08)

#### Fixed (High — P1)
- **Restic lock detection** — `Snapshot()` now extracts stderr from `*exec.ExitError` and checks `unlockCmd.Run()` error (H01)
- **Disconnected drives in backup** — `activeDrives()` now skips disconnected/decommissioned drives (H02)
- **Template rendering** — Buffered via `bytes.Buffer` to prevent partial HTML on error (H07)
- **Sync stop panic** — `Stop()` uses `sync.Once` for safe channel close (H08)
- **Sync race** — `syncing = true` set before releasing lock in `TriggerSync` (H09)
- **Cloudflare context** — Threaded `context.Context` through all Cloudflare API calls for cancellation support (H10)
- **Cross-drive collision** — Replaced flawed leaf-name dedup with proper `seen` map (H15)
- **CSRF bypass** — Bearer token now validated against Hub API key before skipping CSRF (H16)
- **Nil pointer** — Added nil check for `crossDriveRunner` in handlers (H17)
- **Selftest panic** — Replaced `out[:len(out)-1]` with `strings.TrimSpace` (H18)
- **Stderr goroutine** — Added `sync.WaitGroup` in `MigrateDrive` (H19)
- **UUID slice** — Guarded `uuid[:8]` with length check (H20)
- **Fstab matching** — Parse fields exactly instead of loose `strings.Contains` (H21)
- **Atomic save** — `SaveAppConfig` writes to `.tmp` then renames (H04)
- **Deploy failure** — `SaveAppConfig` on failure now includes `encKey` (H05)
- **Encryption migration** — Uses write lock instead of read lock (H03)
- **Deep copy** — `GetFullStatus` deep-copies `lastDBDump`/`lastBackup` (H11)
- **IPv6** — TCP health probe uses `net.JoinHostPort` for IPv6 compatibility
- **Backup path validation** — `RemoveStack` validates paths under expected directory (M12)
- **Updater race** — `SetBackupRunningCheck` protected by mutex (M18)

#### Fixed (Medium — P2)
- **Config env overrides** — `LoadFromBytes` now calls `applyEnvOverrides` (M05)
- **Selfupdate state** — Compose-up failure now sets `state.Status = "failed"` (M16)
- **Memory check** — `usableMB` clamped to min 0 (M22)
- **Cross-backup trigger** — Removed invalid "manual" schedule from `triggerAllCrossBackups` (M23)
- **mmcblk support** — Partition path and `stripPartition` now handle mmcblk devices (M21, L25)
- **Scheduler** — `Start()` guarded against double-start, `Stop()` acquires mutex (M14, L24)
- **Pending events** — Events restored on save failure in `DrainPendingEvents` (M03)
- **Duplicate storage** — `AddStoragePath` rejects already-registered paths (M04)
- **Setup scan** — `CleanupTempMounts` called after drive scan (H13)
- **Setup state** — `SetStep` now logs save errors (M25)

#### Fixed (Low — P3)
- **UTF-8 truncation** — `TruncateStr` now operates on runes and handles negative maxLen (L05/L06)
- **AllDone** — Returns false for empty restore plans (L14)
- **PushOnce** — Returns actual errors instead of swallowing them (L39)
- **CSRF token** — Panics on `crypto/rand.Read` failure instead of using static fallback (L40)
- **Logout** — Requires POST method (L32)
- **Server.Close** — Uses `sync.Once` to prevent double-close panic (L49)
- **Log cap** — `lines` query parameter capped at 10000 (L31)
- **Hash function** — Replaced custom `simpleHash` with `crc32.ChecksumIEEE` (L48)
- **hasPrefix** — Replaced custom implementation with `strings.HasPrefix` (L13)
- **DefaultEnabledEvents** — Copied in `GetNotificationPrefs` early return (L09)
- **Variable shadowing** — Renamed `copy` to `cp` in `SetNotificationPrefs` (L07)

#### Removed
- Dead `imageName` function in selfupdate (L02)
- Dead `detectHostIPViaRoute` function in setup (L03)
- Custom `hasPrefix` function in restore_scan (L13)

### v0.30.2 — Report geo-restriction + logo/favicon update (2026-02-25)

#### Added
- **Geo-restriction in reports** (`internal/report/`) — New `GeoRestrictionReport` struct and `geo_restriction` field in the Report JSON. Hub can now display current geo-blocking status (enabled, allowed countries, per-app overrides, sync state) on customer detail pages.
- **Favicon route** (`/static/favicon.svg`) — Separate favicon SVG served from synced assets or embedded fallback. Uses the cloud icon from `logo_favicon_2.svg`.
- **Hub Bearer auth for geo API** — `/api/geo/` routes now accept `selfUpdateAuthMiddleware` (session auth OR Hub API key), allowing the Hub to send geo-disable commands to controllers.

#### Changed
- **Logo SVG updated** (`internal/web/templates.go`) — Replaced embedded logo with the latest `logo.svg` from the website (white text variant).
- **Favicon link** — Layout and catch-all templates now reference `/static/favicon.svg` instead of the full logo.

### v0.30.1 — Geo-Restriction fix (2026-02-25)

#### Fixed
- **WAF rule creation** — Removed custom block response body from WAF rules (requires paid Cloudflare plan). Block action now uses Cloudflare's default 403 page.

### v0.30.0 — Geo-Restriction via Cloudflare WAF (2026-02-25)

#### Added
- **Geo-restriction feature** (`internal/cloudflare/`) — New package for managing Cloudflare WAF Custom Rules. Allows restricting access to apps by country using the `http_request_firewall_custom` phase. Rules are identified by `[felhom-geo]` description prefix — other WAF rules are untouched.
- **Cloudflare API client** (`internal/cloudflare/client.go`) — HTTP client with Bearer token auth for the Cloudflare v4 API. Supports zone lookup, ruleset management, and rule CRUD operations.
- **Country data** (`internal/cloudflare/countries.go`) — Embedded map of ~250 ISO 3166-1 alpha-2 country codes with Hungarian names. Includes search helpers for the UI.
- **Geo sync manager** (`internal/cloudflare/geosync.go`) — Orchestrator that diffs desired vs existing Cloudflare rules and applies changes. Runs on settings change, after app deploy/remove, and every 6 hours for verification.
- **Settings page UI** (`templates/settings.html`) — New "Földrajzi korlátozás" section with searchable country selector (autocomplete dropdown → tag chips), enable/disable toggle, per-app override summary, and sync status display. Hungary removal triggers a confirmation warning.
- **Per-app override** (`templates/app_info.html`) — Each app's detail page now has a "Földrajzi korlátozás" section (when the feature is globally enabled) to set app-specific allowed countries.
- **Geo API endpoints** (`internal/api/geo.go`) — `GET /api/geo/status`, `POST /api/geo/settings`, `POST /api/geo/sync`, `GET /api/geo/countries`, `POST/DELETE /api/stacks/{name}/geo/override`.
- **Settings model** (`internal/settings/settings.go`) — New `GeoRestriction` struct with `AllowedCountries`, `AppOverrides`, and sync state (zone ID, ruleset ID, last sync). Thread-safe getter/setter methods following existing RWMutex pattern.

#### Changed
- **Router** (`internal/api/router.go`) — Added `OnGeoRelevantChange` callback triggered after app deploy/remove to re-sync geo rules when hostnames change.
- **Main wiring** (`cmd/controller/main.go`) — Cloudflare client, geo sync manager, and scheduler job initialized when `cf_api_token` is configured. New `geoStackAdapter` provides deployed app hostnames.

#### Hub Changes
- **Config form** (`hub/internal/web/templates/config_form.html`) — Updated CF API token help text to indicate Zone WAF:Edit permission is needed for geo-restriction.

#### Notes
- The existing `cf_api_token` needs **Zone WAF:Edit** permission added (in addition to existing Zone DNS:Edit for ACME). No new token field is needed.
- Local network access is inherently unaffected — local traffic bypasses Cloudflare entirely.
- Cloudflare Free plan supports up to 5 custom rules, which is sufficient for a global rule + a few per-app overrides.

### v0.29.3 — Controller-side Health Probes (2026-02-25)

#### Added
- **HTTP/TCP health probes** (`internal/stacks/healthprobe.go`) — The controller now probes deployed apps directly over the Docker network to verify services are actually responding, not just that containers are running. Runs every minute, configurable per-app interval (default 5 min).
- **Three probe types**: `http` (any response = alive), `api` (validates status code and response body), `tcp` (port reachability). Multiple checks per app supported.
- **`.felhom.yml` healthcheck config** (`internal/stacks/metadata.go`) — New `healthcheck:` section with `interval`, `checks[]` (type, port, path, method, expect). Parsed from app catalog metadata.
- **State override** (`internal/stacks/manager.go`) — If a running container's health probe fails, the stack state is overridden to "unhealthy". Clears automatically when probe passes again.

#### Fixed
- **Vikunja healthcheck** — Removed Docker-level healthcheck (distroless image has no wget/curl). Controller-side API probe to `:3456/api/v1/info` replaces it.

### v0.29.2 — Dynamic Logo & Favicon (2026-02-25)

#### Changed
- **Logo served from synced assets** (`internal/web/server.go`) — `serveLogoHandler` now checks the Hub-synced assets directory for `felhom-logo.svg` first, falling back to the embedded SVG constant if not found. This allows logo updates via Hub without a controller rebuild.

#### Added
- **SVG favicon** (`templates/layout.html`, `templates/catchall.html`) — Added `<link rel="icon" type="image/svg+xml">` pointing to `/static/felhom-logo.svg` so browsers display the Felhom logo as a tab icon.

### v0.29.1 — Fix Git Lock File Stale After Interrupted Sync (2026-02-24)

#### Fixed
- **Stale git lock file recovery** — Catalog sync now removes stale `.git/index.lock`, `.git/shallow.lock`, and `.git/HEAD.lock` files before running `git fetch`/`git reset`. Previously, if the container was killed mid-sync, the leftover lock file would block all subsequent syncs until manual intervention.

### v0.29.0 — Encrypt Sensitive Values in app.yaml (2026-02-23)

#### Added
- **AES-256-GCM encryption for app.yaml secrets** — Sensitive deploy field values (`type: password` and `type: secret`) are now encrypted at rest in each stack's `app.yaml` using a per-node 32-byte key. Encrypted values are stored as `ENC:base64(nonce+ciphertext)`. New `internal/crypto` package provides `Encrypt`, `Decrypt`, `LoadOrCreateKey`, `DecryptMap`, and `IsEncrypted` helpers.
- **Encryption key in infra backup** — The encryption key (`encryption.key`) is included in the Hub infra backup bundle (`encryption_key_b64` field) and local drive infra backups for disaster recovery.
- **Encryption key restore** — The setup wizard's infra restore flow restores `encryption.key` from the backup bundle so encrypted app.yaml values remain readable after disaster recovery.
- **Startup migration** — On first start after upgrade, existing plaintext sensitive values in deployed stacks' `app.yaml` files are automatically encrypted in-place.

#### Changed
- **`SaveAppConfig` signature** — Now accepts `encKey []byte` and `sensitiveVars []string` parameters for encryption. All callers (deploy, update, optional config, inject missing fields, HDD path update, storage handlers) updated.
- **`LoadAppConfigDecrypted`** — New helper that loads app.yaml and transparently decrypts all `ENC:` values for docker-compose env injection and web UI display.
- **`SensitiveEnvVars`** — New exported helper that identifies sensitive env vars from `.felhom.yml` metadata (`type: password` or `type: secret` deploy fields).
- **Manager struct** — Added `encKey` field and `SetEncryptionKey()` / `MigrateEncryption()` methods.
- **Web Server struct** — Added `encKey` field and `SetEncryptionKey()` method; deploy handler decrypts values before template rendering.

### v0.28.8 — Password UX Polish (2026-02-23)

#### Fixed
- **Password fields empty after deployment** (`templates/deploy.html`) — Password-type deploy fields now read their stored value from `DeployedFieldValues` (app.yaml env) when viewing settings for an already-deployed app, instead of always using the field's `.Default` (which was empty).
- **Post-deploy credentials masked** — Passwords on the post-deploy success card are now shown as `••••••••••••` with "Megjelenítés" (reveal) and "Másolás" (copy to clipboard) buttons, instead of displaying plaintext.

#### Changed
- **Settings page: initial password hint** — Deployed password fields show a note: *"Telepítéskor beállított kezdeti jelszó — ha az alkalmazásban megváltoztattad, az itt nem frissül."* Generate button is hidden for already-deployed apps.
- **Post-deploy credential detection** — Added EMAIL to the username-detection heuristic (catches Kimai's `ADMIN_EMAIL`).

### v0.28.7 — Password Field UX (2026-02-23)

#### Changed
- **Password deploy fields: masked input with reveal & confirmation** (`templates/deploy.html`) — `type: password` fields now render as masked inputs (hidden by default) with an eye toggle button to reveal/hide. Added a "Jelszó megerősítése" confirmation field below each password input. The "Generálás" button fills both fields simultaneously. Form validation checks that both fields match before allowing deploy. Confirmation fields are only shown for new deployments.
- **App catalog: admin passwords use `type: password`** (separate repo: `app-catalog-felhom.eu`) — Changed 4 apps (Nextcloud, Grafana, Kimai, Code-server) from `type: secret` to `type: password` so users can see/edit/generate admin passwords during deployment (matching the existing Paperless-ngx pattern).

### v0.28.6 — Filebrowser Link, Appdata Paths & Log Timestamps (2026-02-23)

#### Added
- **Post-deploy credential display** (`templates/deploy.html`) — The success page now shows actual username/password values from the deploy form instead of a generic message. Reads from deploy field metadata, filtering out internal DB passwords and secret keys. Falls back to `defaultCreds` for apps without typed deploy fields.

#### Fixed
- **Filebrowser "open" link on stacks page** (`web/handlers.go`) — Protected stacks like filebrowser have no `.felhom.yml` or `app.yaml`, so the subdomain lookup found nothing. Added `protectedStackSubdomains` fallback map for programmatically managed protected stacks (filebrowser → "files"). Now shows `files.<domain> ↗` link on both the stacks page and dashboard.
- **App catalog: appdata volume paths** (separate repo: `app-catalog-felhom.eu`) — 4 compose templates (nextcloud, immich, paperless-ngx, romm) used `${HDD_PATH}/appdata/` instead of `${HDD_PATH}/felhom-data/appdata/` as designed in the v0.26.0+ storage structure. Fixed all templates. Existing deployments need redeployment or manual volume path update.
- **Debug log viewer timestamps** (`web/logbuffer.go`, `templates/debug.html`) — Naplóviewer showed relative times like "-3586mp" (negative due to timezone bug: `time.Parse` assumed UTC but `log.LstdFlags` outputs local time). Now uses `time.ParseInLocation` with `time.Local`, and displays absolute `HH:MM:SS` timestamps.

### v0.28.5 — Post-Deploy Info Card (2026-02-23)

#### Added
- **Post-deploy success page** (`web/templates/deploy.html`) — After a successful deploy, instead of auto-redirecting to the apps list, shows a rich info card with: direct app link ("Alkalmazás megnyitása ↗"), first steps from catalog metadata (with DOMAIN placeholders replaced), default credentials info, documentation link, and a link to the settings page where passwords can be revealed. Also shown for unhealthy/timeout states since apps may still be usable during initialization.

### v0.28.4 — Telemetry: Skip Stopped Apps (2026-02-23)

#### Fixed
- **Stopped apps no longer send zero-value telemetry to hub** (`report/telemetry.go`) — Previously, deployed-but-stopped apps were included in the telemetry report with all-zero memory/CPU values, which dragged down hub-side averages. Now `buildAppTelemetry` checks `isStackRunning()` and only includes apps in running, starting, unhealthy, or restarting states.

### v0.28.3 — Catch-All Page, Deploy Controls, Dashboard Open (2026-02-23)

#### Added
- **Catch-all page for stopped/undeployed apps** — When a user visits a stopped app's subdomain (e.g., `travel.demo-felhom.eu`), they now see a branded felhom page with the app name and status ("Az alkalmazás jelenleg le van állítva") instead of Traefik's raw 404. Implemented via a low-priority (1) Traefik catch-all router on the controller container + `CatchAllMiddleware` in `server.go` that intercepts non-controller hosts and renders standalone `catchall.html` without auth.
- **Start/Stop/Restart buttons on deploy settings page** — Deployed apps now show Indítás/Leállítás/Újraindítás buttons in the page header, plus a "Megnyitás ↗" link to the app's subdomain (visible when running). Previously the deploy page had no state controls.
- **"Megnyitás ↗" button on Vezérlőpult** — Running apps on the dashboard now show an open button that launches the app in a new tab. Uses the `Subdomains` map built from `app.yaml` SUBDOMAIN env with metadata fallback.
- **`findStackBySubdomain()`** helper in `server.go` — looks up stacks by subdomain, checking deployed `app.yaml` env first, then `.felhom.yml` metadata.

#### Changed
- **Subdomain links on Alkalmazások page** — Links now only shown for deployed apps (previously shown for all apps including non-deployed ones where the subdomain isn't final yet).
- **`docker-compose.yml`** — Added 6 catch-all Traefik router labels (`traefik.http.routers.catchall.*`) with `priority=1` and `certresolver=letsencrypt`.

### v0.28.2 — Async Deploy & AdventureLog Fix (2026-02-23)

#### Changed
- **Async deploy** — `DeployStack()` now runs `docker compose up -d` in a background goroutine instead of blocking the HTTP response. The deploy API returns immediately after validation + config save, so the UI switches to the progress panel instantly (previously waited 30-60s for image pulls). New `StateDeploying` container state shown while compose-up is in progress. On failure, the goroutine reverts both disk and in-memory state and stores the error in `DeployError` for the polling UI to display.
- **Deploy progress UI** — Polling now handles the `deploying` state ("Képek letöltése, konténerek indítása...") and `deploy_error` (shows error message with links to logs). Previous behavior only showed progress after compose-up completed.

#### Fixed
- **RestartStack uses `up -d` with env vars** — `RestartStack()` previously used bare `docker compose restart` which only sends SIGTERM+start without re-reading the compose file or injecting env vars from `app.yaml`. Now uses `docker compose up -d` with full env, matching `StartStack()` behavior. This ensures template changes (images, healthchecks) and env var updates are picked up on restart.
- **AdventureLog backend healthcheck** — Replaced `wget` (not available in v0.11.0 image) with `python urllib.request`. Also uses `127.0.0.1` instead of `localhost` to avoid IPv6 resolution issues.
- **AdventureLog frontend healthcheck** — Changed `localhost` → `127.0.0.1` to fix IPv6 resolution causing connection refused (Node.js only listens on IPv4).
- **AdventureLog SECRET_KEY** — Added `SECRET_KEY=${SECRET_KEY}` env var alongside `DJANGO_SECRET_KEY` for v0.11.0 compatibility (Django settings now reads `SECRET_KEY` directly).

### v0.28.1 — Telemetry Debug Section (2026-02-23)

#### Added
- **Telemetria teszt section on Debug page** — New collapsible section between "Hub & Kapcsolatok" and "Önfrissítés teszt". Click "Telemetria futtatása" to run the full telemetry collection pipeline on-demand without waiting for the 15-minute report cycle.
- **`GET /api/debug/telemetry`** — New debug endpoint in `handler_debug.go`. Invokes `GetTelemetryPreview` callback, returns per-app data: container list, memory (current/avg/peak), CPU avg, catalog limit, log error/warning counts, top issues, and overall latency. Response: `{latency_ms, app_count, total_errors, total_warnings, app_telemetry[]}`.
- **`GetTelemetryPreview` callback** added to `DebugCallbacks` struct. Wired in `main.go` debug-mode block: calls `report.BuildAppTelemetryForDebug(stackMgr, metricsStore, logger)`. Available regardless of hub configuration.
- **`report.BuildAppTelemetryForDebug()`** — Exported wrapper in `internal/report/telemetry.go` around the private `buildAppTelemetrySection()`. Allows debug endpoint access without exposing internal package details.
- **JS rendering** — `runTelemetryTest()` fetches the endpoint and shows a summary message. `renderTelemetryDetail()` builds a table with per-app rows (color-coded errors in red, warnings in yellow) and sub-rows for top issues. Includes a collapsible "Nyers JSON" section showing the exact payload that would go to the hub.

### v0.28.0 — App Telemetry & Analytics (2026-02-23)

#### Added
- **App telemetry in Hub reports** — `Report.AppTelemetry` (new field in `report/types.go`) carries per-stack memory/CPU metrics and log scan results to the Hub on every report push. Backward-compatible: old Hub versions silently ignore the new field.
- **`internal/metrics/telemetry.go`** — New `MetricsStore.GetContainerTelemetry(since)` method aggregates container memory (current/avg/peak) and CPU averages from the existing `container_metrics` SQLite table over the last 15 minutes.
- **`internal/metrics/logscanner.go`** — New `ScanContainerLogs(containerNames, since, logger)` function runs `docker logs --since=15m --tail=1000` on each non-protected deployed container. Detects errors/warnings by keyword matching, deduplicates via fingerprinting (strips timestamps, replaces 6+ digit numbers with `<N>`, hex with `<HEX>`, UUIDs with `<UUID>`). Returns `[]ContainerLogSummary` with counts and `RecentIssues` (top 10 per container).
- **`internal/report/telemetry.go`** — New `buildAppTelemetrySection()` and `buildAppTelemetry()` functions assemble per-stack `AppTelemetry` records by aggregating container-level metrics and log summaries. Only non-protected, deployed stacks are included.

#### Changed
- **`internal/report/builder.go`** — `BuildReport()` now calls `buildAppTelemetrySection()` after the stacks section, populating `r.AppTelemetry`.
- **`internal/report/types.go`** — Added `AppTelemetry []AppTelemetry` field to `Report` struct. Added new `AppTelemetry` type with fields: app_name, display_name, containers, memory metrics, catalog estimate/limit, log error/warning counts, and top issues.

### v0.27.3 — Real System Memory Everywhere (2026-02-23)

#### Changed
- **Deploy page uses real system memory** — Memory bar now shows actual `/proc/meminfo` usage instead of declared `mem_request` sums. Labels changed from "Jelenlegi foglalás" to "Jelenlegi használat". `system.GetMemoryMB()` provides real-time total and used memory.
- **Pre-start memory check uses real memory** — `actionStack("start")` in `router.go` and `DeployStack()` in `deploy.go` now check real used memory (`usedMB + newReqMB > usableMB`) instead of declared committed sums. `CommittedMemory()` kept only for soft overcommit warnings.

#### Added
- **`system.GetMemoryMB()` helper** — Lightweight function in `internal/system/info_linux.go` that returns real total and used memory from `/proc/meminfo` without the overhead of full `GetInfo()` (no disk/CPU/temp). Stub in `info_other.go` for non-Linux.
- **Monitoring page memory distribution bar** — New stacked bar on `/monitoring` showing per-container memory usage (colored segments), OS/system overhead (gray), and free memory. Built dynamically from container summary data + real-time `/api/system/info`. Color-coded legend with per-app labels.

### v0.27.2 — Comprehensive Fixes and New Labels (2026-02-23)

#### Fixed
- **Deploy error popups now copyable** — Replaced all native `alert()` calls with a custom modal (`showAlert()` in layout.html) using a `<pre>` block with `user-select:text`. Error messages can now be selected and copied. Applied across deploy.html and layout.html.
- **Manual Tier2 backup now reports to Hub** — Added `OnCrossDriveComplete` callback to `Router` (`internal/api/router.go`). Both `triggerCrossBackup` (single-app) and `triggerAllCrossBackups` (run-all) now call `pushInfraBackup()` + `writeLocalInfraBackup()` after completion, matching the automatic scheduled path.
- **Memory bar excludes stopped apps** — `CommittedMemory()` in `internal/stacks/manager.go` now skips apps with `StateStopped` or `StateExited`. Only running/starting/unhealthy apps count toward committed memory.
- **Pre-start memory check** — `actionStack("start")` in `internal/api/router.go` now validates available memory before starting a stopped app. Returns 409 Conflict with a descriptive Hungarian error if insufficient.

#### Added
- **`hungarian_ui` metadata field** — New `HungarianUI bool` field in `ResourceHints` (`internal/stacks/metadata.go`). Shows "Magyar felület" green badge on deploy, stacks, and app info pages when `hungarian_ui: true` in `.felhom.yml`.
- **USB badge on storage cards** — Settings page storage cards now show an orange "USB" badge next to Aktív/Alapértelmezett when the drive is USB-attached (using existing `IsUSB` sysfs detection).
- **`StackMemoryMB()` helper** — New method on `Manager` to get a specific stack's memory request.

#### App Catalog (app-catalog-felhom.eu)
- **AdventureLog** — Fixed image tags from `v0.12.0` (non-existent) to `v0.11.0` for both backend and frontend.

### v0.27.1 — Fix FileBrowser Mount Sync (2026-02-22)

#### Fixed
- **`internal/web/handlers.go`** — `SyncFileBrowserMounts()` was reading the domain from a `.env` file that doesn't exist in the filebrowser stack directory (domain is baked into the compose labels by `docker-setup.sh`). It always logged `[WARN] Cannot read DOMAIN from FileBrowser .env — skipping mount sync` and returned early, so storage paths were never synced to FileBrowser's config.yaml or docker-compose.yml. Fixed by using `s.cfg.Customer.Domain` directly from the controller config.

### v0.27.0 — User-Configurable App Subdomains (2026-02-22)

#### Added
- **User-configurable subdomains**: Users can now customize the subdomain (e.g., `wiki`, `cloud`, `my-notes`) for each app during deployment, instead of using a fixed value. The deploy page shows an editable text input with the default subdomain pre-filled and the base domain as a suffix (e.g., `[wiki] .demo-felhom.eu`).
- **New deploy field type `"subdomain"`** — `internal/stacks/metadata.go`, `deploy.go`: A new field type that is user-editable with a default value, validated, and locked after deployment. Changing the subdomain requires removing the app (clean install) and redeploying.
- **Subdomain validation** — `internal/stacks/deploy.go`: Three-layer validation: DNS-safe format (lowercase alphanumeric + hyphens, max 63 chars), reserved name blocklist (`felhom`, `files`, `traefik`, `api`, `www`, `mail`, `admin`, etc.), and uniqueness check across all deployed stacks.
- **Backward compatibility** — `internal/stacks/deploy.go`: `InjectMissingFields()` auto-fills `SUBDOMAIN` from the `.felhom.yml` default for existing deployed apps when templates are synced, so no manual intervention is needed.
- **`internal/web/handlers.go`** — `stacksHandler()` builds an effective subdomain lookup map (stored env → metadata fallback). `appDetailHandler()` passes `EffectiveSubdomain` to templates.
- **`internal/web/templates/deploy.html`** — New `.subdomain-input-group` widget with inline `.domain` suffix. Client-side validation enforces DNS-safe format with real-time lowercasing.
- **`internal/web/templates/stacks.html`**, **`app_info.html`** — Subdomain links now read from stored `app.yaml` env (via lookup map) instead of hardcoded metadata, showing the user's actual chosen subdomain.

#### Changed
- **`internal/stacks/deploy.go`** — `PreviewDeployValues()` domain case simplified: shows just the base domain now (subdomain is a separate field).
- **`internal/web/handlers.go`** — Deploy page domain auto-field no longer prepends `meta.Subdomain + "."`. Passes `DeployedFieldValues` for rendering stored subdomain on settings page.

#### App Catalog (app-catalog-felhom.eu)
- All 51 template `docker-compose.yml` files updated: hardcoded `{subdomain}.${DOMAIN}` replaced with `${SUBDOMAIN}.${DOMAIN}` in Traefik labels, app env vars (APP_URL, trusted domains, webhook URLs, etc.), and comments.
- All 51 `.felhom.yml` files updated: added `SUBDOMAIN` deploy field with `type: subdomain` and `default:` matching the existing `subdomain:` metadata value.

### v0.26.2 — Show Full App URL on Deploy Page (2026-02-22)

#### Fixed
- **`internal/stacks/deploy.go`** — `PreviewDeployValues()` now shows the full reachable URL (`subdomain.base_domain`) for domain-type fields instead of just the base domain. Informational only — stored env var remains the base domain.
- **`internal/web/handlers.go`** — Same fix applied to the already-deployed settings page: domain field displays `subdomain.base_domain` matching what the app card shows.

### v0.26.1 — Show Auto-Generated Values on Deploy Page (2026-02-22)

#### Changed
- **`internal/stacks/deploy.go`** — Added `PreviewDeployValues()` method: pre-generates domain and secret field values when the deploy page is loaded, so the user can see (and note down) exact values before deploying. Updated `DeployStack()` to accept pre-generated secret values from the form instead of always regenerating.
- **`internal/web/handlers.go`** — `deployHandler` now calls `PreviewDeployValues()` for non-deployed apps and populates `AutoFieldValues` (previously empty for pre-deploy).
- **`internal/web/templates/deploy.html`** — "Automatikusan generált értékek" section now shows actual values on the pre-deploy page too: domain as a readonly text input, secrets as readonly password inputs with a "Megjelenítés" reveal button. Updated section description to inform the user to note down passwords. Pre-generated secret values are submitted as hidden inputs so the same values shown to the user are saved to `app.yaml`.

### scripts — Hub Mode + FileBrowser Controller-Managed Volumes (2026-02-22)

#### `scripts/docker-setup.sh` — v6.0.0
- **Hub mode** (`--hub-customer` / `--hub-password`): downloads `controller.yaml` from Hub API early in setup, extracts `domain`, `email`, `cf_api_token`, `cf_tunnel_token` and auto-populates all infrastructure settings. Single one-liner deploys fully configured Traefik + TLS + Cloudflare Tunnel with no additional flags needed. CLI flags always override hub values.
- **`yaml_get()` helper**: strips leading whitespace before key comparison — required because Go's `yaml.v3` uses 4-space indentation.
- **`apply_hub_config()`**: called before `print_banner` in `main()` so hub-sourced values are reflected in the plan display.
- **FileBrowser initial install**: removed drive auto-discovery from `install_filebrowser()`. FileBrowser is now installed with no drive volumes and a minimal `config.yaml` with `/srv` fallback. Drive volumes are managed entirely by the controller (`SyncFileBrowserMounts()`) after storage is registered via the dashboard.
- **Bug fix**: `((found_mounts++))` → `found_mounts=$(( found_mounts + 1 ))` — `set -euo pipefail` traps post-increment when var=0 (exit code 1). Same fix applied to `step_num` in `install_filebrowser()`.

#### `scripts/felhom-wipe.sh`
- **`cleanup_scan_dir()`**: removes `/mnt/.felhom-scan/` (ephemeral DR scan directory) — called from `full` level onwards.
- **`cleanup_raw_mounts()`**: removes raw helper mount infrastructure (`/mnt/.felhom-raw/`) at `nuclear` level: unmounts bind mounts first, then raw mounts, strips fstab entries, removes empty directories. Physical drive data untouched.
- **Bug fix**: `do_soft_wipe()` used `[ -f "$f" ] && rm -f "$f" && info "..."` — with `set -euo pipefail`, when a state file doesn't exist `[ -f ]` returns 1, the whole `&&` chain returns 1, and `set -e` exits the script. Nuclear wipe was silently stopping after removing only the first two state files that existed. Fixed with `if [ -f "$f" ]; then ...; fi`.

#### `scripts/README.md`
- Hub mode quick start simplified to one-liner
- Updated installation steps table: step 7 reflects controller-managed FileBrowser volumes
- Added "Raw helper mounts" section explaining two-level mount architecture
- Updated wipe levels table for `full` (scan dir) and `nuclear` (raw mounts + scan dir)

### v0.26.0 — Storage Namespace `felhom-data/` + Test Node Wipe Script (2026-02-22)

All felhom-managed data on external drives now lives under a `felhom-data/` subdirectory, cleanly separating controller-managed data from user files. Plus a multi-level wipe script for repeatable test node cleanup.

**Key design principle:** `HDD_PATH` env var stays as the mount point (e.g., `/mnt/hdd_1`). The `felhom-data` segment is embedded in path helpers and compose templates — not in `HDD_PATH`.

#### Changed
- **`internal/backup/paths.go`** — Added `FelhomDataDir = "felhom-data"` constant. Updated 8 path functions to insert `felhom-data` between the drive root and data subdirectory:
  - `PrimaryBackupPath` → `<drive>/felhom-data/backups/primary`
  - `PrimaryResticRepoPath` → `<drive>/felhom-data/backups/primary/restic`
  - `AppDBDumpPath` → `<drive>/felhom-data/backups/primary/<stack>/db-dumps`
  - `SecondaryBackupPath` → `<drive>/felhom-data/backups/secondary`
  - `AppSecondaryRsyncPath` → `<drive>/felhom-data/backups/secondary/<stack>/rsync`
  - `SecondaryResticRepoPath` → `<drive>/felhom-data/backups/secondary/restic`
  - `SecondaryInfraPath` → `<drive>/felhom-data/backups/secondary/_infra`
  - `AppDataDir` → `<drive>/felhom-data/appdata/<stack>`
  - `InfraBackupDir` **unchanged** — stays at drive root for DR scanner
- **`internal/stacks/delete.go`** — Added local `felhomDataDir = "felhom-data"` constant (cannot import `backup` due to architectural boundary). Updated `ProtectedHDDPaths()` to protect `<drive>/felhom-data`, `<drive>/felhom-data/appdata`, `<drive>/felhom-data/backups`. Fixed hardcoded paths in `GetStackBackupData()`.
- **`internal/storage/migrate_drive.go`** — Added `backup` package import. Fixed 4 issues:
  - Conflict check: uses `backup.AppDataDir()` instead of hardcoded `appdata/`
  - Verify step: uses `backup.AppDataDir()` instead of hardcoded `appdata/`
  - rsync excludes: updated from `backups/primary/restic/` to `felhom-data/backups/primary/restic/`
  - Size estimation: now scans inside `felhom-data/` namespace, skipping restic repos correctly
- **`internal/storage/migrate.go`** — Added `backup` package import. Post-migration DB dump copy now uses `backup.AppDBDumpPath()` instead of hardcoded paths.
- **`internal/web/handlers.go`** — Fixed legacy `"storage"` path in storage app detail size calculation (was dead code — path never existed); now uses `backup.AppDataDir()`.
- **`internal/storage/format_linux.go`** — Format wizard creates `felhom-data/` subdirectory instead of legacy `storage/`.
- **`internal/storage/attach_linux.go`** — Attach wizard creates `felhom-data/` subdirectory instead of legacy `storage/`.

#### Added
- **`scripts/felhom-wipe.sh`** — Test node cleanup script with 4 wipe levels:
  - `soft` — Removes controller state files (settings.json, metrics.db, session/setup/update/snapshot state)
  - `controller` — Soft + removes all app containers, volumes, and stack directories (skips protected stacks by default)
  - `full` — `controller`-level cleanup + removes `felhom-data/` on all storage drives (also removes old-style `appdata/` and `backups/` for migration compatibility); infra containers preserved, controller restarted after cleanup
  - `nuclear` — Full + removes controller.yaml, all infrastructure containers (controller, traefik, cloudflared, portainer), DR markers, and runs `docker system prune -af --volumes`
  - Auto-detects paths from `controller.yaml` and `settings.json`
  - Dry-run by default; requires `--yes` to execute
  - Interactive confirmation prompt with `--yes` execution

#### Notes
- **Migration**: Pre-v0.26.0 restic snapshots reference old paths (without `felhom-data/`). Existing installations need data migration before upgrading.
- **App catalog**: Compose templates need separate update: `${HDD_PATH}/appdata/` → `${HDD_PATH}/felhom-data/appdata/` (tracked as separate task).
- All backup, crossdrive, and restore logic automatically picks up new paths via `paths.go` helpers — no changes needed in `backup.go`, `crossdrive.go`, or `restore.go`.

---

### v0.25.0 — Debug Page: Operator Testing & Diagnostics Dashboard (2026-02-21)

**Full debug dashboard with 8 sections for testing all controller subsystems in debug mode.**

Only available when `logging.level: "debug"` — sidebar link, page, and all `/api/debug/*` endpoints return 404 otherwise.

#### New files
- `internal/web/logbuffer.go` — Ring buffer (1000 entries) implementing `io.Writer` for capturing log output. Parses Go standard log format (with/without `Lshortfile`), extracts level/source/timestamp. Supports filtered retrieval by level and timestamp.
- `internal/web/handler_debug.go` — Debug page handler + 20 API endpoint handlers organized in 8 sections. `DebugCallbacks` struct (6 fields) for wiring main.go closures.
- `internal/web/templates/debug.html` — Full debug dashboard template with 8 collapsible sections, complete JS framework (lazy-load, polling, action buttons, log viewer with filter/auto-refresh).

#### Debug page sections
1. **Rendszer diagnosztika** — Diagnostic dump (migrated from `api/router.go`) with structured UI rendering: controller info, storage paths, deployed stacks, scheduler jobs, alerts. JSON download button.
2. **Értesítés teszt** — Send test events with configurable type/severity, view event history ring buffer (last 50 events, newest first).
3. **Mentés teszt** — Trigger individual backup phases: full backup, DB dump only, cross-drive only, restic integrity check, infrastructure backup.
4. **Tárhely teszt** — Storage watchdog status table with per-path probe state. Simulate disconnect (stops apps, marks disconnected, skips unmount) and reconnect (cleans locks, clears state). 5s auto-refresh.
5. **Hub & Kapcsolatok** — Hub report push, infra backup push, Hub/Gitea connectivity tests with latency, preference sync.
6. **Önfrissítés teszt** — Version check + dry-run (shows current/new image lines, compose writability, backup status).
7. **DR / Telepítő varázsló** — Infra backup status per drive (files, timestamps). "RESET" confirmation + infra backup pre-check before triggering setup mode via marker file.
8. **Naplóviewer** — In-memory log viewer with level filter (DEBUG/INFO/WARN/ERROR), 2s auto-refresh, color-coded entries, clear display.

#### Module additions
- `notify/notifier.go`: `PushTestEventSync()` (synchronous, returns Hub status), `GetEventHistory()` (ring buffer), `recordHistory()` for debug page.
- `backup/crossdrive.go`: `RunAllConfigured()` — runs all enabled apps ignoring schedule filter.
- `selfupdate/updater.go`: `DryRun()` — checks update availability, compose writability, backup status without performing changes.
- `monitor/watchdog.go`: `SimulateDisconnect()` / `SimulateReconnect()` with `simulatedPaths` map, `GetDebugStatus()` for per-path probe state. Watchdog `Check()` skips simulated paths.
- `setup/setup.go`: `NeedsSetup()` now checks `.needs-setup` marker file. `ClearSetupMarker()` for cleanup.

#### Routing changes
- **Mux carve-out**: `/api/debug/` routes to web server (same pattern as `/api/storage/`), with auth + CSRF.
- **Removed** `SetDebugDumpDeps()` from `api/router.go` and the `/api/debug/dump` route — dump handler migrated to `handler_debug.go` using Server's existing fields.

#### Infrastructure
- `setupLogger()` now returns `(*log.Logger, *web.LogBuffer)`. In debug mode, creates `io.MultiWriter(os.Stdout, logBuffer)` so all log output is captured from the start.
- Debug CSS: ~170 lines of styles for sections, result badges, log viewer, confirm input, danger button, spinner.

### v0.24.0 — Pre-Testing Observability (2026-02-21)

**Three features for pre-testing diagnostics: verbose debug logging, diagnostic dump endpoint, and startup self-test.**

#### Feature 1: Debug logging across all modules

All `[DEBUG]` log lines are gated behind `logging.level: "debug"` — zero overhead at `info` level.

- **New** `internal/util/strings.go`: shared `TruncateStr()` for safely truncating command output in logs.
- **Backup** (`backup.go`, `dbdump.go`, `crossdrive.go`, `restore.go`, `local_infra.go`): added `isDebug()` method and per-operation debug logging. DB dump logs container discovery, per-dump command details (passwords masked as `***`), validation results. Cross-drive logs source/dest paths, rsync results, auto-enable decisions. Restore logs step-by-step progress.
- **Storage** (`scan_linux.go`, `format_linux.go`, `attach_linux.go`, `migrate.go`): added `Logger`/`Debug` fields to request structs. Logs raw lsblk output (truncated), per-disk classification, pipeline steps for format/attach, rsync progress for migrate. Updated `*_other.go` stubs.
- **Sync** (`sync.go`): logs masked clone URLs, per-file hash comparison, post-sync hook triggers.
- **Self-update** (`updater.go`): logs registry API calls, tag parsing, version comparison, compose file edits.
- **Monitor** (`watchdog.go`): smart logging — periodic 60-probe summaries (~5 min), immediate log on unexpected failures, reconnect attempt details. (`healthcheck.go`): logs raw check values and per-check results.
- **Notify** (`notifier.go`): logs event push URL/type/response, preference sync details.
- **Report** (`pusher.go`, `builder.go`): logs payload sizes, section summaries, push responses.
- **Assets** (`syncer.go`): logs manifest fetch, per-file hash comparison, download/removal actions.
- **Setup** (`scanner.go`, `handlers.go`): logs drive scan details, hub recovery/config write operations.

#### Feature 2: Diagnostic dump endpoint (`GET /api/debug/dump`)

Returns a comprehensive JSON snapshot of all controller state. Only available when `logging.level: "debug"` — returns 404 otherwise.

- Sections: `controller` (version, uptime, config hash, PID), `storage` (per-path usage), `stacks` (deployed/running/stopped counts + list), `backup` (status, repo stats), `hub` (push status, consecutive failures), `scheduler` (all jobs with last_run/running/errors), `health` (fresh check), `notifications`, `self_update`, `alerts`.
- API router expanded with `SetDebugDumpDeps()` setter for scheduler, hub pusher, alert manager, version, and start time.

#### Feature 3: Startup self-test

- **New** `internal/selftest/selftest.go`: runs 9 diagnostic checks on boot with 5s timeout each.
- Checks: Docker socket, stacks directory, data directory (write test), system data path (mount point), storage paths (connected vs disconnected), git catalog (.felhom.yml files), Hub connectivity (/healthz), restic repos, metrics DB.
- Results logged in a clear block: `[PASS]`/`[WARN]`/`[FAIL]` per check, summary at end.
- Self-test summary (pass/warn/fail counts) sent to Hub via `NotifyControllerStarted` details map.
- Never blocks startup — purely diagnostic.

#### Constructor/signature changes

- `notify.New()`: added `debug bool` param. `NotifyControllerStarted()`: added `details map[string]interface{}` param.
- `report.NewPusher()`: added `debug bool` param. `BuildReport()`: added `logger *log.Logger` param.
- `monitor.RunHealthCheck()`: added `logger *log.Logger` param (5 call sites in main.go).
- `selfupdate.NewUpdater()`: added `debug bool` param.
- `assets.New()`: added `debug bool` param.
- `backup.NewCrossDriveRunner()`: added `debug bool` param. `WriteLocalInfraBackup()`: added `debug bool` param.
- `backup.DiscoverDatabases()`, `DumpOne()`: added `debug bool` param.
- `storage.ScanDisks()`: added `logger, debug` params. `FormatRequest`, `AttachRequest`, `MigrateRequest`: added `Logger`/`Debug` fields.
- `setup.ScanDrivesForInfraBackups()`: added `debug bool` param.

### v0.23.0 — CSRF Protection (2026-02-21)

**CSRF (Cross-Site Request Forgery) protection on all browser-facing POST endpoints — controller and hub.**

**Controller changes:**

- New `internal/web/csrf.go`: `CsrfProtect` HTTP middleware validates CSRF tokens on all state-mutating requests (POST/DELETE/PATCH).
  - Reads token from `_csrf` form field or `X-CSRF-Token` request header.
  - Exempt paths: `Authorization: Bearer` requests (selfupdate, config/apply hub→controller calls) — browsers cannot auto-send Bearer headers, so no CSRF risk.
  - Auth-disabled mode (no password set): CSRF check is skipped entirely.
  - On rejection: JSON error for `/api/` paths, HTTP 403 text for page routes.
- `internal/web/auth.go`: `session` struct gains a `csrfToken string` field. `createSession()` generates a second 32-byte random CSRF token alongside the session token. New `csrfTokenForSession(sessionToken)` method returns the CSRF token for a given session.
- `internal/web/server.go`: New `executeTemplate(w, r, name, data)` wrapper auto-injects `CSRFField` (`template.HTML` hidden input) and `CSRFToken` (raw string) into every page render data map.
- `cmd/controller/main.go`: All route registrations wrapped with `webServer.CsrfProtect(...)` middleware. Version bumped to `v0.23.0`.
- All handlers (`handlers.go`, `storage_handlers.go`, `handler_restore.go`): Switched from `s.render(w, ...)` to `s.executeTemplate(w, r, ...)`.
- All templates updated:
  - `layout.html`: Added `<meta name="csrf-token">` and inline `csrfHeaders()` JS helper (returns `{'X-CSRF-Token': ...}`) in `<head>` (before page-specific scripts). Updated 4 fetch POST/DELETE calls.
  - `settings.html`: Added `{{$.CSRFField}}` to 5 forms inside `{{range .StoragePaths}}` (must use `$` for outer scope inside range). Added `{{.CSRFField}}` to 3 page-level forms. Inline-label form uses `document.querySelector('meta[name="csrf-token"]').content`. Updated 5 fetch calls.
  - `deploy.html`: Added `{{.CSRFField}}` to cross-backup form. Updated 3 fetch calls.
  - `backups.html`: Updated 3 fetch calls. Dynamically-created restore form injects `_csrf` from meta tag.
  - `storage_init.html`, `storage_attach.html`, `migrate.html`, `migrate_drive.html`, `app_info.html`, `restore.html`: All fetch calls updated.
  - `storage_attach.html`: Replaced `navigator.sendBeacon()` with `fetch(..., {keepalive: true})` — `sendBeacon` cannot send custom headers, making CSRF impossible.

**Hub changes (v0.3.8):**

- `internal/web/server.go`: Replaced insecure literal `hub_session=authenticated` cookie with proper server-side session map.
  - New `hubSession` struct with `csrfToken string` and `expiresAt time.Time`.
  - `sessions map[string]*hubSession` + `sessionsMu sync.RWMutex` on `Server` struct.
  - `handleLogin`: Generates cryptographically random 64-char hex session token + 64-char hex CSRF token. Cookie gains `SameSite=Lax` and `Secure` (when TLS) attributes. Session expires after 7 days.
  - `RequireAuth`: Validates session token against map (constant-time compare), redirects to `/login` on failure.
  - `CleanupSessions(ctx)`: Goroutine that purges expired sessions every hour.
  - CSRF validation block at top of `ServeHTTP`: checks `X-CSRF-Token` header or `_csrf` form field on POST/DELETE/PATCH. Skips when no session cookie (Basic Auth / API path).
  - `csrfToken(r)`, `csrfField(r)` helpers for template data injection.
- `internal/web/configs.go`: Added `html/template` import. All template render calls pass `CSRFField template.HTML` and/or `CSRFToken string`. `renderConfigForm` gains `r *http.Request` parameter.
- Templates updated:
  - `config_form.html`: Added `{{.CSRFField}}` inside the `<form>`.
  - `customer_unified.html`: Added `<meta name="csrf-token">` + inline `csrfHeaders()` in `<head>`. Added `{{.CSRFField}}` to all 5 POST forms (unblock, block, delete config, create-config, regen-password). Updated 3 JS fetch POST calls (trigger-update, push-config, pull-config).
- `cmd/hub/main.go`: Started `go webServer.CleanupSessions(ctx)` goroutine.

### v0.22.3 — Hub Asset Sync (2026-02-21)

**Hub-managed asset downloads**

- New `internal/assets` package: downloads and caches app assets (logos, screenshots) from the Hub API with SHA-256 change detection.
- Asset syncer resolves files from downloaded cache first, falls back to baked-in `/usr/share/felhom/assets/` directory.
- Config: `assets.sync_enabled: true` + `assets.sync_schedule: "05:00"` to enable daily sync.
- API: `POST /api/assets/sync` triggers on-demand sync, `GET /api/assets/status` returns sync status.
- Web server's `serveAsset()` now routes through syncer's `Resolve()` when available.

### v0.22.2 — Setup Logo Fix (2026-02-21)

- **Fix setup wizard logo**: Logo failed to load because `handleLogo()` tried to read it as a file from the filesystem, but it only exists as an embedded string constant. Now imports and serves `web.FelhomLogoSVG` directly.

### v0.22.1 — Setup Wizard Bugfixes (2026-02-21)

- **Fix setup mode detection**: Remove `demo-felhom` from `NeedsSetup()` check — only empty `customer.id` triggers setup mode. Previously the demo customer was stuck in setup mode.
- **Fix CSRF nil pointer panic**: `renderError()` was passing `nil` instead of `*http.Request` to `ensureCSRFToken()`, causing panic when rendering error pages.
- **Fix double-v version display**: Welcome page showed "vv0.22.0" — removed redundant `v` prefix from template.
- **Fix IP detection in Docker**: Setup wizard showed container bridge IP (172.x) instead of host LAN IP. Now reads `HOST_IP` env var (set by docker-setup.sh).
- **Add Hub download logging**: Log Hub config download attempts and errors for easier debugging.
- **docker-setup.sh**: Inject `HOST_IP` env var into generated docker-compose.yml.

### v0.22.0 — First-Run Setup Wizard & Local Infra Backup (2026-02-21)

Major feature release: moves ALL initial configuration and disaster recovery setup from `docker-setup.sh` into the controller itself as a web-based wizard.

**Setup Wizard (`internal/setup/`):**
- New web-based setup wizard replaces interactive CLI wizard from `docker-setup.sh`
- Dual listener: `:8080` (behind Traefik) + `:8081` (direct HTTP for LAN access before DNS is configured)
- Setup mode detection: controller enters wizard when `customer.id` is empty
- Two paths: "Restore from backup" (local drive scan + Hub recovery) and "Fresh install" (Hub download or manual config)
- Drive scanner: detects `.felhom-infra-backup/` on all connected drives, validates checksums
- Hub recovery: `GET /api/v1/recovery/{id}` with retrieval password auth — returns combined config + infra backup
- CSRF protection (cookie + hidden field) for all wizard POST endpoints
- State persistence (`setup-state.json`) survives browser crashes
- All UI text in Hungarian, uses existing dark theme CSS
- After setup: writes `controller.yaml`, creates `settings.json`, `os.Exit(0)` → Docker restart into normal mode

**Local Infra Backup (`internal/backup/local_infra.go`):**
- Writes infrastructure backup to all connected drives as `.felhom-infra-backup/backup.json` + `metadata.json`
- Schema-versioned with SHA256 checksum validation
- Runs on startup and after each nightly backup cycle
- Enables disaster recovery without Hub connectivity — any drive can bootstrap a new controller

**Hub Verification:**
- Pusher parses Hub report response for `customer_blocked` field
- Updates `hub_verified` / `hub_verified_at` in settings on each successful push
- `IsLimitedMode()` checks verification state + 7-day grace period

**Recovery Info:**
- New `internal/recovery/` package generates `recovery-info.txt` in data directory
- Settings page shows recovery info section (customer ID, Hub URL, masked retrieval password)
- Recovery file auto-regenerated on each startup when retrieval password is set

**Pending Events:**
- New `PendingEvent` type in settings with `AddPendingEvent()` / `DrainPendingEvents()`
- Events queued during setup (e.g., DR completed) are drained and pushed to Hub on first successful report push

**Config & Settings Schema:**
- `config.go`: Added `SetupListen` field (default `:8081`), `LoadPermissive()`, `Default()`
- `settings.go`: Added `hub_verified`, `hub_verified_at`, `retrieval_password`, `pending_events` fields with RWMutex accessors

**Infrastructure:**
- `docker-compose.yml`: Added port `8081:8081` mapping for setup wizard
- Removed old fresh-deployment auto-restore code from `main.go` (lines 70-141)
- Removed `restoreSettingsFromHub()` and `restorePasswordsFromHub()` helpers

### v0.21.3 — Config Apply Infra Push + Fixes (2026-02-20)
- **Push infra backup after config apply**: After a successful `POST /api/config/apply`, the controller immediately pushes an infra backup to the Hub so the config sync status updates right away.
- **Fix double "v" prefix in startup event**: "Controller elindult (vv0.21.2)" → "Controller elindult (v0.21.3)".

### v0.21.2 — Config Apply Bind Mount Fix (2026-02-20)
- **Fix config apply on Docker bind mounts**: `POST /api/config/apply` failed with "device or resource busy" because `os.Rename()` doesn't work on bind-mounted files. Now falls back to direct write when rename fails.

### v0.21.1 — Config Content Endpoint (2026-02-20)
- **`GET /api/config`**: New endpoint returning raw controller.yaml content (text/yaml). Used by Hub for live config diff and pull operations. Same auth as other config endpoints (Bearer token or session cookie).

### What was just completed (2026-02-20 session 64)
- **v0.21.0 — Hub Monitoring Takeover (Controller-side, Phases 5+6):**

  Replaces external Healthchecks.io dependency with Hub-native event system. The controller now pushes structured events directly to the Hub's `/api/v1/event` endpoint, and the Hub handles dead man's switch detection, notification dispatch, and cooldown management.

  **Phase 5 — Event Push System (`internal/notify/notifier.go`):**
  - New core method `PushEvent(eventType, severity, message, details)` — non-blocking goroutine, 2 retries with 3s backoff, POSTs to Hub `/api/v1/event`
  - 8 typed detail structs: `BackupDetails`, `DBDumpDetails`, `DiskDetails`, `HealthDetails`, `StorageDetails`, `UpdateDetails`, `AppDetails`, `CrossDriveDetails`
  - Replaced all old `Notify*` methods with event-based equivalents:
    - `NotifyBackupCompleted/Failed` → `backup_completed`/`backup_failed` events
    - `NotifyDBDumpCompleted/Failed` → `db_dump_completed`/`db_dump_failed` events
    - `NotifyIntegrityOK/Failed` → `backup_integrity_ok`/`backup_integrity_failed` events
    - `NotifyHealthChange` → detects transitions, pushes `health_degraded`/`health_critical`/`health_recovered`
    - `NotifyStorageDisconnected/Reconnected` → `storage_disconnected`/`storage_reconnected` events
    - `NotifyControllerStarted` → `controller_started` event on startup
    - `NotifyControllerUpdated` → `controller_updated` event (replaces `NotifyUpdateSuccess/Failed`)
    - `NotifyAppDeployed/Removed` → `app_deployed`/`app_removed` events
    - `NotifyCrossDriveCompleted/Failed` → `crossdrive_completed`/`crossdrive_failed` events
    - `NotifyDRStarted/Completed` → `disaster_recovery_started`/`disaster_recovery_completed` events
  - Removed old `/api/v1/notify` relay, `classifyWarning()`, and client-side cooldown logic (Hub handles cooldowns now)
  - `SendTest()` now pushes `test` event type via `PushEvent`
  - `SyncPreferences` updated to include `cooldownHours` parameter

  **Phase 5 — Event Wiring:**
  - `main.go`: Wired success events for backup, db-dump, integrity check; startup event with 5s delay; update event after `VerifyStartup()`
  - `router.go`: Added `NotifyAppDeployed`/`NotifyAppRemoved` after successful deploy/remove via API
  - `handler_restore.go`: Added `NotifyDRStarted`/`NotifyDRCompleted` in DR restore flow
  - `server.go`: New `HubPushStatusData` struct and `SetHubPushStatus` callback for monitoring page

  **Phase 5 — Hub Connection Monitoring:**
  - `pusher.go`: Added `PushStatus` tracking (LastAttempt, LastSuccess, LastError, Consecutive failures) to report Pusher
  - `handlers.go`: Monitoring page now shows Hub connection status (connected/unreachable, URL, customer ID, last success, last error) instead of Healthchecks ping UUIDs
  - `monitoring.html`: Replaced "Távoli monitoring" section with "Hub kapcsolat" section
  - `alerts.go`: Replaced "Missing ping UUIDs" alert with Hub connection alerts (`hub-disabled` warning, `hub-unreachable` error)

  **Phase 5 — Expanded Notification Settings:**
  - `settings.html`: Expanded from 4 checkboxes to 11 grouped toggles in two categories:
    - "Hibák és figyelmeztetések": backup_failed, db_dump_failed, backup_integrity_failed, crossdrive_failed, disk alerts, storage_disconnected, node_down, health_critical, expected missed
    - "Tájékoztató": storage_reconnected, health_recovered
  - Compound toggles: "Lemez figyelmeztetés" maps to `disk_warning` + `disk_critical`; "Elvárt mentés elmaradt" maps to `expected_backup_missed` + `expected_dbdump_missed`
  - `settings.go`: Updated `DefaultEnabledEvents` to new Hub event types
  - `handlers.go`: Updated settings POST handler for expanded event names and compound toggles

  **Phase 6 — Config Cleanup:**
  - `main.go`: Deprecation log on startup when ping UUIDs are configured: `[INFO] Healthchecks ping UUIDs configured but no longer used — monitoring is now handled by the Hub`
  - Pinger still runs for transitional backward compatibility

### What was just completed (2026-02-20 session 63)
- **v0.20.0 — Hub Config Management (Phase B):**

  Two new features enabling the Hub to manage and compare controller configuration remotely.

  **Feature A — Config Apply Endpoint:**
  - `router.go`: Added `POST /api/config/apply` — accepts YAML body from Hub, validates it's parseable via `config.LoadFromBytes()`, writes atomically to controller.yaml (`.tmp` + `os.Rename`), returns success JSON. Restart required to apply.
  - `router.go`: Added `GET /api/config/hash` — returns SHA256 hex digest of current controller.yaml
  - `router.go`: Router struct gained `configPath string` field; `NewRouter()` signature updated
  - `config.go`: Added `LoadFromBytes([]byte)` — parses YAML without file I/O (for validation)
  - `config.go`: Added `FileHash(path)` — SHA256 hex digest helper
  - `main.go`: Config endpoints use same dual auth middleware as self-update (session OR Hub API key Bearer token)
  - `main.go`: Added `/api/config/` mux entry with `selfUpdateAuthMiddleware`

  **Feature B — Config Hash in Reports:**
  - `types.go`: Added `ConfigHash string` field to `Report` struct (JSON: `config_hash`)
  - `builder.go`: `BuildReport()` now accepts `configPath string` parameter, computes SHA256 of controller.yaml and includes it in every report
  - `main.go`: All 4 `BuildReport()` call sites updated to pass `*configPath`
  - Hub uses this hash to compare against its generated YAML — shows "In sync" / "Config mismatch" / "Unknown" on the unified customer detail page

### What was just completed (2026-02-20 session 62)
- **docker-setup.sh — Hub Config Download:**
  - Added `--hub-customer` and `--hub-password` CLI flags for downloading pre-configured controller.yaml from Felhom Hub
  - Added `HUB_URL` global variable (default: `https://hub.felhom.eu`)
  - Hub download logic at start of `run_config_wizard()`: downloads YAML via `curl` with `X-Retrieval-Password` header, validates response, extracts key variables (domain, CF tokens, email), sets global variables for subsequent setup steps
  - Falls back to interactive wizard if download fails or credentials not provided

### What was just completed (2026-02-20 session 61)
- **v0.19.0 — Deployed App Removal + Missing Field Injection:**

  Two new features: "Eltávolítás" (Remove) action for deployed stacks and automatic missing deploy field injection on template updates.

  **Feature A — Deployed App Removal ("Eltávolítás"):**
  - `delete.go`: Added `RemoveStack()` — removes deployed (non-orphaned) stack: `docker compose down --volumes`, optional HDD data cleanup, optional backup data cleanup (DB dumps + cross-drive rsync), removes `app.yaml` only (template files preserved for redeploy); stack reverts to "Nincs telepítve" state
  - `delete.go`: Added `GetStackBackupData()` — returns backup path info (DB dump dir + cross-drive rsync dir) with sizes and existence status
  - `delete.go`: Added `RemoveResponse`, `BackupDataResponse` structs, `buildPathInfo()` helper
  - `router.go`: Added `POST /api/stacks/{name}/remove` endpoint — accepts `{remove_hdd_data, remove_backups}`, computes backup paths via `AppDBDumpPath()`/`AppSecondaryRsyncPath()`, cleans cross-drive config on success
  - `router.go`: Added `GET /api/stacks/{name}/backup-data` endpoint — returns backup data paths with sizes
  - `crossdrive.go`: Made `getAppDrivePath` → `GetAppDrivePath` (public) for use by router
  - `stacks.html`: Added "Eltávolítás" button for stopped, deployed, non-orphaned, non-protected stacks
  - `dashboard.html`: Same button in compact card layout
  - `layout.html`: Added `removeStack()` modal — fetches HDD + backup data in parallel, 3-section layout (always removed / HDD data with checkbox / backup data with checkbox), reimport warning for preserved HDD data, restic retention note
  - `layout.html`: Added `confirmRemoveStack()` — POST to `/remove`, shows result summary with removed/preserved paths

  **Feature B — Missing Deploy Field Injection:**
  - `deploy.go`: Added `InjectMissingFields(stackNames)` — iterates deployed stacks, compares `.felhom.yml` deploy_fields against `app.yaml` env vars, auto-generates values for missing `secret` (using generator spec) and `domain` fields, saves updated `app.yaml`
  - `deploy.go`: Added `base64key` generator type — produces `base64:<N random bytes base64-encoded>` (for Laravel APP_KEY and similar)
  - `deploy.go`: Added `containsStr()` helper
  - `manager.go`: Added `DeployedStackNames()` — returns names of all deployed stacks
  - `sync.go`: Added `postSyncHook func(updated []string)` field to `Syncer`; `New()` accepts optional hook; hook called in `doSync()` after rescan with names of updated stacks
  - `main.go`: Wired injection on startup (all deployed stacks) and after sync (updated stacks only)

### v0.18.0 (2026-02-19 session 60)
- **v0.18.0 — Drive Migration & Tier 2 Restic Deprecation:**

  Full drive replacement workflow with decommissioned state, enhanced per-app migration with backup awareness, and deprecation of restic as a Tier 2 cross-drive backup method (rsync only).

  **Phase 1 — Restic Tier 2 Deprecation:**
  - `settings.go`: Auto-migrate restic→rsync on startup via `migrateResticToRsync()` in `Load()`
  - `crossdrive.go`: Removed `runResticBackup()`, `pruneResticRepo()`, `ensureResticRepo()`; `RunAppBackup()` calls rsync directly
  - `backup.go`: Removed Tier 2 secondary restic scanning from `ListAllSnapshots()`
  - `settings.go`: Removed cross-drive restic password methods (`GetOrCreateCrossDrivePassword`, etc.)
  - `deploy.html`: Removed method dropdown (rsync/restic selector)
  - `handlers.go`: Simplified `Tier2DriveGroup` (flat `Items` list), removed method handling from `settingsCrossBackupHandler()`
  - `backups.html`: Removed method split in Tier 2 details section
  - `router.go`: Always set method to "rsync" in cross-backup API
  - `infra_backup.go`: Removed cross-drive password block from `CollectInfraBackup()`
  - `main.go`: Removed `SetCrossDriveResticPassword` restore block

  **Phase 2 — Enhanced Per-App Migration:**
  - `backup.go`: Extracted `backupDrive()` from `runBackupInternal()` loop; added `TryRunDriveBackup()` with non-blocking lock
  - `crossdrive.go`: Added `AnyRunning()` method
  - `migrate.go`: Added `BackupTrigger` interface, `MigrateOrchestrator`, `RunEnhancedMigration()` with post-migration steps (DB dump copy, Tier 2 conflict clearing, auto-delete stale data, immediate Tier 1 backup)
  - `storage_handlers.go`: Wired orchestrator into migration handler with `auto_delete_stale` support
  - `migrate.html`: Added auto-delete checkbox, "cleaning" + "backing_up" progress steps

  **Phase 3 — Full Drive Migration:**
  - `settings.go`: Added `Decommissioned`/`DecommissionedAt`/`MigratedTo` fields to `StoragePath`; added `SetDecommissioned()`, `ClearDecommissioned()`, `IsDecommissioned()`, `GetDecommissionedPaths()`, `GetStorageLabel()`; `GetConnectedPaths()`/`GetSchedulableStoragePaths()` exclude decommissioned
  - `migrate_drive.go` (NEW): `DriveMigrator` with `MigrateDrive()` 10-step flow (validate→stop→rsync→verify→configure→decommission→Tier2→start→backup→notify), `migrationTx` rollback pattern, excludes restic repos from rsync
  - `settings.html`: Decommissioned card variant with "Kiváltva" badge, "Összes adat átköltöztetése" button on connected cards
  - `migrate_drive.html` (NEW): Drive migration wizard (form + progress + done cards)
  - `storage_handlers.go`: Added `/api/storage/migrate-drive`, `/api/storage/migrate-drive/status`, `/api/storage/decommission/remove` endpoints
  - `server.go`: Added `/settings/storage/migrate-drive` route, `SetDriveMigrator()` setter
  - `watchdog.go`: Skip decommissioned drives in `Check()`; block `SafeDisconnect()` for decommissioned
  - `healthcheck.go`: Skip decommissioned paths in `checkStoragePaths()`
  - `backup.go`: Skip decommissioned drives in `backupDrive()`/`runDBDumpsInternal()`; added `MigrationActiveCheck` callback to skip nightly backup during migration
  - `crossdrive.go`: Reject decommissioned destinations in `ValidateDestination()`; skip decommissioned paths in `AutoEnableSmallApps()`
  - `handlers.go`: Skip decommissioned drives in `buildStorageBars()`; made `SyncFileBrowserMounts()` public
  - `main.go`: Added `driveMigrateStackAdapter`, wired `DriveMigrator` with all dependencies

  **Phase 4 — Hub Changes:**
  - `report/types.go`: Added `Decommissioned`/`MigratedTo` fields to `StorageReport`
  - `report/builder.go`: Include decommissioned drives in report with flag

  **Files modified:** 21 files modified + 2 new files (`migrate_drive.go`, `migrate_drive.html`).

### What was just completed (2026-02-19 session 59)
- **v0.16.1 + hub v0.1.8 — Hub Update Trigger + Controller URL Reporting:**

  Controller now includes its external URL (`controller_url`) in periodic hub reports so the hub can trigger self-updates remotely. Hub tracks the URL in a new `controller_url` DB column, checks the Gitea registry for the latest controller image version (VersionChecker goroutine, `web/version.go`), and shows a "Controller Update" card on the customer detail page.

  **Controller (v0.16.1):**
  - `internal/report/types.go`: Added `ControllerURL string` field to Report struct.
  - `internal/report/builder.go`: Sets `ControllerURL` from `cfg.Customer.Domain` → `https://felhom.<domain>`.
  - `internal/api/router.go`: **Bug fix** — moved selfupdate routes to before `hasSuffix(path, "/update")` stack case (which was catching `/selfupdate/update` first).

  **Hub (v0.1.8):**
  - `cmd/hub/main.go`: Added `Registry` config section + defaults; creates `VersionChecker` goroutine if credentials configured; passes `apiKey` to `web.New()`.
  - `internal/store/store.go`: Added `ControllerURL` to `CustomerSummary`; idempotent `ALTER TABLE reports ADD COLUMN controller_url TEXT` migration; updated `SaveReport`, `GetCustomers`, `GetCustomer`, `GetCustomerHistory` queries.
  - `internal/web/version.go` (NEW): `VersionChecker` type — polls Gitea Docker Registry V2 API (`/v2/<owner>/<repo>/tags/list`) every 6h; parses semver tags; stores latest version thread-safely.
  - `internal/web/server.go`: Added `apiKey`, `versionChecker` fields; updated `New()` signature; added `SetVersionChecker()`; added `handleTriggerUpdate` handler that proxies POST to controller's `/api/selfupdate/update`; added trigger-update route (before `/customers/` catch-all); updated `handleCustomerDetail` with `ControllerURL`, `LatestVersion`, `UpdateAvailable` template data; added `compareVersions` helper.
  - `internal/web/templates/customer.html`: New "Controller Update" section between Health and Notifications — shows current/latest version with update indicator, controller URL link, and conditional "Trigger Update" button with JS.
  - `internal/api/handler.go`: Added `ControllerURL` to `/api/v1/customers` JSON response.
  - Hub config (`hub.yaml`): Added `registry:` section with Gitea admin credentials.

  **Files modified/created:** controller: 3 files; hub: 5 modified + 1 created (version.go).

### What was just completed (2026-02-19 session 58)
- **v0.16.0 — Controller Self-Update:**

  Watchtower-style self-update mechanism. New package `internal/selfupdate/` with 3 files: `version.go` (semver parsing/comparison), `state.go` (audit log state file I/O), `updater.go` (registry check via Gitea V2 API, update trigger, startup verification).

  **Flow:** Gitea registry tag list → `docker pull` → atomic compose file rewrite → `docker compose up -d` → process replaced. State file (`update-state.json`) persists across restart as audit log; verified on next startup to detect success/failure.

  **Config:** `SelfUpdateConfig` extended with `AutoUpdateTime` field + defaults for `Image` and `AutoUpdateTime`. Scheduler jobs: periodic check every `check_interval` (default 6h); optional daily auto-update at `auto_update_time` (default 04:30).

  **API:** 3 new endpoints under `/api/selfupdate/` (`status`, `check`, `update`). Auth via session cookie OR `Authorization: Bearer <hub_api_key>` header (for external triggering from build scripts).

  **UI:** Settings page "Verzió és frissítés" card shows current/latest version, check time, auto-update status, last update result. "Frissítés keresése" button queries registry; "Frissítés telepítése" button appears when update is available. `pollUntilBack()` JS polls `/api/health` after triggering update and reloads when container is back up.

  **Notifications:** `NotifyUpdateSuccess()` and `NotifyUpdateFailed()` added to notifier for post-update startup verification results.

  **Alert:** Dashboard shows "Új controller verzió elérhető" info alert when update is available.

  **docker-compose.yml:** Added `/opt/docker/felhom-controller:/opt/docker/felhom-controller` directory bind mount (required for compose file access during self-update); named volume and read-only config override on top.

  **Files modified/created (12):** `internal/selfupdate/version.go` (NEW), `internal/selfupdate/state.go` (NEW), `internal/selfupdate/updater.go` (NEW), `internal/config/config.go`, `internal/notify/notifier.go`, `internal/api/router.go`, `internal/web/server.go`, `internal/web/handlers.go`, `internal/web/alerts.go`, `internal/web/templates/settings.html`, `cmd/controller/main.go`, `docker-compose.yml`

### What was just completed (2026-02-19 session 57)
- **v0.15.7 — Fix backup page storage display & rename system drive label:**

  Backup page ("Biztonsági mentés") now shows all registered storage paths instead of only a single "Külső HDD". Added `data["StorageBars"] = s.buildStorageBars()` to `backupsHandler` (was missing unlike dashboard/monitoring handlers). Updated `backups.html` storage bars section to use `StorageBars` loop (same pattern as monitoring page), replacing the old `{{if .HDDConfigured}}` single-HDD block.

  Renamed system root partition label from "SSD (/)" to "Rendszer (/)" on all three pages (backup, monitoring, dashboard), as the root filesystem is not necessarily on an SSD.

  **Files modified (4):** `internal/web/handlers.go`, `internal/web/templates/backups.html`, `internal/web/templates/monitoring.html`, `internal/web/templates/dashboard.html`

### What was just completed (2026-02-19 session 56)
- **v0.15.6 (controller) + hub v0.1.7 — Bug hunt fixes (BUGHUNT.md):**

  **Controller — Restore race conditions (P0-P1):** All 4 restore handlers (`restorePageHandler`, `apiRestoreStatus`, `apiRestoreAll`, `apiRestoreSkip`) now hold `restoreMu.RLock()` across nil-check and field reads. `apiRestoreAll` uses new `TryStartRestore()` method for atomic check-and-set (eliminates double-restore race). `executeAllRestores()` snapshots plan under lock, uses `SetStatus("done")` instead of direct write. Removed dead no-op goroutine.

  **Controller — restore_scan.go:** `dirIsEmpty()` now returns `false` on read errors (was silently treating unreadable dirs as empty, losing backup data). `Snapshot()` deep-copies Apps and Drives slices. Added `TryStartRestore()`, `SetStatus()`, `GetStatus()` helper methods.

  **Controller — infra_backup.go (P0):** `controller.yaml` read failure now returns a real error (was silently creating empty backup). `settings.json` and restic password read failures now logged. Added `logger *log.Logger` parameter to `BuildInfraBackup`.

  **Controller — main.go DR wiring:** Fixed ordering — `restoreSettingsFromHub` + settings reload now happens before `restorePasswordsFromHub` (prevents cross-drive password loss). Nil check after `ScanDrivesForBackups`. `os.MkdirAll` error now logged. `os.MkdirAll` added to `restoreSettingsFromHub` before write.

  **Hub — store.go (P2):** 5 `json.Unmarshal` calls now log `[WARN]` on failure. `GetInfraBackupMeta` logs unmarshal error instead of silently returning wrong counts.

  **docker-setup.sh (P0-P2):** DRY_RUN check moved to top of `run_config_wizard()` with dummy values (was prompting interactively even in dry-run). CF tunnel token quoted in docker-compose env. `htpasswd` uses `cut -d: -f2` + bcrypt format validation. `grep -qF` for literal path matching. Volume paths quoted in YAML output. Post-wizard validation rejects default `demo-felhom`/`homeserver.local` values.

  **restore.html (P2-P3):** Error text uses `textContent` instead of `innerHTML`. Poll errors counted; after 10 failures shows "Kapcsolat megszakadt" message instead of polling silently forever.

  **Files modified (controller, 6):** `internal/backup/restore_scan.go`, `internal/web/handler_restore.go`, `internal/report/infra_backup.go`, `cmd/controller/main.go`, `internal/web/templates/restore.html`, `scripts/docker-setup.sh`
  **Files modified (hub, 1):** `hub/internal/store/store.go`

### What was just completed (2026-02-19 session 55)
- **v0.15.5 — Fix startup hub report silently failing:**

  `Push()` now returns actual errors instead of always `nil`. Previously, push failures were logged internally but the caller could never detect them, leading to a misleading `[INFO] Startup hub report sent` log even when the push actually failed (e.g., hub returning HTTP 503 during simultaneous deployment). Removed the "Never returns error to caller" behavior: marshal error returns a wrapped error, and after 3 failed retries the error is returned to the caller (the internal `[WARN]` log before `return nil` is gone).

  Startup hub push now retries 3 times with 15-second delays between outer attempts, giving the hub time to come up when both are deployed together. Each outer attempt uses `Push()`'s own internal 3-retry logic (5s backoff), so the hub gets up to ~40s total to become ready. If all 3 outer attempts fail, logs a clear warning with the next scheduled push interval.

  **Files modified (2):** `internal/report/pusher.go`, `cmd/controller/main.go`

### What was just completed (2026-02-19 session 54)
- **v0.15.4 (controller) + hub v0.1.6 — Hub reporting improvements:**

  **Controller:** When `hub.enabled: false` but URL+API key are configured, the controller now creates the `Pusher` and sends a one-time "disabled" notification on startup (`health.status = "disabled"`, `reporting_disabled: true`). This replaces the old behavior where a disabled controller was indistinguishable from a crashed node. Added `PushOnce()` method to `Pusher` (bypasses the `enabled` flag). Added `ReportingDisabled` field to the `Report` struct.

  **Hub:** Added "disabled" status handling — when the latest report has `health_status = "disabled"`, the overall status is "disabled" (checked BEFORE the stale-time logic, so it stays "PAUSED" even after 30min+). Dashboard shows gray "PAUSED" badge. Customer detail shows "Reporting has been disabled on this node" with a hint to re-enable. Storage labels now shown (`label` field with fallback to `mount`). Report history timestamps now show date + time ("Feb 19 09:46" instead of "09:46:54"). New `.status-badge-disabled` CSS (neutral gray `#475569`).

  **Files modified (controller):** `internal/report/types.go`, `internal/report/pusher.go`, `cmd/controller/main.go`
  **Files modified (hub):** `hub/internal/web/server.go`, `hub/internal/web/templates/dashboard.html`, `hub/internal/web/templates/customer.html`, `hub/internal/web/templates/style.css`

### What was just completed (2026-02-19 session 53)
- **v0.15.3 — Show all storage paths on dashboard + fix hub report:**

  Dashboard ("Vezérlőpult") and monitoring ("Rendszermonitor") pages now show usage bars for ALL registered storage paths instead of just one hardcoded "Külső HDD" bar. New `StorageBarInfo` type and `buildStorageBars()` helper build bars from `settings.GetStoragePaths()`. Each bar shows the storage label and live disk usage.

  Hub storage report now correctly includes all registered storage paths with proper mount paths and labels. Previously it sent only root `/` plus one HDD entry using the deprecated (empty) `cfg.Paths.HDDPath`. Now uses `system.GetDiskUsage()` per storage path, same as the dashboard bars. Added `Label` field to `StorageReport` in `types.go`.

  **Files modified (5):** `internal/web/handlers.go`, `internal/web/templates/dashboard.html`, `internal/web/templates/monitoring.html`, `internal/report/builder.go`, `internal/report/types.go`

### What was just completed (2026-02-19 session 52)
- **v0.15.2 — Fix data loss on container restart (2 bugs):**

  **Bug 1:** Snapshot history delta stats (HOZZÁADOTT, ÚJ FÁJL, VÁLTOZOTT) showed 0 after container restart because restic doesn't store these stats — they were only in memory. Fixed by persisting the snapshot history ring buffer to `data/snapshot-history.json`. On startup, persisted stats are merged with restic repo snapshots. Added `saveSnapshotHistory()` (atomic write via tmp+rename), `loadSnapshotHistoryFromFile()`, updated `appendSnapshotRecord()` to save after each backup, and updated `LoadSnapshotHistory()` to merge persisted + restic data.

  **Bug 2:** DB validation (ÉRVÉNYESÍTÉS column) showed "–" after restart because the synthesized `LastDBDump.Results` didn't copy `Validation` from `DumpFileInfo`. One-line fix: added `Validation: f.Validation` to the synthesized `DumpResult` in `GetFullStatus()`.

  **Files modified:** `internal/backup/backup.go`

### What was just completed (2026-02-19 session 51)
- **v0.15.1 — Backup Page "Részletek" Overhaul:**

  Replaced the "Tároló" section on the backup page with a new "Részletek" section containing 3 collapsible tier sections with per-drive breakdowns.

  **Tier 1 (Helyi mentés):** Shows per-drive restic repo stats (size, snapshot count) with storage labels. Includes aggregated totals when multiple drives exist, plus DB dump summary, integrity check, and encryption key (all carried over).

  **Tier 2 (Másodlagos másolat):** Groups cross-drive backup items by destination drive, separated into restic and rsync method sections with per-app sizes.

  **Tier 3 (Távoli mentés):** Placeholder for future B2/S3/SFTP remote backup.

  **Restore UI improvements:** Snapshot dropdown now groups by tier (optgroup), shows tier label + drive name per snapshot (e.g., "1. szint, hdd_1"), and marks Tier 1 as recommended. Also lists Tier 2 (secondary restic) snapshots for visibility.

  **Backend:** New `DriveRepoInfo` struct, `perDriveRepoStats()` method, `ListAllSnapshots()` that includes secondary restic repos, and `Tier2DriveGroup` handler struct. `SnapshotInfo` now carries `Tier` and `DriveLabel` fields.

  **Files modified (5):** `internal/backup/backup.go`, `internal/backup/restic.go`, `internal/web/handlers.go`, `internal/api/router.go`, `internal/web/templates/backups.html`, `internal/web/templates/style.css`

### What was just completed (2026-02-18 session 50)
- **v0.15.0 — Attach Existing Drive (bind mount wizard):**

  New feature: Settings → "Meglévő meghajtó csatolása" wizard. Allows attaching a drive that already has a filesystem (ext4, etc.) without formatting. Solves the real-world scenario where a customer's drive contains existing data that must be preserved.

  **How it works:** The partition is mounted read-only at a hidden staging path (`/mnt/.felhom-raw/<label>`). A directory browser lets the user navigate the drive's contents and create a new folder. The selected folder is bind-mounted at `/mnt/<hdd-name>`, keeping the controller's data isolated from existing files. Two fstab entries (raw + bind, both with `nofail`) ensure the mount survives reboots.

  **Wizard flow:** Scan → Select partition (only shows partitions with existing FS) → Mount raw + Browse directories → Create folder if needed → Configure mount name + label → Finalize (bind mount + fstab + permissions + register). Cancel cleans up the temp mount.

  **New files (4):** `internal/storage/attach.go`, `internal/storage/attach_linux.go`, `internal/storage/attach_other.go`, `internal/web/templates/storage_attach.html`
  **Modified files (3):** `internal/web/storage_handlers.go` (6 new API handlers), `internal/web/server.go` (route + activeRawMount field), `internal/web/templates/settings.html` (button)

### What was just completed (2026-02-18 session 49)
- **v0.14.2 — Backup Bug Fixes (4 fixes from code review):**

  **Bug 1 (HIGH):** rsync `--delete` was destroying `_db/` and `_config/` directories on every single-mount run. Fixed by adding `--exclude _*` to the rsync command in `runRsyncBackup()`. Controller-managed directories (underscore prefix) are now excluded from `--delete` cleanup. (`crossdrive.go`)

  **Bug 2 (MEDIUM):** Scheduled backups (`RunBackup`, `RunDBDumps`) did not set `m.running`, so UI showed "not running" during nightly jobs and restore could overlap. Fixed by extracting `acquireRunning()` / `releaseRunning()` helpers and `runDBDumpsInternal()` / `runBackupInternal()` internal methods. All three public entry points now guard with the running flag; `RunFullBackup()` calls the internal methods directly to avoid deadlock. (`backup.go`)

  **Bug 3 (MEDIUM):** `ValidateDestination` silently succeeded when `GetDiskUsage` returned nil (exotic filesystems, FUSE, NFS). Fixed by logging `[WARN]` and returning nil (backward-compatible). (`crossdrive.go`)

  **Bug 4 (MEDIUM):** Empty `systemDataPath` produced relative dump paths. Fixed with: startup `[WARN]` in `NewManager()`, `[ERROR]` log in `GetAppDrivePath()`, and explicit guard in `DumpStackDB()` that returns an error when path is empty or non-absolute. (`backup.go`)

  **Files modified (2):** `internal/backup/backup.go`, `internal/backup/crossdrive.go`

### What was just completed (2026-02-18 session 48)
- **v0.13.1 — UI Polish Fixes Round 2 (4 fixes):**

  **Fix 1:** Deploy page "Biztonsági mentés" section now has proper card border. Root cause: `.deploy-cross-drive` used undefined CSS variables `--card-bg` and `--border` (only `--bg-secondary` and `--border-color` exist). Fixed by using correct vars (`style.css`).

  **Fix 2:** Auto-generated env values section cleaned up (`deploy.html`, `style.css`). Badge moved inline with label. "Másolás" buttons removed (native select+copy sufficient). Secret fields keep show/hide toggle. Non-secret fields now plain readonly input without button wrapper. Removed `copyAutoField()` JS. CSS updated: `.form-group-auto` now block layout (was flex row), label uses `display: flex; gap: .5rem`, badge downsized to `0.75rem / normal weight`, readonly inputs get muted background.

  **Fix 3:** Snapshot table n/a → 0 (`backups.html`). Replaced `<span class="col-na" title="...">n/a</span>` with plain `0` in all three stats columns. Removed `.col-na` CSS class (no longer used).

  **Fix 4:** Disk warnings moved from top banner to inline under storage bars (`alerts.go`, `layout.html`, `handlers.go`, `dashboard.html`, `monitoring.html`, `style.css`). Added `Inline bool` field to `Alert` struct. Disk-related warnings set `Inline: true`. Layout banner skips inline alerts. New `GetInlineAlerts(page)` method on `AlertManager`. Dashboard and monitoring handlers pass `DiskWarnings`. Inline warning block rendered below storage bars. New `.inline-warning*` CSS classes (compact, subtle, colored).

  **Files modified (8):** `alerts.go`, `handlers.go`, `templates/style.css`, `templates/dashboard.html`, `templates/backups.html`, `templates/deploy.html`, `templates/monitoring.html`, `templates/layout.html`

### What was just completed (2026-02-18 session 47)
- **v0.13.0 — UI Polish Fixes (8 independent fixes):**

  **Fix 1:** backup-status-card border already correct (verified same styling as system-info-card).

  **Fix 2:** Deploy page auto-generated fields now show actual values for deployed apps (`deploy.html`, `handlers.go`). Secrets show as password fields with show/hide toggle; domain/plain values show as readonly text with copy button. JS helpers `toggleAutoField()` / `copyAutoField()` added.

  **Fix 3:** Temperature display made more prominent (`dashboard.html`, `style.css`). Dot enlarged to 11px; value wrapped in colored pill badge (`.temp-value-pill` / `.temp-pill-{green|yellow|red}`).

  **Fix 4:** Dashboard backup card reworked (`dashboard.html`, `handlers.go`). Removed "Mentés most" button and `triggerBackup()` JS. Removed "Tároló méret" line. Added Tier 2 status line (configured/total apps) + warning row for failed cross-drive backups. Handler now computes `CrossDriveTotal`, `CrossDriveConfigured`, `CrossDriveFailed`.

  **Fix 5:** HDD warning banner scoped to dashboard + monitoring pages only (`alerts.go`, `layout.html`, `funcmap.go`). Added `PageOnly []string` field to `Alert` struct. Disk-related warnings (keywords "meghajtón", "adattároló") get stable ID `"disk-not-separate"` + `PageOnly: ["dashboard", "monitoring"]`. `pageMatch()` template function added. Layout renders alerts conditionally.

  **Fix 6:** Tárhely section moved up in Rendszermonitor — now appears right after "Rendszer áttekintés", before "Távoli monitoring" (`monitoring.html`).

  **Fix 7:** Snapshot table improvements (`backups.html`, `style.css`). "MÉRET" renamed to "HOZZÁADOTT (új adat)". `–` for unavailable data replaced with `n/a` (with tooltip explaining restic limitations). New `.col-subtitle` and `.col-na` CSS classes.

  **Fix 8:** Tároló section restructured into tiers (`backups.html`, `handlers.go`, `style.css`). Tier 1 (restic local), Tier 2 (cross-drive, only shown if configured), DB dump directory + total size. Removed "Távoli másolat: Nincs beállítva" placeholder. Handler passes `DBDumpDir`, `DBDumpTotalBytes`, `Tier2Dests` (deduplicated). New `.repo-tier` / `.repo-tier-title` CSS.

  **Files modified (9):** `alerts.go`, `funcmap.go`, `handlers.go`, `templates/style.css`, `templates/dashboard.html`, `templates/backups.html`, `templates/deploy.html`, `templates/monitoring.html`, `templates/layout.html`

### What was just completed (2026-02-18 session 46)
- **v0.12.9 — Tier 2 for All Apps + Status Dot Update:**

  **Fix 1: Tier 2 now configurable for ALL apps — not just HDD apps (`crossdrive.go`)**
  - Removed `len(mounts) == 0` error gate from `RunAppBackup()` — empty mounts = config-only backup
  - rsync: DB dump copy (`_db/`) + config rsync (`_config/`) still runs even with zero HDD mounts
  - restic: config dir + DB dump dir still appended even without mount paths
  - Non-HDD apps (Mealie, Gokapi, etc.) can now be protected against drive failure via Tier 2

  **Fix 2: Status dot logic updated, HasHDDData gate removed (`handlers.go`)**
  - `buildAppBackupRows()`: "auto" (gray) status removed — all apps start yellow ("Csak helyi mentés")
  - Green requires Tier 2 configured + last status "ok" (not just "configured but never run")
  - Tier2 section is now unconditional — no `if app.HasHDDData` gate
  - Cross-drive summary loop: removed `if !app.HasHDDData { continue }` — all apps in summary

  **Fix 3: Backup page template updates (`backups.html`)**
  - Tier 2 row shown for all apps (removed `{{if .HasHDDData}}` gate)
  - Meta badge: non-HDD apps show "Konfig" or "Konfig + DB" instead of "Auto"
  - Tier 3 placeholder row added (grayed out "Hamarosan / távoli offsite")
  - Button text: "Összes HDD mentés" → "Összes 2. mentés futtatása most"

  **Fix 4: Deploy page cross-drive section visible for all deployed apps (`deploy.html`)**
  - Removed `{{if .StorageInfo}}` double-gate — section now shows for all deployed apps
  - Updated heading: "Másolat másik meghajtóra (felhasználói adatok)" → "2. mentés — másolat másik meghajtóra"
  - Updated hint: "mint az alkalmazás adattárolója" → "a meghibásodás elleni védelem érdekében"

  **Files modified (4):** `internal/backup/crossdrive.go`, `internal/web/handlers.go`, `internal/web/templates/backups.html`, `internal/web/templates/deploy.html`

### What was just completed (2026-02-18 session 45)
- **v0.12.8 — Complete Cross-Drive Backup + Per-Tier UI:**

  **Fix 1: Cross-drive backup now includes DB dumps + app config (`crossdrive.go`, `main.go`)**
  - `CrossDriveRunner` gets `dbDumpDir` field + `SetDBDumpDir(dir string)` setter
  - `copyStackDBDumps()` helper copies `<stackName>_*.sql` files to `_db/` subfolder in rsync dest
  - `runRsyncBackup()`: after HDD mount rsync loop, copies DB dumps to `_db/` and rsyncs config dir to `_config/` — both non-fatal on error
  - `runResticBackup()`: appends config dir and full DB dump dir to restic paths (restic deduplicates)
  - rsync destination layout: `backups/rsync/<app>/_db/` (dumps) + `_config/` (compose+yaml) + user data
  - `main.go`: `crossDriveRunner.SetDBDumpDir(cfg.Paths.DBDumpDir)` wired after runner init

  **Fix 2: UI restructured from per-layer to per-tier (`handlers.go`, `backups.html`, `style.css`)**
  - `AppBackupRow` struct rebuilt: dropped old `DBLastRun/Status`, `VolumeLastRun/Status`, `HasUserData`, `UserDataConfigured/Method/Dest/Schedule/LastRun/LastStatus/LastError/StatusBadge` fields
  - New fields: `BackupContents` (e.g., "DB + Konfig + Adatok"), `Tier1LastRun/LastStatus/DBStatus`, `Tier2Configured/Method/MethodLabel/Dest/Schedule/LastRun/LastStatus/LastError/StatusBadge/SizeHuman/Browsable`
  - `buildAppBackupRows()` rewritten: destination health now via `s.crossDriveRunner.ValidateDestination()` instead of `system.CheckBackupDestination()`
  - `backups.html`: two tier rows (1. mentés / 2. mentés) replace the old three layer rows (DB / Konfig / Userdata)
  - `style.css`: added `.tier-label`, `.tier-location`, `.tier-contents`, `.tier-size`, `.tier-browsable` classes

  **Fix 3: Cleanup (`router.go`)**
  - `filterSnapshotsByPaths()` and `pathCovers()` deleted (were unused since v0.12.7a)

  **Files modified (6):** `internal/backup/crossdrive.go`, `cmd/controller/main.go`, `internal/web/handlers.go`, `internal/web/templates/backups.html`, `internal/web/templates/style.css`, `internal/api/router.go`

### What was just completed (2026-02-18 session 44)
- **v0.12.7a — Post-deploy fixes:**

  **Fix A: Restore now shows snapshots for all apps (`internal/api/router.go`)**
  - Root cause: `filterSnapshotsByPaths` filtered older snapshots (pre-v0.12.7) by HDD paths. Older snapshots don't contain HDD paths (backup wasn't mandatory yet), so Immich got zero snapshots.
  - Fix: removed HDD path filtering entirely from `backupSnapshots`. All snapshots contain config + DB dumps and are useful for any app. `RestoreApp` extracts whatever paths are available from the chosen snapshot.
  - `filterSnapshotsByPaths` and `pathCovers` functions kept (unused, no compile error).

  **Fix B: Clarified "no cross-drive" warning (`internal/web/handlers.go`, `backups.html`, `style.css`)**
  - Root cause: "Nincs beállítva" / red dot implied no backup at all — misleading since nightly restic now always covers HDD data.
  - `handlers.go`: status `"red"` → `"yellow"`, StatusText → `"Nincs második másolat (csak helyi mentés)"`
  - `backups.html`: added `✓ Helyi mentés auto` badge before the `⚠ Nincs 2. másolat` warning
  - `style.css`: `.layer-auto-ok` class added (green text for the auto badge)

  **Files modified (3):** `internal/api/router.go`, `internal/web/handlers.go`, `internal/web/templates/backups.html`, `internal/web/templates/style.css`

### What was just completed (2026-02-18 session 43)
- **v0.12.7 — Backup Architecture Overhaul (mandatory HDD backup, pre-dump, restore for all apps):**

  **Fix 1: HDD data backup now mandatory (`backup.go`, `appdata.go`, `settings.go`)**
  - `resolveAppBackupPaths()` rewrote to iterate ALL deployed stacks via `ListDeployedStacks()` — no longer reads `GetAppBackupMap()` or checks `Enabled` flag
  - `DiscoverAppData()` signature simplified: dropped `backupPrefs map[string]bool` parameter; `BackupEnabled` is now derived from `HasHDDData` (if app has HDD data, it's always backed up)
  - `RefreshCache()` updated to call new `DiscoverAppData(m.stackProvider, status.DiscoveredDBs)` signature
  - 5 dead settings methods deleted: `IsAppBackupEnabled`, `SetAppBackup`, `GetAppBackupMap`, `SetAppBackupBulk`, `GetAppBackupPrefs` — `AppBackupPrefs.Enabled` field kept in struct for backward-compat JSON loading

  **Fix 2: Cross-drive backup triggers fresh DB dump first (`crossdrive.go`, `backup.go`, `main.go`)**
  - New `DBDumper` interface with `DumpStackDB(ctx, stackName)` in `crossdrive.go`
  - `CrossDriveRunner` gets `dbDumper` field + `SetDBDumper(d DBDumper)` setter
  - `Manager.DumpStackDB()` discovers containers for that stack via `DiscoverDatabases()`, runs `DumpAll()`, persists validation cache — same logic as nightly dump but scoped to one stack
  - `RunAppBackup()` calls `DumpStackDB()` before `ValidateDestination()` — non-fatal on failure (logs warn, proceeds with user data)
  - `main.go` wires `crossDriveRunner.SetDBDumper(backupMgr)` after both are initialized

  **Fix 3: Restore dropdown shows ALL deployed apps (`backups.html`, `restore.go`, `router.go`)**
  - `restore.go` rewritten: no `IsAppBackupEnabled()` check; resolves `GetStackComposePath` + `DBDumpDir` + HDD mounts; always restores config+DB, adds user data if `hasHDD`; logs restore type (`config+DB` vs `full (config+DB+userdata)`)
  - Restore dropdown template: removed `{{if and .HasHDDData .BackupEnabled}}` filter; every app gets an `<option>` with `data-has-hdd` and `data-has-db` attributes
  - New `#restore-type-info` div added between snapshot selector and warnings
  - `onRestoreAppChange()` JS updated: reads `data-has-hdd`/`data-has-db` from selected option, shows Hungarian restore type banner (full / config+DB / config only) with color-coded styling
  - `router.go` `backupSnapshots`: added clarifying comment for non-HDD apps (no filter = all snapshots returned)

  **Fix 4: Honest UI label (`backups.html`)**
  - "Docker kötetek" renamed to "Konfiguráció" — Docker named volumes at `/var/lib/docker/volumes/` are NOT in the restic backup paths; what's actually backed up is compose files + app.yaml + .felhom.yml

  **CSS: `.restore-info` and `.restore-info-partial` classes added to `style.css`**

  **Files modified (9):** `internal/backup/backup.go`, `internal/backup/appdata.go`, `internal/settings/settings.go`, `internal/backup/crossdrive.go`, `internal/backup/restore.go`, `cmd/controller/main.go`, `internal/web/templates/backups.html`, `internal/web/templates/style.css`, `internal/api/router.go`

### What was just completed (2026-02-18 session 42)
- **v0.12.6 — Cross-Drive Backup Rsync Fixes:**

  **Context:** After fixing mount-point validation and system-drive thresholds (v0.12.5), testing revealed two more rsync issues for Immich.

  **Fix 3: Simplified rsync destination path structure (`internal/backup/crossdrive.go` `runRsyncBackup`)**
  - Old logic stripped only the first 2 path segments and kept the rest as a subpath, producing redundant nesting: `backups/rsync/immich/storage/immich/<data>` instead of `backups/rsync/immich/<data>`
  - New logic: if app has a single mount, rsync directly into the stack folder (`backups/rsync/immich/`); if multiple mounts, use each mount's leaf directory name as subfolder
  - Duplicate leaf names disambiguated by appending `_N` index suffix
  - Loop variable changed from `_, srcMount` to `i, srcMount` to support the index-based disambiguation
  - Old nested `storage/immich/` folder will remain orphaned after first run (no data loss; `--delete` only affects the target subtree)

  **Fix 4: Exclude app-internal DB dump files from rsync (`internal/backup/crossdrive.go` `runRsyncBackup`)**
  - Apps like Immich store their own periodic DB dumps in `<data>/backups/*.sql.gz` (~16 MB/day)
  - The controller already handles DB backups via `pg_dump` separately — copying these again via rsync is redundant and wastes space
  - Added `--exclude backups/*.sql.gz`, `--exclude backups/*.sql`, `--exclude backups/*.dump` to rsync command
  - The `backups/` directory itself and non-dump files within it are preserved

  **Files modified (1):** `internal/backup/crossdrive.go`

### What was just completed (2026-02-18 session 41)
- **v0.12.5 — Cross-Drive Backup Validation Fix:**

  **Root cause:** Immich cross-drive backup failed with `destination /mnt/hdd_placeholder is not a mount point` because `ValidateDestination()` hard-blocked non-mount-point destinations. The `/mnt/hdd_placeholder` folder is on the internal SSD (not a separate mount), so the device-ID check returned false.

  **Fix 1: Drive-type-aware space checks in `ValidateDestination` (`internal/backup/crossdrive.go`)**
  - `onSystemDrive` flag replaces the previous boolean-only mount-point check
  - System-drive destinations: require **≥10 GB free** and **<90% usage** to protect OS stability
  - External-drive destinations: require **≥100 MB free** (original threshold)
  - Updated function comment to reflect the new tiered logic

  **Fix 2: Aligned `CheckBackupDestination` UI thresholds for system drives (`internal/system/mounts_linux.go`)**
  - Tier 4 disk checks now branch on `h.SystemDrive` flag (set in Tier 3)
  - System drive: block at <10 GB free OR ≥90% used (matches runner enforcement); Hungarian warning messages
  - External drive: warn at ≥90% used, block at ≥95% used (unchanged)
  - Removed the `&& h.Severity == "ok"` guard that prevented system-drive warnings from being overridden properly

  **Files modified (2):** `internal/backup/crossdrive.go`, `internal/system/mounts_linux.go`

### What was just completed (2026-02-18 session 40)
- **v0.12.4 — Correctness & Robustness Bug Fixes (TASK.md — 15 bugs fixed):**

  **CRITICAL fixes (data loss, panics):**
  - **C1: `SetAppBackupBulk` data loss + nil map panic** — Fixed: now updates map IN PLACE instead of replacing it, so stacks absent from the input are preserved. Added nil guard for `s.AppBackup`. (`internal/settings/settings.go`)
  - **C2: `UpdateStackConfig` nil Env map panic** — Added nil check `if appCfg.Env == nil { appCfg.Env = make(...) }` before the field assignment loop. (`internal/stacks/deploy.go`)
  - **C3: `ValidateDump` missing scanner.Err() check** — Added `if err := scanner.Err()` check after the scan loop so I/O errors don't silently mark a partial dump as valid. (`internal/backup/dbdump.go`)

  **HIGH fixes (logic errors, resource leaks):**
  - **H1: `nextDailyRun` DST bug** — Replaced `next.Add(24 * time.Hour)` with `time.Date(day+1, ...)` for correct scheduling across Europe/Budapest DST transitions. (`internal/scheduler/scheduler.go`)
  - **H2: `nextDailyRun` repeated `LoadLocation`** — Cached timezone in package-level `sync.Once` variable; `getBudapestLocation()` now loaded only once. (`internal/scheduler/scheduler.go`)
  - **H3: `settings.save()` .tmp file leak** — Added `os.Remove(tmpPath)` cleanup on `WriteFile` failure path. (`internal/settings/settings.go`)
  - **H4: `SetNotificationPrefs` nil pointer panic** — Added nil guard at start of function, returns error instead of panicking. (`internal/settings/settings.go`)
  - **H5: `appDirSize` ignores `Sscanf` return value** — Now checks `n != 1` and returns `(0, "?")` on parse failure. Same fix applied to `getDirSizeBytes` in `stacks/delete.go`. (`internal/backup/appdata.go`, `internal/stacks/delete.go`)
  - **H6: `getDirSizeBytes` no timeout** — Added `exec.CommandContext` with 30s timeout. Added `"context"` import. (`internal/stacks/delete.go`)
  - **H7: `dbdump.go` tmpFile not using `defer Close`** — Replaced explicit `tmpFile.Close()` call with `defer tmpFile.Close()` so the file handle is released even on panic. (`internal/backup/dbdump.go`)
  - **H8: `UpdateCrossDriveStatus` misleading comment** — Updated comment to accurately describe the "does nothing if nil" behavior instead of claiming it "creates one if nil". (`internal/settings/settings.go`)

  **MEDIUM fixes (code quality, edge cases):**
  - **M1: Custom `contains`/`containsBytes` replaced** — Removed bespoke `containsBytes` and simplified `contains` to delegate to `strings.Contains`. Added `"strings"` import. (`internal/notify/notifier.go`)
  - **M2: `scheduler.Every()` doesn't validate interval** — Added early return with error log if `interval <= 0` to prevent panic in `time.NewTicker`. (`internal/scheduler/scheduler.go`)
  - **M3: `executeJob` panic recovery missing `LastRun`** — Panic recovery defer now also sets `job.LastRun = time.Now()` so the job status shows a timestamp after a panic. (`internal/scheduler/scheduler.go`)
  - **M4: `logPostStartStatus` goroutine captures env by reference** — Copies the env slice before launching the goroutine (`envCopy`). (`internal/stacks/manager.go`)
  - **M5: Multiple `time.LoadLocation` calls in web package** — Added package-level `getTimezone()` with `sync.Once` in `funcmap.go`. Replaced all `time.LoadLocation("Europe/Budapest")` calls in the web package with `getTimezone()`. (`internal/web/funcmap.go`, `internal/web/handlers.go`)

  **Files modified (8):** `internal/settings/settings.go`, `internal/stacks/deploy.go`, `internal/backup/dbdump.go`, `internal/scheduler/scheduler.go`, `internal/backup/appdata.go`, `internal/stacks/delete.go`, `internal/stacks/manager.go`, `internal/notify/notifier.go`, `internal/web/funcmap.go`, `internal/web/handlers.go`

### What was just completed (2026-02-17 session 39)
- **v0.12.3 — Security & Correctness Bug Fixes (TASK.md — 33 bugs fixed):**

  **CRITICAL fixes (data races, security vulnerabilities):**
  - **C1: Data race in RefreshCache** — Moved `m.lastDBDump.Results` mutation inside `m.mu.Lock()`. Was previously mutating shared state without the lock, causing potential torn writes visible to `GetFullStatus()` goroutines. (`internal/backup/backup.go`)
  - **C2: SnapshotHistory reversed after unlock** — Moved snapshot reversal loop before `m.cachedStatus = status` (inside the lock). Previously reversed after `Unlock()`, so `m.cachedStatus.SnapshotHistory` was reversed without protection. (`internal/backup/backup.go`)
  - **C3: SetStackProvider write without lock** — `m.stackProvider = provider` now wrapped in `m.mu.Lock()`. Read by `resolveAppBackupPaths()` concurrently. (`internal/backup/backup.go`)
  - **C4: GetFullStatus shallow-copies mutable pointers** — `LastDBDump` and `LastBackup` are now deep-copied (struct + Results slice) so callers cannot mutate shared manager state. (`internal/backup/backup.go`)
  - **C5: IsSystemDisk 8-bit major mask** — Replaced `>> 8 & 0xff` with `unix.Major()`/`unix.Minor()` (12-bit extraction). Also compares disk-portion of minor (groups of 16) to correctly distinguish physical disks of the same type. Adds `golang.org/x/sys/unix` import. (`internal/storage/safety_linux.go`)
  - **C6: No /dev/ prefix validation on DevicePath** — `FormatAndMount` now validates `DevicePath` starts with `/dev/` and does not contain `..` before any disk operations. (`internal/storage/format_linux.go`)
  - **C7: Path traversal in extractName** — `extractName()` now rejects empty string, `.`, `..`, and names containing `/` or `\`. (`internal/api/router.go`)
  - **C8: Path traversal in TargetPath** — Migration API validates `TargetPath` against registered storage paths from settings before starting migration job. (`internal/web/storage_handlers.go`)
  - **C9: Path traversal in DestinationPath** — Cross-drive backup config API validates `DestinationPath` against registered storage paths when `enabled=true`. (`internal/api/router.go`)
  - **C10: Path traversal in ParseComposeHDDMounts** — `filepath.Clean()` applied before prefix check; uses separator-aware check `cleanHDD + string(filepath.Separator)` to prevent `${HDD_PATH}/../../etc/passwd` escaping. (`internal/stacks/delete.go`)

  **HIGH fixes (logic errors, resource leaks):**
  - **H1: ValidateDump reads entire file into memory** — Replaced `os.ReadFile` with `bufio.Scanner` reading line-by-line. 256KB per-line buffer prevents OOM on large (500MB+) SQL dumps during 5-min cache refresh. (`internal/backup/dbdump.go`)
  - **H2/H3: Double du invocation per mount + no timeout** — Replaced `appDirSizeHuman()`+`appDirSizeBytes()` with single `appDirSize()` function using `exec.CommandContext` with 30s timeout. Halves subprocess calls per mount point. (`internal/backup/appdata.go`)
  - **H4: Snapshot validation only checks first 100** — Replaced `ListSnapshots(100)` existence check with regex validation (`^[0-9a-f]{8,64}$`). Allows restoring any snapshot; `restic restore` returns a clear error for non-existent IDs. (`internal/backup/restore.go`)
  - **H5: No pruning for cross-drive restic repos** — Added `pruneResticRepo()` called after each successful cross-drive restic backup (`forget --keep-daily 7 --keep-weekly 4 --prune`). Non-fatal — logs warning on failure. (`internal/backup/crossdrive.go`)
  - **H6: Temp password file management** — Reorganized temp file lifecycle: close before deferred remove, remove-on-write-error cleanup. (`internal/backup/crossdrive.go`)
  - **H7: dirSizeBytes swallows walk errors** — `filepath.Walk` callback now returns errors instead of `nil`, propagating permission/IO issues. (`internal/backup/crossdrive.go`)
  - **H8: Non-atomic fstab write** — `AppendFstabEntry` now reads existing fstab, writes to `.tmp`, then atomically renames. Crash-safe. (`internal/storage/safety_linux.go`)
  - **H9: IsDeviceMounted naive prefix matching** — After prefix check, next character must be digit (`0-9`) or `p` (partition marker). Prevents `/dev/sdb` matching `/dev/sdba`. (`internal/storage/safety_linux.go`)
  - **H10: eMMC device mapping bug** — `partitionToParentDisk` now handles `mmcblk0p1 → mmcblk0` and `nvme0n1p1 → nvme0n1` patterns. Uses `LastIndex("p")` with digit-suffix check before falling back to `TrimRight("0-9")`. (`internal/storage/scan_linux.go`)
  - **H11: Data race on bytesCopied in rsync error path** — Error return path in `runRsync` now reads `bytesCopied` under mutex lock. (`internal/storage/migrate.go`)
  - **H13: Path prefix match without separator** — Migration source path check now uses `srcPath == req.CurrentHDDPath || strings.HasPrefix(srcPath, req.CurrentHDDPath+"/")`. Prevents `/mnt/hdd` matching `/mnt/hdd_backup/data`. (`internal/storage/migrate.go`)
  - **H14: DeleteStack continues after failed compose down** — `docker compose down` failure now returns an error immediately, preventing deletion of files while containers are still running. (`internal/stacks/delete.go`)
  - **H16: exec.Command("docker") without timeout** — `syncFileBrowserMounts()` now uses `exec.CommandContext` with 60s timeout. (`internal/web/handlers.go`)
  - **H17: SetNotificationPrefs stores caller's pointer** — Deep-copies `NotificationPrefs` struct and `EnabledEvents` slice before storing. (`internal/settings/settings.go`)
  - **H18: wipefs error silently discarded** — wipefs failure logged as warning via progress channel; continues (wipefs may not be installed). (`internal/storage/format_linux.go`)
  - **H19: Orphaned fstab entry on mount failure** — New `RemoveFstabEntry()` function atomically removes UUID entry. Called as rollback on `mount` failure and `findmnt` verify failure. (`internal/storage/safety_linux.go`, `format_linux.go`)

  **MEDIUM fixes (edge cases, code quality):**
  - **M1: formatBytes duplicate in dbdump.go** — Removed `formatBytes()` from `dbdump.go`; all callers (backup.go, restic.go, dbdump.go) now use `humanizeBytes()` from appdata.go. (`internal/backup/dbdump.go`, `backup.go`, `restic.go`)
  - **M2: Dead code .tmp suffix check** — Reordered filter in `ListDumpFiles`: `.tmp` check now comes before `.sql` check to correctly skip `.sql.tmp` temp files (was unreachable before). (`internal/backup/dbdump.go`)
  - **M3: sizeBytes() returns 0 for string types** — Added `case string:` to `sizeBytes()` using `strconv.ParseUint`. (`internal/storage/scan_linux.go`)
  - **M6: Dead elapsed variable** — Removed `_ = elapsed`; elapsed time now shown inline in the "done" progress message. (`internal/storage/migrate.go`)
  - **M7: time.LoadLocation error silently discarded** — Two locations in handlers.go now handle `LoadLocation` error, falling back to `time.UTC`. (`internal/web/handlers.go`)
  - **M10: filterSnapshotsByPaths imprecise prefix** — Added `pathCovers()` helper using separator-aware prefix check. Prevents `/mnt/hdd_1` matching `/mnt/hdd_10/data`. (`internal/api/router.go`)
  - **M11: XSS in editStorageLabel innerHTML** — `cancelEditLabel()` in settings.html now uses DOM manipulation (`document.createElement`, `.textContent`) instead of `innerHTML` for the label text. (`internal/web/templates/settings.html`)

  **Files modified (15):** `internal/backup/backup.go`, `internal/backup/appdata.go`, `internal/backup/dbdump.go`, `internal/backup/restore.go`, `internal/backup/crossdrive.go`, `internal/backup/restic.go`, `internal/storage/safety_linux.go`, `internal/storage/format_linux.go`, `internal/storage/scan_linux.go`, `internal/storage/migrate.go`, `internal/stacks/delete.go`, `internal/api/router.go`, `internal/web/handlers.go`, `internal/web/storage_handlers.go`, `internal/settings/settings.go`, `internal/web/templates/settings.html`

### What was just completed (2026-02-17 session 38)
- **v0.12.2 — Restore Section Simplification (Bug 4 from v0.12.1 TASK.md):**
  - **Feature: Snapshot filtering by app** — `GET /api/backup/snapshots?stack={name}` now filters snapshots to those whose `Paths` overlap with the app's HDD mount paths. Uses prefix matching (snapshot path is prefix of required, or vice versa). New `filterSnapshotsByPaths()` helper in `internal/api/router.go`. Manager gains `GetStackHDDMounts()` method to expose stackProvider's mount resolution.
  - **Feature: Auto-stop/restart on restore** — `RestoreApp()` now stops the app's containers before running `restic restore` and restarts them after (even on failure). Avoids data corruption from live writes during restore. Eliminates the "Javasoljuk az alkalmazás leállítását" advisory from the UI.
  - **Interface extension: StackDataProvider** — Added `StopStack(name string) error` and `StartStack(name string) error` to the `backup.StackDataProvider` interface in `internal/backup/appdata.go`. `stackAdapter` in `cmd/controller/main.go` wires these through to `stacks.Manager`.
  - **UI simplification: Restore section** — Removed confusing "Visszaállítandó útvonalak" path list (technical detail not needed by customer). Snapshot dropdown now populated per-app (filtered) with human-friendly format: `2026-02-17 hétfő 03:00 (a3f2b1)`. Single calm warning replacing the triple-exclamation block. Empty filtered result shows inline message instead of empty dropdown. `data-paths` attribute removed from app dropdown options.
  - **Files modified (6):** `internal/backup/appdata.go`, `internal/backup/backup.go`, `internal/backup/restore.go`, `internal/api/router.go`, `internal/web/templates/backups.html`, `cmd/controller/main.go`

### What was just completed (2026-02-17 session 37)
- **v0.12.0 — Backup Page Overhaul — Unified App Backup Status & Bug Fixes:**
  - **Bug Fix 1: Duplicate unconfigured apps** — `GetFullStatus()` now returns a deep copy of the cached status. `CrossDriveSummary`, `UnconfiguredApps`, and `CrossDriveWarnings` slices are always nil in the returned copy so the handler builds them fresh on every page load. Previously the handler appended to the cached slices, causing 3× duplication on 3 page loads.
  - **Bug Fix 2: Misleading "drive disconnected" error** — Replaced the binary `IsMountPoint || !IsWritable` check with tiered `CheckBackupDestination()` validation (new in `internal/system/mounts_linux.go` and stub in `mounts_other.go`). Tiers: path doesn't exist (critical/blocked), not writable (critical/blocked), same block device as `/` (warning/allowed with note about system drive), disk >95% full (critical/blocked), disk >90% (warning/allowed). `isSameBlockDevice()` replaces `IsMountPoint()` for source/dest same-device detection. Used in both `deployHandler()` and `backupsHandler()` for display, and in `crossdrive.go` logic via `CheckBackupDestination()`.
  - **Bug Fix 3: Dead BackupEnabled toggle** — Removed `settingsAppBackupHandler()` from handlers.go and its `POST /settings/app-backup` route from server.go. The toggle wrote to settings.json but nothing read it to skip apps. UI nightly backup section in deploy.html now shows an informational note instead of the toggle.
  - **Architecture: Unified per-app backup rows** — New `AppBackupRow` struct and `buildAppBackupRows()` in handlers.go. Replaces old "Alkalmazás adatok" + "Másolatok másik meghajtóra" sections with a single expandable row per app showing all 3 backup layers (DB, Docker volumes, user data). Status dot: green=fully covered, yellow=warning (failed run, system drive, disk full), red=HDD data without cross-drive configured, auto=no user data. Expandable JS toggle with ▶/▼ icon.
  - **Architecture: Sequential backup chaining** — Removed independent `cross-drive-daily` (03:30) and `cross-drive-weekly` (04:30) scheduler jobs. Cross-drive backups now run immediately after the restic backup completes (daily jobs every night; weekly jobs on Sunday). This ensures DB dump → restic → cross-drive happen in the same window for file/DB consistency on restore.
  - **Architecture: Deploy page schedule dropdown** — Removed "Csak kézi indítás" option (schedule="manual"). Two options remain: "Naponta (az éjszakai mentés után)" and "Hetente, vasárnap (az éjszakai mentés után)". Weekly option shows informational note about DB consistency implications. Existing "manual" configs treated as "weekly" in the dropdown.
  - **CSS added:** `.app-backup-row`, `.app-backup-row-header`, `.app-backup-row-name`, `.app-backup-row-meta`, `.app-backup-row-detail`, `.status-dot` (green/yellow/red/auto), `.backup-layers`, `.backup-layer-row`, `.layer-label`, `.layer-badge`, `.layer-na`, `.layer-method`, `.layer-dest`, `.layer-schedule`, `.layer-last`, `.layer-unconfigured`, `.layer-actions`, `.layer-warnings`, `.backup-layer-warning`, `.btn-xs`, `.text-ok`, `.text-error`.
  - **Files modified (9):** `internal/backup/backup.go`, `internal/system/mounts_linux.go`, `internal/system/mounts_other.go`, `internal/web/handlers.go`, `internal/web/server.go`, `internal/web/templates/backups.html`, `internal/web/templates/deploy.html`, `internal/web/templates/style.css`, `cmd/controller/main.go`

### What was just completed (2026-02-17 session 36)
- **v0.11.9 — UI Polish Fixes for deploy/settings backup section:**
  - **Fix 1: Spacing** — `.deploy-cross-drive` `margin-bottom` increased from `1rem` to `1.5rem` for consistent spacing before deploy form.
  - **Fix 2: Tooltip on "Módszer"** — Renamed "Verziózott mentés (restic)" to "Titkosított mentés (restic)". Added info `(i)` tooltip explaining rsync vs restic tradeoffs.
  - **Fix 3: Nightly backup indicator** — Replaced disabled checkbox (with confusing pointer cursor) with a non-interactive green/gray dot indicator.
  - **Fix 4: Progressive disclosure** — Dest/method/schedule selects are disabled until "Engedélyezve" is checked. JS `toggleCrossDriveFields()` enables/disables them. Backend handler updated to preserve existing config when disabling (disabled fields not submitted).
  - **Fix 5: Emoji cleanup** — Removed all emoji from `deploy.html` backup section (h4, warning, status, hint, stale data) and `backups.html` cross-drive summary (status badges, schedule badge, unconfigured warning). JS callbacks also cleaned up.
  - **CSS added:** `.info-tooltip`, `.info-icon`, `.info-tooltip-text`, `.cross-drive-nightly-status`, `.nightly-status-indicator`, `.nightly-enabled`, `.nightly-disabled`, `.meta-badge-fail`.
  - **Files modified (4):** `web/templates/deploy.html`, `web/templates/backups.html`, `web/templates/style.css`, `web/handlers.go`

### What was just completed (2026-02-17 session 35)
- **v0.11.8 — Per-App Cross-Drive Backup (3-2-1 rule, second copy on different media):**
  - **Feature: CrossDriveBackup data model** — `AppBackupPrefs` extended with `CrossDrive *CrossDriveBackup` field in `settings.go`. New methods: `GetCrossDriveConfig`, `SetCrossDriveConfig`, `UpdateCrossDriveStatus`, `GetAllCrossDriveConfigs`, `GetOrCreateCrossDrivePassword`. Existing `SetAppBackup`/`SetAppBackupBulk` now preserve cross-drive config. Auto-generated restic password stored in `settings.json`.
  - **Feature: CrossDriveRunner** — New `internal/backup/crossdrive.go`. Supports rsync (simple mirror with `--delete`) and restic (versioned, deduplicated, shared repo). Safety guards: destination ≠ source, mount point check, writable check, per-app concurrency lock. `RunAllScheduled(ctx, schedule)` iterates all apps matching the given schedule. Status (last_run, last_status, last_error, last_duration, last_size_human) persisted to settings.json after each run.
  - **Feature: Scheduler jobs** — Two new daily jobs: `cross-drive-daily` at 03:30 (for apps with `schedule: daily`), `cross-drive-weekly` at 04:30 Sundays only (for `schedule: weekly`).
  - **Feature: API endpoints** — 4 new routes: `POST /api/stacks/{name}/cross-backup`, `POST /api/stacks/{name}/cross-backup/run`, `GET /api/stacks/{name}/cross-backup/status`, `POST /api/backup/cross-drive/run-all`.
  - **Feature: Deploy/Settings page UI** — New "Biztonsági mentés" card on the deploy page for apps with HDD data. Shows nightly backup toggle (read-only link), cross-drive dropdowns (destination, method, schedule), last run status, manual trigger button. States: no other storage (info message), configured, destination unreachable (warning). Flash messages on save redirect.
  - **Feature: Backup page summary** — New "Másolatok másik meghajtóra" section showing all configured apps with method, destination, last status, size. Warns about unconfigured apps with HDD data. Destination health warnings. "Összes futtatása most" button.
  - **CSS:** `margin-bottom: 1.5rem` added to `.deploy-stale-data`. New styles: `.deploy-cross-drive`, `.cross-drive-list`, `.cross-drive-item`, `.cross-drive-header`, `.cross-drive-meta`, `.cross-drive-actions`.
  - **Files modified (10):** `settings/settings.go`, `backup/crossdrive.go` (new), `backup/backup.go`, `api/router.go`, `web/handlers.go`, `web/server.go`, `web/templates/deploy.html`, `web/templates/backups.html`, `web/templates/style.css`, `cmd/controller/main.go`

### What was just completed (2026-02-17 session 34)
- **v0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Title Fix:**
  - **Feature: Stale data cleanup** — After app data migration, the deploy/settings page now shows leftover data on previous storage paths with size info and a delete button. Two-step confirmation required before deletion. Protected paths (storage root, media, Dokumentumok, appdata) cannot be deleted. Also available immediately after migration on the migration-done page.
  - **Fix: FileBrowser sync after migration** — `syncFileBrowserMounts()` now called after successful data migration, ensuring FileBrowser mounts reflect the current storage layout.
  - **Fix: Deploy page title** — Already-deployed apps now show "Beállítások" (Settings) instead of "Telepítés" (Deploy) in both the browser page title and the `<h2>` heading.
  - **Internal: Exported `ProtectedHDDPaths()`** from stacks package for reuse in web handlers.
  - **Files modified (7):** `internal/stacks/delete.go`, `internal/web/handlers.go`, `internal/web/storage_handlers.go`, `internal/web/templates/deploy.html`, `internal/web/templates/migrate.html`, `internal/web/templates/style.css`

### What was just completed (2026-02-17 session 33)
- **v0.11.6 — FileBrowser Auto-Mount Sync + UI Polish (3 fixes):**
  - **Feature: FileBrowser auto-mount sync** — Added `syncFileBrowserMounts()` and `generateFileBrowserCompose()` to `handlers.go`. After a storage path is added (via storage init wizard) or removed, the controller regenerates `/opt/docker/stacks/filebrowser/docker-compose.yml` with volume mounts for all registered paths (`/mnt/hdd_1:/srv/hdd_1` etc.), then recreates the FileBrowser container. Domain is read from FileBrowser's `.env`. If FileBrowser isn't deployed, the function silently returns. The generated compose is self-contained (no env vars).
  - **UI Fix 1: Badge color fix** — `settings.html`: changed "Nincs csatolva!" (red `state-red`) badge to "Rendszermeghajtón" (yellow `badge-warn`). The path is on the system SSD, which isn't an error — just informational. Added `.badge-warn { background: rgba(250, 204, 21, 0.15); color: #facc15; }` to `style.css`.
  - **UI Fix 2: Progress bar fix** — `storage_init.html`: replaced the disk-usage gradient progress bar (green→yellow→red zones, alarming at 30%) with a clean single-color `progress-bar-task` bar. Added `.progress-bar-task` and `.progress-bar-task .progress-fill` CSS classes to `style.css`.
  - **UI Fix 3: Button text fix** — `settings.html`: "Alapértelmezett" button (reads as status, confusing) → "Legyen alapértelmezett" (clear action verb).
  - **Files modified (5):** `web/handlers.go`, `web/storage_handlers.go`, `web/templates/settings.html`, `web/templates/storage_init.html`, `web/templates/style.css`

### What was just completed (2026-02-17 session 32)
- **v0.11.4 — Bugfix: Storage Initialization (FormatAndMount) — 3 bugs + 4 safety improvements:**
  - **Bug 1 (sfdisk):** Added `wipefs -a` before sfdisk; changed sfdisk input from `,,,L` (unsupported GPT type shorthand) to `,,` (default Linux GUID); added `--force --wipe always` flags. Previous table confusing sfdisk and `L` type not accepted for GPT.
  - **Bug 2 (mount):** Replaced `mount mountPath` (fstab lookup — uses container's /etc/fstab, not host's) with explicit `mount -t ext4 -o defaults,noatime /host-dev/sdb1 /mnt/hdd_1`. fstab entry still written to `/host-fstab` for host reboot persistence.
  - **Bug 3 (mount propagation):** Changed `/mnt` volume in compose to long-form bind with `propagation: rshared`. Also ran `mount --bind /mnt /mnt && mount --make-rshared /mnt` on demo host. Confirmed `Propagation=rshared` in `docker inspect`. Mounts created inside container now propagate to host.
  - **Safety 1 (post-mount verification):** Added `findmnt` check after mount — fails with clear error if mount isn't actually visible.
  - **Safety 2 (ASCII label):** Use `req.MountName` (always ASCII) for ext4 `-L` label (16-byte limit). Display label (`req.Label`, may contain UTF-8 Hungarian chars) stays only in settings.json.
  - **Safety 3 (smart partition):** In `storageInitAPIHandler`, if disk has exactly 1 empty partition (no filesystem), skip wipefs+sfdisk entirely and format existing partition directly. Handles demo sdb case (sdb1 exists, no FS).
  - **Safety 4 (progress messages):** Updated `send()` calls to include command details (device paths, flags) for remote debugging via UI progress panel.
  - **Files modified (3):** `storage/format_linux.go`, `docker-compose.yml`, `web/storage_handlers.go`

### What was just completed (2026-02-17 session 31)
- **v0.11.3 — Bugfix: Missing sfdisk in container (fdisk package):**
  - `sfdisk` is in the `fdisk` package on Debian bookworm, not `util-linux`. Dockerfile had `util-linux` but not `fdisk`, so `sfdisk` was missing and partitioning failed.
  - Added `fdisk` to Dockerfile's `apt-get install` list. Updated comment to clarify which package provides what.
  - Verified: all six disk tools now present in container (`sfdisk`, `mkfs.ext4`, `blkid`, `mount`, `lsblk`, `partprobe`).
  - **Files modified (1):** `Dockerfile`

### What was just completed (2026-02-17 session 30)
- **v0.11.2 — Bugfix: /dev/sdb not accessible inside container:**
  - **Root cause:** Docker always creates a fresh tmpfs at `/dev` inside containers. Even with `privileged: true`, the bind mount `- /dev:/dev` is silently dropped. Block device nodes like `/dev/sdb` don't exist inside the container.
  - **Fix:** Mount host `/dev` at `/host-dev` instead. With `privileged: true`, the kernel allows I/O to the device nodes regardless of path inside the container.
  - **docker-compose.yml:** Changed `- /dev:/dev` → `- /dev:/host-dev:rw`. Also applied missing `privileged: true`, `/etc/fstab:/host-fstab`, and `/run/udev:/run/udev:ro` to demo node's live compose (never applied after v0.11.0).
  - **safety.go:** Added `HostDevPath = "/host-dev"` constant and `HostDevicePath(devPath) string` helper (`/dev/sdb` → `/host-dev/sdb`).
  - **format_linux.go:** All device operations (os.Stat, sfdisk, partprobe, mkfs.ext4, blkid UUID) use `HostDevicePath()`.
  - **safety_linux.go:** `IsSystemDisk()` stats device via `HostDevicePath()`.
  - **scan_linux.go:** `enrichWithBlkid()` probes each partition individually (`blkid -o value -s TYPE/UUID/LABEL /host-dev/sdXN`) instead of batch `blkid -o export` (which fails when `/dev` is Docker's minimal tmpfs).
  - **Verified:** `/host-dev/sda`, `/host-dev/sdb`, partitions visible; `blkid /host-dev/sdb1` returns correct UUID/fstype/label.
  - **Files modified (5):** `storage/safety.go`, `storage/safety_linux.go`, `storage/format_linux.go`, `storage/scan_linux.go`, `docker-compose.yml`

### What was just completed (2026-02-17 session 29)
- **v0.11.1 — Bugfix: Storage Scan — System Disk Detection & FSType in Container:**
  - **Bug 1 fix: System disk detection** — Replaced mount-point string comparison (`== "/"`, `"/boot"`, `"/boot/efi"`) with host fstab parsing. Inside the container, `lsblk` reports container mount points (e.g. `/opt/docker/felhom-controller/data`), not host mount points. New `getSystemDiskNames()` reads `/host-fstab` (fallback: `/etc/fstab`), finds system entries (`/`, `/boot`, `/boot/efi`, `swap`), resolves `UUID=` entries to device paths via `blkid -U`, and marks parent disks as system. `partitionToParentDisk()` handles both standard (`sda2→sda`) and NVMe (`nvme0n1p2→nvme0n1`) naming.
  - **Bug 2 fix: FSType enrichment** — `lsblk` returns null fstype in containers (udev/blkid cache incomplete). New `enrichWithBlkid()` runs `blkid -o export` after lsblk scan and fills in missing `FSType`, `UUID`, `Label` per partition from direct device probing. Runs on both `AvailableDisks` and `SystemDisks`.
  - **Result:** sda (system SSD) now correctly appears in SystemDisks; sdb (USB HDD) appears in AvailableDisks; partition fstypes (vfat/ext4/swap) correctly shown; sdb1 genuinely shows "(nincs fájlrendszer)".
  - **Files modified (1):** `storage/scan_linux.go`

### What was just completed (2026-02-17 session 28)
- **v0.11.0 — Phase C: Storage Init, Data Migration & Startup Fixes:**
  - **Step 0: Startup ping + hub report** — Controller now fires heartbeat ping, system_health ping, and hub report immediately on startup (5s delay) instead of waiting for first scheduler tick (5-15 min). `hubPusher` instance created once and reused for both startup and periodic reports. Prevents Healthchecks showing stale "Last Ping: X ago" after restarts.
  - **Step 1-3: Storage initialization wizard** — New `internal/storage/` package (`scan.go`, `format.go`, `safety.go`, `format_linux.go`, `safety_linux.go`, `scan_linux.go` + non-linux stubs). `ScanDisks()` via `lsblk -J`. `FormatAndMount()` with progress channel (partition via sfdisk → mkfs.ext4 → blkid UUID → fstab backup + UUID-based entry → mount → chown + subdirs). Safety guards: system disk detection via major device numbers, mount path conflict, confirmation "FORMÁZÁS" required. New wizard page at `/settings/storage/init`. JSON API endpoints at `/api/storage/scan`, `/api/storage/init`, `/api/storage/init/status`. Auto-registers storage path in settings.json after success.
  - **Step 4-5: Data migration** — New `MigrateAppData()` in `internal/storage/migrate.go`. Per-app "Mozgatás" button on deploy page (for deployed apps with HDD data) and settings page storage app list. Migration flow: stop app → rsync with `--info=progress2` progress parsing → update `app.yaml` HDD_PATH → start app. Rollback on failure (revert config + restart with original path). Old data preserved. New migration page at `/stacks/{name}/migrate`. JSON API at `/api/storage/migrate`, `/api/storage/migrate/status`.
  - **Step 6: Per-app storage display** — Deploy page (read-only mode) now shows "Adattárolás" section for deployed apps: current path + label, data size, free space. "Mozgatás" link shown when other storage paths exist.
  - **Step 7: Container setup** — Added `privileged: true` to `docker-compose.yml`. New volume mounts: `/dev:/dev`, `/etc/fstab:/host-fstab`, `/run/udev:/run/udev:ro`. Docker socket changed from `:ro` to writable. `Dockerfile` adds: `util-linux`, `e2fsprogs`, `rsync`, `parted`.
  - **Storage API routing** — New `/api/storage/` prefix registered in `main.go` before `/api/` catch-all (longer prefix takes priority in Go ServeMux). `ServeStorageAPI` method on web.Server handles all storage JSON endpoints.
  - **CSS additions** — `.disk-step`, `.disk-step-active`, `.disk-step-done`, `.disk-progress-steps`, `.disk-progress-bar-wrap`, `.deploy-storage-info` styles.
  - **Files created (13):** `storage/scan.go`, `storage/scan_linux.go`, `storage/scan_other.go`, `storage/safety.go`, `storage/safety_linux.go`, `storage/safety_other.go`, `storage/format.go`, `storage/format_linux.go`, `storage/format_other.go`, `storage/migrate.go`, `web/storage_handlers.go`, `templates/storage_init.html`, `templates/migrate.html`
  - **Files modified (8):** `main.go`, `web/server.go`, `web/handlers.go`, `templates/settings.html`, `templates/deploy.html`, `templates/style.css`, `docker-compose.yml`, `Dockerfile`

### What was just completed (2026-02-17 session 27)
- **v0.10.0 — Phase B: Storage Management UI Polish & Health Severity Fix:**
  - **Step 0: Health severity fix** — `checkStoragePaths()` mount-point check reclassified from **issue** (FAIL) to **warning** (WARN). All storage health messages translated to Hungarian. Added `.monitoring-banner-warn` CSS class for yellow warning banners. Prevents false FAIL status on demo/test environments where storage is intentionally on SSD.
  - **Step 1: Success flash messages** — All 4 storage handlers (add/remove/set-default/toggle-schedulable) now redirect with `?storage_msg=success&storage_detail=...` query params. Settings page displays green "alert-info" flash on success. Consistent with backup page flash pattern.
  - **Step 2: Edit storage path labels** — New `SetStorageLabel()` method in `settings.go`. New `POST /settings/storage/label` route + handler. Inline edit UI with ✏️ button, text input, OK/Cancel. Added `.btn-ghost` CSS class.
  - **Step 3: App details per storage path** — Settings page now shows expandable `<details>` list per storage path with app names, sizes, and links to deploy page. New `StorageAppDetail` struct + `appDetailsForPath()` helper. Added CSS for `.storage-app-details`, `.storage-app-list`, `.storage-app-row`.
  - **Step 4: Storage badge on stacks page** — Deployed app cards show "💾 Label" badge indicating which registered storage path the app uses. `StorageLabels` map built from deployed apps' HDD_PATH → registered storage path label lookup. Added `.meta-badge-storage` CSS.
  - **Step 5: Deploy dropdown enhancements** — Storage path dropdown now shows free space ("234 GB szabad"). `DeployStoragePath` struct wraps `StoragePath` with `FreeHuman`/`FreePercent` from `GetDiskUsage()`. JS `checkStorageSpace()` shows yellow warning when selected storage has <20% free.
  - **Step 6: Filesystem & disk info** — New `FSInfo` struct + `GetFSInfo()` in `mounts_linux.go` using `findmnt` command + `/sys/block/` sysfs reads for disk model. Settings page shows "ext4 · /dev/sdb1 · WD Elements" below disk usage bar. Non-Linux stub returns nil.
  - **Step 7: Backup page storage context** — Added `StorageLabel` field to `AppBackupInfo`. Backup page shows storage label badge per app by matching HDD path prefixes against registered storage paths. Uses existing `.meta-badge-storage` CSS.
  - **Files modified (12):** `healthcheck.go`, `settings.go`, `mounts_linux.go`, `mounts_other.go`, `appdata.go`, `handlers.go`, `server.go`, `settings.html`, `stacks.html`, `deploy.html`, `backups.html`, `style.css`

### What was previously completed (2026-02-17 session 26)
- **v0.9.0 — Phase A: Storage Paths Foundation & Backup Toggle Fix:**
  - **Root cause:** Per-app backup toggles (v0.8.0) didn't appear because `controller.yaml` had no `paths.hdd_path` set → `ParseComposeHDDMounts` returned nil. Even with global hdd_path, apps with different HDD_PATH values wouldn't match.
  - **Core fix: Per-app HDD_PATH resolution** — `stackAdapter.GetStackHDDMounts()` now reads each app's own `HDD_PATH` from its `app.yaml` env section (Priority 1), falling back to all registered storage paths (Priority 2). Removed dependency on global `cfg.Paths.HDDPath`.
  - **Storage paths registry** (`settings.json`) — new `StoragePath` struct with Path, Label, IsDefault, Schedulable, AddedAt. Thread-safe CRUD methods in `settings.go` (Get/Add/Remove/SetDefault/SetSchedulable). Multiple external storage paths supported.
  - **Auto-discovery** — On startup, `discoverHDDPaths()` scans deployed apps' `app.yaml` for `HDD_PATH` values. `AutoDiscoverStoragePaths()` registers discovered paths with inferred labels. Legacy `cfg.Paths.HDDPath` used as fallback.
  - **Mount-point validation** — New `mounts_linux.go` (build-tagged): `IsMountPoint()` via `syscall.Stat_t.Dev` comparison, `IsWritable()`, `PathsOverlap()`, `GetDiskUsage()` via `syscall.Statfs`. Non-Linux stubs in `mounts_other.go`.
  - **Settings page "Adattárolók" section** — Lists registered paths with label, path, disk usage bar, app count, badges (default/active/unmounted). Actions: set default, toggle schedulable, remove (with guards). Expandable "Új adattároló hozzáadása" form with 5-step validation (exists, mount point, writable, no overlap, no duplicate).
  - **Deploy page storage dropdown** — `path` field type renders as `<select>` dropdown of schedulable storage paths. Falls back to text input with warning if no paths registered.
  - **Health check storage monitoring** — `RunHealthCheck()` now accepts `storagePaths` parameter. Checks: path accessible (warning), not a mount point (issue — data writes to SSD!), disk usage ≥95% (issue) / ≥90% (warning).
  - **Controller docker-compose.yml** — Changed HDD mount from `${HDD_PATH:-/mnt/hdd_placeholder}:...:ro` to `/mnt:/mnt:rw` for multi-storage support + restore capability.
  - **Removed unused `hddPath` param** from `DiscoverAppData()` signature in backup/appdata.go.
  - **Files created (2):** `system/mounts_linux.go`, `system/mounts_other.go`
  - **Files modified (11):** `settings.go`, `main.go`, `appdata.go`, `backup.go`, `handlers.go`, `server.go`, `settings.html`, `deploy.html`, `style.css`, `healthcheck.go`, `docker-compose.yml`, `report/builder.go`

### What was previously completed (2026-02-16 session 25)
- **v0.8.0 — Phase 7: Storage Overview, Per-App Backup Toggles & Limited Restore:**
  - **Storage overview on backup page** — new "Tárhely áttekintés" section as first section on backup page showing SSD/HDD progress bars + backup repo stats (repo size, dump file count, snapshot count). Reuses existing `system.GetInfo()` and `RepoStats`.
  - **Restic password visibility** — new "Titkosítási kulcs" section inside the repository card. Masked password field with show/copy buttons (JS toggle). Password synced to hub via periodic report for disaster recovery (`ResticPassword` field added to `BackupReport`).
  - **App data discovery** — new `internal/backup/appdata.go`:
    - `StackDataProvider` interface to avoid circular imports between backup and stacks packages
    - `AppBackupInfo`, `AppDataPath`, `AppDockerVolume` structs
    - `DiscoverAppData()` iterates deployed stacks, discovers HDD bind mounts (via adapter calling `ParseComposeHDDMounts`), Docker named volumes (via `parseComposeNamedVolumes` using YAML parser), and DB dump status
    - Stack adapter in `main.go` implements `StackDataProvider` using `stacks.Manager`
  - **Per-app backup toggles** — new "Alkalmazás adatok" section on backup page:
    - Toggle checkbox per app (only for apps with HDD data)
    - Shows HDD paths with sizes, Docker volume info, DB dump notes
    - `POST /settings/app-backup` handler saves preferences to `settings.json`
    - `AppBackupPrefs` struct + bulk getter/setter in `settings.go`
    - `RefreshCache()` populates `AppDataInfo` via `DiscoverAppData()`
  - **Dynamic backup paths** — `RunBackup()` now includes enabled app HDD data paths:
    - `resolveAppBackupPaths()` reads enabled apps from settings, resolves HDD paths via provider
    - Paths logged at INFO level, included in restic snapshot
    - `BackupPaths` display on backup page includes app data paths
  - **Limited app restore** — new restore section on backup page:
    - `RestoreApp()` in `restore.go`: validates enabled, resolves HDD paths, validates snapshot exists, uses running mutex
    - `RestoreAppData()` on `ResticManager`: runs `restic restore` with `--include` flags for specific paths
    - `POST /backup/restore` web handler with confirmation flow
    - `GET /api/backup/snapshots` JSON endpoint for restore dropdown
    - UI: app/snapshot dropdowns, warning box, confirmation checkbox, JS-driven form submission
  - **Exported `ParseComposeHDDMounts`** from stacks package (was unexported `parseComposeHDDMounts`)
  - **Flash messages** on backup page via query params (success/error redirects from handlers)
  - **CSS**: New styles for storage overview grid, app backup toggles, encryption key field, restore section, flash messages
  - **Files created**: `appdata.go`, `restore.go`
  - **Files modified**: `backup.go`, `restic.go`, `handlers.go`, `server.go`, `backups.html`, `style.css`, `settings.go`, `delete.go`, `router.go`, `types.go`, `builder.go`, `main.go`

### What was previously completed (2026-02-16 session 24)
- **v0.7.2 — Fix Notification Preferences Sync (Controller → Hub):**
  - **Two repos changed** (deploy-felhom-compose + felhom.eu):
  - **Hub: `POST /api/v1/preferences` endpoint** (`hub/internal/api/handler.go`):
    - New route in API handler: same Bearer token auth as /report and /notify
    - Accepts JSON payload: `{customer_id, email, enabled_events}`
    - Calls existing `store.SaveNotificationPrefs()` — no store changes needed
    - Logs preference updates at INFO level
  - **Hub: Notification section on customer detail page** (`hub/internal/web/`, `hub/internal/store/store.go`):
    - New `GetRecentNotifications()` store method returns last N notification_log entries
    - `handleCustomerDetail()` loads NotifPrefs + RecentNotifications
    - `joinStrings` template function added for event list display
    - `customer.html` template: new "Notifications" section showing email, events, and last 10 notification log entries (time, event, status, message)
  - **Controller: `SyncPreferences` method** (`internal/notify/notifier.go`):
    - New `preferencesRequest` struct for JSON payload
    - `SyncPreferences(email, enabledEvents)` — synchronous POST to hub `/api/v1/preferences`
    - `IsEnabled()` getter for checking hub connectivity
    - Hungarian error messages for user-facing feedback
  - **Controller: Sync on settings save** (`internal/web/handlers.go`):
    - `settingsNotificationsHandler` now calls `SyncPreferences` after saving to `settings.json`
    - Three flash message variants: success (synced), warning (local save OK, sync failed), error (save failed)
    - Local save always succeeds even if hub sync fails
  - **Controller: Sync on startup** (`cmd/controller/main.go`):
    - Non-blocking goroutine syncs preferences to hub when controller starts
    - Only runs if hub is enabled and email is configured
    - Handles hub DB rebuild recovery (re-populates preferences after hub redeployment)
  - **Files changed**: hub (3 files: handler.go, store.go, server.go, customer.html), controller (3 files: notifier.go, handlers.go, main.go)
  - **Documentation**: README.md updated (version, notify module, phase checklist), CONTEXT.md updated

### What was previously completed (2026-02-16 session 23)
- **v0.7.1 — Phase 2: Monitoring Warnings, Dashboard Alerts & Notification System:**
  - **Three workstreams across two repos** (deploy-felhom-compose + felhom.eu):
  - **Monitoring page "Távoli monitoring" section** (`monitoring.html`, `handlers.go`):
    - New section between System Overview and System Metrics showing healthcheck ping UUID status
    - 5 rows: Heartbeat, System Health, DB Dump, Backup, Backup Integrity — each shows ✅ configured or ⚠️ missing
    - Banner: green (all configured), yellow (some missing), red (monitoring disabled)
    - `isPingConfigured()` helper checks non-empty AND not "CHANGEME" prefix
  - **Dashboard alert banners** (new `alerts.go`, `layout.html`):
    - `AlertManager` struct with `Refresh()` + `GetAlerts()` — generates alerts from health report, missing pings, backup disabled
    - Alert types: `Alert{ID, Level, Message, Link, LinkText}` — levels: error/warning/info
    - Renders colored banners (red/yellow/blue) after `<main class="content">` on all pages
    - Caps at 5 alerts with "+N more" overflow; monitoring page excludes "pings-missing" (shown in table instead)
    - Refreshed every 5 min via system-health scheduler task + once at startup
  - **Hub notification relay** (felhom.eu repo — `hub/internal/api/handler.go`, `hub/internal/store/store.go`):
    - `POST /api/v1/notify` endpoint: Bearer auth, JSON payload (customer_id, event_type, severity, message, details)
    - New `customer_notifications` table (email, enabled_events JSON) + `notification_log` audit table
    - Resend email integration: direct HTTP POST to `https://api.resend.com/emails`
    - Hungarian email template with event details, timestamp, severity
    - `hub.yaml.example` updated with notifications config section
  - **Controller-side notifier** (new `internal/notify/notifier.go`):
    - `Notifier` struct: fires HTTP POST to hub `/api/v1/notify`, non-blocking (goroutine)
    - Cooldown tracking per event type (default 6h, configurable via UI)
    - Checks notification preferences (email configured + event enabled) before sending
    - `NotifyHealthChange()`: only notifies on status degradation (ok→warn, ok→fail, warn→fail)
    - `NotifyBackupFailed/NotifyDBDumpFailed/NotifyIntegrityFailed` convenience methods
    - `SendTest()` for test email flow
    - Wired into scheduler: system-health task calls `NotifyHealthChange()`, backup tasks call failure notifiers
  - **Notification preferences UI** (`settings.html`, `handlers.go`):
    - New "Értesítések" Section C on Settings page (only shown when hub enabled)
    - Email input, 4 event checkboxes (disk_warning, backup_failed, update_available, security_update)
    - Cooldown hours input (default 6)
    - "Mentés" + "Teszt email küldése" buttons
    - Saved to `settings.json` via `NotificationPrefs` struct (Email, EnabledEvents, CooldownHours)
  - **Settings persistence expanded** (`settings.go`):
    - `NotificationPrefs` struct with Email, EnabledEvents, CooldownHours
    - `DefaultEnabledEvents`: disk_warning, backup_failed, update_available
    - `GetNotificationPrefs()` returns defaults if nil, `SetNotificationPrefs()` saves atomically
  - **Files changed**: 3 new (alerts.go, notifier.go, notify package), ~12 modified across both repos
  - **Deployed:** Controller v0.7.1 to demo-felhom.eu, verified healthy (0 alerts on clean system)

### What was previously completed (2026-02-16 session 22)
- **v0.7.0 — Phase 1: Authentication, Persistence & Settings Page:**
  - **New `internal/settings/settings.go`:** Shared persistence layer via `settings.json` in the data directory. Atomic writes (tmp + rename), thread-safe with `sync.RWMutex`. Stores password hash overrides and DB validation cache. Graceful handling if file doesn't exist.
  - **Auth improvements:**
    - Password resolution priority: `settings.json` → `controller.yaml` → none (open dashboard)
    - Startup logs which source is active: `Auth: using password from settings.json/controller.yaml/no password configured`
    - Session duration extended to 7 days (was 24h)
    - `?next=` redirect after session expiry — returns user to the page they were on
    - Flash messages on login page (green info box, used after password change)
    - Conditional logout link — hidden when auth is disabled (no password configured)
    - `invalidateAllSessions()` method for password change flow
  - **New Settings page (`/settings`):**
    - "Rendszer konfiguráció" section: read-only display of controller.yaml values (customer ID/name/domain, git repo/sync interval, backup enabled/schedule, monitoring, healthchecks URL, hub status, controller version)
    - "Jelszó módosítás" section: form with current password, new password, confirm — validates min 8 chars, match check, bcrypt comparison
    - Password saved to `settings.json`, all sessions invalidated, redirect to login with flash message
    - Only shown if auth is enabled; otherwise shows info message to contact operator
  - **Sidebar update:**
    - "Beállítások" menu item with ⚙ icon pinned to bottom (above version/logout)
    - Version and logout link separated from nav links
    - Logout link conditionally shown only when auth is enabled
  - **DB validation persistence:**
    - After each successful dump, validation results saved to `settings.json` (`db_validations` map keyed by filename)
    - Cached data survives container restarts
    - `DBValidationCache` struct with `validated_at`, `table_count`, `has_header`, `error`
  - **10 files changed** (3 new: settings.go, settings.html; 7 modified: main.go, backup.go, auth.go, handlers.go, server.go, layout.html, login.html, style.css)
  - **Deployed:** Controller v0.7.0 to demo-felhom.eu, verified healthy

### What was previously completed (2026-02-16 session 21)
- **v0.6.3 — Bug fixes from v0.6.2 code scan (4 minor fixes):**
  - **Bug 1:** `--hdd-path` in `docker-setup.sh` now uses `require_arg` validation like all other flags. Previously, `--hdd-path` as the last argument without a value would crash with a cryptic bash error under `set -u` instead of a friendly message.
  - **Bug 2:** `stackAction()` in `layout.html` now receives `event` as an explicit parameter instead of relying on the deprecated implicit `window.event`. All 10 onclick call sites in `dashboard.html` and `stacks.html` updated to pass `event` as first argument.
  - **Bug 3:** Page `<title>` now has an em dash separator: `"Vezérlőpult — Felhom.eu"` instead of `"VezérlőpultFelhom.eu"`.
  - **Bug 4:** `nextPruneLabel()` in `funcmap.go` now returns `"ma"` (Hungarian for "today") on Sunday before 4am, consistent with the `nextRunLabel` function. Previously returned the date in `"2006-01-02"` format.
  - **Deployed:** Controller v0.6.3 to demo-felhom.eu, verified healthy

### What was previously completed (2026-02-16 session 20)
- **Hub Dashboard Bugs + Backup Validation Fix (3 bugs):**
  - **Bug 1&2 (Hub repo, felhom-hub v0.1.2):** Hub timestamp parsing failure — `time.Parse` with single hardcoded format silently failed for formats returned by `modernc.org/sqlite`. Added `parseSQLiteTime()` that tries 6 common formats. Fixed: hub main page showing DOWN despite OK status, and report history timestamps showing 00:00:00.
  - **Bug 3 (Controller repo, v0.6.2):** Backup page showing "Hiba" for all DB validations — zero-value `DumpValidation{}` (never assigned) hit the `{{else}}` branch in template. Three fixes:
    - Template: 4-branch guard (Valid → OK / Error → Hiba / zero-value → "–" with tooltip)
    - Debug logging: Added `[DEBUG]` and `[WARN]` log lines to all `ValidateDump()` code paths
    - Re-validation: `RefreshCache()` now cross-checks `lastDBDump` results against fresh `ListDumpFiles()` validation, healing stale in-memory state
  - **Deployed:** Hub v0.1.2 to k3s, Controller v0.6.2 to demo-felhom
  - **Verified:** Controller logs show `ValidateDump OK` for all 3 databases (immich: 60 tables, paperless: 67 tables, romm: 14 tables)

### What was previously completed (2026-02-16 session 19)
- **v0.6.1 — Code Review Bugfixes (7 fixes):**
  - **Fix 1:** `http.NotFound(w, nil)` → pass actual `*http.Request` in `deployHandler` and `appDetailHandler`
  - **Fix 2:** Dashboard running/stopped counts now computed from the filtered `deployedStacks` set (was counting ALL stacks including non-deployed)
  - **Fix 3:** Session cookie `Secure` flag now dynamic based on `r.TLS != nil || X-Forwarded-Proto == "https"`. `SameSite` changed from `Strict` to `Lax` (Strict breaks Cloudflare Tunnel redirects)
  - **Fix 4:** Removed misleading `subtle.ConstantTimeCompare` from `isValidSession()` (map lookup already leaks timing; comparing token to itself is meaningless). Removed unused `token` field from `session` struct. Removed `crypto/subtle` import.
  - **Fix 5:** Replaced `time.Tick()` (goroutine leak) with proper `time.NewTicker` + `done` channel in `cleanupSessions()`. Added `Close()` method to Server. Added `done chan struct{}` to Server struct.
  - **Fix 6:** Added `http.MaxBytesReader(w, req.Body, 1<<20)` (1MB limit) to `deployStack`, `updateOptionalConfig`, `deleteStack` API handlers via `limitBody()` helper.
  - **Fix 7:** Cached `time.LoadLocation("Europe/Budapest")` once at top of `templateFuncMap()`, removed 5 per-function `LoadLocation` calls (timeAgo, fmtTime, fmtTimeShort, nextRunLabel, nextPruneLabel).
  - **Post-fix verification:** All 4 grep checks pass (0 results for NotFound(w,nil), ConstantTimeCompare, time.Tick(, Secure:.*true). `go vet ./...` clean.
  - **Controller version:** v0.6.1 — deployed and verified on demo-felhom.eu

### What was previously completed (2026-02-16 session 18)
- **v0.6.0 — Healthcheck Implementation + Central Push + Hub Dashboard:**
  - **Part 1 — Healthcheck enhancements (controller-side):**
    - Added `heartbeat` ping — lightweight "I'm alive" signal every 5 min (no logic, just ping)
    - Added `backup_integrity` ping — weekly `restic check` on Sunday 04:00, pings healthchecks with result
    - Added `Heartbeat` and `BackupIntegrity` fields to `PingUUIDsConfig`
    - Added `RunIntegrityCheck()` to backup Manager (calls restic Check(), updates lastCheckTime/lastCheckOK, pings)
    - Updated `controller.yaml.example` with new monitoring ping_uuids
    - Created `monitoring/DEPRECATED.md` for legacy bash monitoring scripts
  - **Part 2 — Central hub reporting (controller-side):**
    - New `internal/report/` package: types.go (Report struct), builder.go (BuildReport), pusher.go (HTTP push)
    - Report builder gathers data from all subsystems: system info (via metrics.GetStaticInfo + system.GetInfo), container stats (via metricsStore.QueryContainerSummary), backup status (via backupMgr.GetFullStatus), health (via monitor.RunHealthCheck), stacks (via stackMgr.GetStacks)
    - Report pusher: POST JSON to hub with Bearer token auth, 3 retries with 5s backoff, never fails caller
    - Added `HubConfig` to config.go (enabled, url, api_key, push_interval)
    - Wired hub reporting into scheduler (configurable interval, default 15m)
    - Hub reporting disabled by default (hub.enabled: false)
  - **Part 3 — Hub service (felhom.eu repo, new `hub/` subfolder):**
    - Full Go service: `cmd/hub/main.go`, `internal/api/handler.go`, `internal/store/store.go`, `internal/web/server.go`
    - SQLite store with WAL mode, auto-migration, denormalized fields for fast queries
    - REST API: POST /api/v1/report (Bearer token auth), GET /api/v1/customers, GET /api/v1/customers/{id}, GET /api/v1/customers/{id}/history
    - Dark theme dashboard (English): multi-customer overview table with status indicators, customer detail page with system/storage/containers/backup/health sections
    - Color coding: green (OK, <30min), yellow (warn or 30-60min), red (fail or >60min)
    - K8s manifest: Deployment + Service + Ingress for hub.felhom.eu in felhom-system namespace
    - Dockerfile, Makefile, hub.yaml.example config
    - 90-day report retention with daily auto-prune
  - **Controller version:** v0.6.0 — deployed and verified on demo-felhom.eu (9 scheduler jobs, all new jobs registered)
  - **Manual steps remaining for Viktor (Part 4 of TASK.md):**
    - Create 5 healthcheck checks on status.felhom.eu (heartbeat, system-health, db-dump, backup, backup-integrity)
    - Update controller.yaml on demo-felhom with real UUIDs
    - Build and deploy felhom-hub to k3s cluster
    - Configure hub.felhom.eu DNS in Cloudflare
    - Enable hub reporting on demo-felhom controller.yaml

### What was previously completed (2026-02-16 session 17)
- **v0.5.4 — Monitoring Page Frontend Fixes (4 bugs, frontend-only):**
  - **Bug 1: Tooltip "Invalid Date"** — `items[0].parsed.x` unreliable across Chart.js versions. Fixed tooltip callback to use `items[0].raw.x` (direct {x,y} data access) with `parsed.x` as fallback.
  - **Bug 2: Charts fill full width regardless of data density** — `setChartXBounds()` setting `min/max` at runtime was ignored because the scale was created without them. Fixed by including `min: now - defaultRangeMs, max: now` in the initial `chartOpts()` options. Now "7 nap" shows full 7-day x-axis with data clustered on the right.
  - **Bug 3: Sysinfo values not consistently right-aligned** — `.sysinfo-grid` used `auto-fill` creating variable-width cells. Fixed to `1fr 1fr` (fixed 2-column). Added `align-items: baseline`, `gap: 1rem`, `white-space: nowrap` on labels, `font-weight: 600` + `word-break: break-word` on values. Removed redundant `<style>` block from monitoring.html (styles now in style.css).
  - **Bug 4: Charts overflow on mobile** — Added `min-width: 0` on `.chart-box` (critical CSS grid fix), `overflow: hidden` + `max-width: 100%` on `.chart-wrap` and `.chart-wrap-bar`, `max-width: 100%` on canvas.
  - **Controller version:** v0.5.4 — deployed and verified on demo-felhom.eu

### What was previously completed (2026-02-16 session 16)
- **v0.5.1 — Monitoring Page Bugfixes:**
  - **Bug 1: Hostname** — `os.Hostname()` returns the container ID inside Docker. Fixed by mounting `/etc/hostname:/host/etc/hostname:ro` and reading it first in `sysinfo.go`. Now shows `demo-felhom`.
  - **Bug 2: Tooltip timestamps** — Chart.js tooltip callback used `items[0].parsed.x` (category index 0,1,2...) instead of `items[0].label` (actual timestamp). Index 0 worked by accident (`0 || label` falls through), but all other points showed 1970-01-01.
  - **Bug 3+4: Default range + empty charts** — Default range was `24h` but new system had only minutes of data. Changed to `1h` default for both system and container detail charts. Moved `active` class to "1 óra" button.
  - **Controller version:** v0.5.1 — deployed and verified on demo-felhom.eu

### What was previously completed (2026-02-16 session 15)
- **v0.5.0 — Backup Bugfixes + Monitoring Page with Metrics Store:**
  - **Task 1: Fixed "Helyi mentés" showing "–" after restart** — `GetFullStatus()` now synthesizes `LastBackup` from `SnapshotHistory` and `LastDBDump` from `DumpFiles` on disk when the in-memory values are nil (e.g., after controller restart). Dashboard handler also updated to use `GetFullStatus()` instead of `GetStatus()` for consistent behavior.
  - **Task 2: Verified backup page caching** — Already implemented in v0.4.7 (`RefreshCache`, scheduler job, `AfterBackup` callback). No changes needed.
  - **Task 3: New Monitoring Page ("Rendszermonitor")** — Full system monitoring subsystem:
    - **SQLite metrics store** (`internal/metrics/store.go`, `types.go`): WAL-mode SQLite via `modernc.org/sqlite` (pure Go, no CGO). Stores system metrics (CPU%, memory, temperature, load) and container metrics (CPU%, memory, net/block I/O) with timestamp. Downsampled queries via bucket-based `GROUP BY` for Chart.js. 30-day auto-prune via daily scheduler job at 04:00.
    - **Metrics collector** (`internal/metrics/collector.go`): Background goroutine collects system + container metrics every 60 seconds. System data from `system.GetInfo()`, container data from `docker stats --no-stream` with tab-separated format parsing.
    - **System info provider** (`internal/metrics/sysinfo.go`, `sysinfo_other.go`): Reads hostname, OS, kernel, CPU model/cores, uptime from `/proc` filesystem. Linux-specific with build-tag fallback for cross-compilation.
    - **REST API endpoints** (4 new routes in `router.go`): `GET /api/metrics/system` (time-series with range presets), `GET /api/metrics/containers/summary` (current stats), `GET /api/metrics/containers/{name}` (per-container time-series), `GET /api/metrics/sysinfo` (static system info).
    - **Monitoring page template** (`monitoring.html`): 5 sections — System Overview (sysinfo via API), System Metrics Charts (4 line charts: CPU, Memory, Temperature, Load in 2×2 grid), Container Resources (2 horizontal bar charts: CPU% and Memory), Per-container Detail (click to expand with historical charts), Storage (server-rendered progress bars). Time range selectors (1h/6h/24h/7d/30d). Auto-refresh every 60s.
    - **Chart.js 4.4.7** embedded locally (offline environments, ~200KB UMD), dark theme configuration matching site design.
    - **CSS**: ~100 lines added for monitoring page (`.monitor-card`, `.charts-grid`, `.chart-box`, `.container-charts-row`, `.storage-bars`, responsive rules).
    - **Wiring**: 4th sidebar nav item "Rendszermonitor", metrics DB path in named volume (`data/metrics.db`), `/etc/os-release:/host/etc/os-release:ro` volume mount in docker-compose.yml, Dockerfile updated to `golang:1.24-bookworm` (required by `modernc.org/sqlite`), `go.mod` upgraded to `go 1.24.0`.
  - **Controller version:** v0.5.0 — deployed and verified on demo-felhom.eu (metrics collecting, 16 containers reporting, sysinfo showing Intel N100 correctly)

### What was previously completed (2026-02-16 session 14)
- **v0.4.7 — Protected Stack Detail Pages + Backup Page Caching:**
  - **Protected stacks clickable** — `data-href` gating changed from `{{if not .Protected}}` to `{{if .Meta.Slug}}` on both `stacks.html` and `dashboard.html`. Protected stacks with `.felhom.yml` (i.e. a slug) are now clickable, linking to `/apps/{slug}`. Stacks without `.felhom.yml` remain non-clickable.
  - **"Részletek" button for protected stacks** — Protected stack action section in `stacks.html` now shows a "Részletek" link when the stack has a slug, next to the restart button.
  - **FileBrowser `.felhom.yml` resources** — Added `resources` section (mem_request: 128M, mem_limit: 256M, pi_compatible: true, needs_hdd: true) to both `install_filebrowser()` in `docker-setup.sh` and manually on the demo node. FileBrowser detail page now shows memory/Pi/HDD badges.
  - **Backup page caching** — `GetFullStatus()` no longer runs expensive subprocess calls (restic stats, docker inspect, disk listing) on every page load. Instead, a new `RefreshCache()` method runs these in the background:
    - Every 5 minutes via `backup-cache` scheduler job
    - After each successful backup via `AfterBackup` callback
    - On startup via a goroutine (non-blocking)
  - `GetFullStatus()` returns the cached `FullBackupStatus` instantly, updating only dynamic fields (running flag, next run times, snapshot history). Falls back to a minimal status if cache hasn't populated yet.
  - **Controller version:** v0.4.7 — deployed and verified on demo-felhom.eu

### What was previously completed (2026-02-16 session 13)
- **v0.4.6 — MariaDB Validation Fix + Dashboard & Protected Stack UX:**
  - **Bugfix: MariaDB dump validation false positive** — MariaDB 11.4+ prepends `/*M!999999\- enable the sandbox mode */` before the dump header comment. `ValidateDump()` now scans the first 10 lines for the expected header pattern instead of just checking line 1. Accepts `-- MariaDB dump`, `-- MySQL dump`, `-- mysqldump` for MariaDB and `-- PostgreSQL database dump` for PostgreSQL.
  - **Dashboard shows deployed apps only** — `dashboardHandler()` filters to deployed + protected stacks only. Non-deployed apps remain on the Alkalmazások page. Section heading changed to "Telepített alkalmazások". `TotalCount` stat card still shows all 52 apps.
  - **Protected stack restart button** — Protected stacks (traefik, cloudflared, felhom-controller, filebrowser) now show an "Újraindítás" restart button when operational, on both dashboard (compact ↻) and Alkalmazások page (full button). "Védett" / "Védett rendszerkomponens" badge still shown.
  - **API protection guard** — Centralized guard in `actionStack()` blocks all actions except `restart` on protected stacks (HTTP 403). Defense-in-depth: `StopStack()` and `DeleteStack()` retain their own guards.
  - **FileBrowser `.felhom.yml`** — `install_filebrowser()` in `docker-setup.sh` now creates `.felhom.yml` with `subdomain: files` metadata, so the controller shows the `files.DOMAIN ↗` URL link. Manually created on demo node.
  - **Controller version:** v0.4.6 — deployed and verified on demo-felhom.eu

### What was previously completed (2026-02-16 session 12)
- **v0.4.5 — Dedicated Backup Page ("Biztonsági mentés"):**
  - **New `/backups` page** with full backup system visibility — 5 sections:
    1. **Status overview cards**: Local backup status (green/gray), remote placeholder (gray), DB count, repo size
    2. **Schedule section**: DB dump/restic/prune schedule with next-run times, last backup time + duration, retention policy, "Mentés most" button
    3. **Database table**: Lists all discovered DBs with type badge (PostgreSQL/MariaDB), dump file size, last dump time, validation (table count), status
    4. **Snapshot history table**: Last 20 snapshots with ID, time, data added, files new/changed
    5. **Repository info card**: Path, size, snapshot count, integrity check status, backed-up paths list, remote copy placeholder
  - **Backend extensions:**
    - `SnapshotRecord` type + ring buffer (20 entries) in Manager for per-snapshot stats
    - `DumpValidation` — scans dump files for CREATE TABLE statements, validates header and file size
    - `ValidateDump()` runs after each successful dump in `DumpOne()`
    - `ListDumpFiles()` scans dump directory for existing `.sql` files (fallback when in-memory results empty)
    - `ListSnapshots()` on ResticManager — returns all snapshots from restic (newest first)
    - `GetFullStatus()` on Manager — single call returns everything the page needs
    - `LoadSnapshotHistory()` populates history from restic on startup (without delta stats)
    - Restic check result tracking (`lastCheckTime`, `lastCheckOK`)
    - `NextDailyRun()` exported from scheduler for next-run time calculation
  - **Server wiring:**
    - `Server` struct now holds `*scheduler.Scheduler`
    - `NewServer()` accepts scheduler parameter
    - `/backups` route + `backupsHandler()` in handlers.go
  - **New template functions** (`funcmap.go`): `timeAgo`, `fmtTime`, `fmtTimeShort`, `dbTypeLabel`, `nextRunLabel`, `pruneLabel`, `nextPruneLabel`, `fmtDuration`, `fmtBytes`, `shortID`
  - **Navigation**: Sidebar now has 3 items (Vezérlőpult, Alkalmazások, Biztonsági mentés)
  - **Dashboard**: Backup card title is now a clickable link to `/backups`
  - **Auto-refresh**: Page polls `/api/backup/status` every 3s during backup-in-progress, reloads when complete
  - **CSS**: Full dark-theme styles for schedule card, database table, snapshot table, repository card, validation badges, DB type badges, empty state
  - **Controller version:** v0.4.5 — deployed and verified on demo-felhom.eu (2 historical snapshots loaded)

### What was previously completed (2026-02-15 session 11)
- **v0.4.1 — App Filtering + Bugfixes:**
  - **Filter bar on Alkalmazások page**: Four pill-shaped filter buttons (Mind/Futó/Leállítva/Telepíthető) with live count badges computed from DOM. Filters stack cards via `display: none`, updates URL with `?filter=running` via `history.replaceState`. Reads filter from URL on page load for deep-linking support.
  - **New `filterCategory` template function** (`funcmap.go`): Maps container state + deployed flag to filter categories (running/stopped/available). Each stack card gets a `data-filter-state` attribute for client-side filtering.
  - **Clickable dashboard stat cards**: Stat cards (Futó/Leállítva/Összes) changed from `<div>` to `<a>` with `href` linking to `/stacks?filter=running`, `/stacks?filter=stopped`, `/stacks` respectively. Hover effect with translateY + box-shadow.
  - **docker-compose.yml synced to demo node**: Fixed the stale compose file that still had `dashboard.${DOMAIN}` Traefik label (from pre-v0.3.0). Now uses correct `felhom.${DOMAIN}` label + `/sys:/host/sys:ro` mount.
  - **Controller version:** v0.4.1 — deployed and verified on demo-felhom.eu
  - **Remaining manual tasks for Viktor (Task 2 & 3 from TASK.md):**
    - Verify `felhom.demo-felhom.eu` resolves correctly (Cloudflare Tunnel public hostname may need updating from `dashboard.*` to `felhom.*`)
    - Update Pi-hole local DNS if applicable
    - Enable backup in `controller.yaml` on demo node (`backup.enabled: true`)
    - Create `/srv/backups` directories on demo node

### What was previously completed (2026-02-15 session 10)
- **v0.4.0 — Monitoring & Health + Backups (Phase 2 & 3):**
  - **Central job scheduler** (`internal/scheduler/scheduler.go`):
    - Replaces ad-hoc goroutines in main.go with a unified scheduler
    - `Every(name, interval, fn)` for periodic jobs, `Daily(name, timeStr, fn)` for scheduled tasks
    - Panic recovery, skip-if-running, quiet mode for high-frequency jobs (≤30s)
    - Daily jobs use `Europe/Budapest` timezone with `time.Timer` for DST correctness
    - Graceful shutdown with 30s timeout for running jobs
  - **CPU usage collector** (`internal/system/cpu_linux.go`):
    - Background goroutine samples `/proc/stat` every 5s, computes delta-based CPU %
    - Platform stubs for non-Linux in `cpu_other.go`
  - **Temperature & load metrics** (`internal/system/info_linux.go`):
    - Reads `/proc/loadavg` for 1/5/15 min load averages
    - Reads thermal zones from `/host/sys/class/thermal/` (Docker mount) with `/sys/` fallback
    - Handles millidegree values, picks highest zone, with hwmon fallback
  - **Healthchecks.io pinger** (`internal/monitor/pinger.go`):
    - HTTP ping client for Healthchecks.io-compatible endpoints
    - POST to `/ping/{uuid}` (success), `/fail` (failure), `/start` (started)
    - 10s timeout, 3 retries with 2s backoff, skips CHANGEME UUIDs
  - **System health checks** (`internal/monitor/healthcheck.go`):
    - Checks disk, memory, CPU, temperature, Docker reachability, protected containers
    - Returns HealthReport with status "ok"/"warn"/"fail" + formatted message for pings
  - **Database dump engine** (`internal/backup/dbdump.go`):
    - Auto-discovers PostgreSQL/MariaDB containers via `docker ps` + `docker inspect`
    - Dumps via `docker exec pg_dump`/`mariadb-dump` with 5min timeout
    - Atomic writes (`.tmp` → `.sql`), empty file detection, stale temp cleanup
  - **Restic integration** (`internal/backup/restic.go`):
    - Auto-generates repository password (32 random bytes, base64url)
    - Init, snapshot (JSON output), prune, check, stats, latest snapshot
    - Stale lock detection with automatic unlock + retry
  - **Backup orchestrator** (`internal/backup/backup.go`):
    - DB dumps + restic snapshots, weekly prune on Sundays
    - Thread-safe running flag, Healthchecks.io pings with results
    - `RunFullBackup()` for manual trigger (sequential: dumps → snapshot)
  - **Wiring updates:**
    - `main.go`: scheduler-based job registration, cpuCollector lifecycle, pinger + backupMgr init
    - `api/router.go`: `GET /api/backup/status`, `POST /api/backup/run`
    - `web/server.go` + `handlers.go`: pass cpuCollector to GetInfo(), backup status on dashboard
    - `funcmap.go`: `tempColor`, `fmtTemp`, `fmtLoad` template functions
  - **Dashboard UI enhancements:**
    - CPU usage bar with load average display below
    - Temperature with colored indicator dot (green/yellow/red at 60°/75°C)
    - Backup status card: last run time, DB count, repo size/snapshots
    - "Mentés most" button triggers manual backup via API
  - **Config updates:**
    - `controller.yaml.example`: added `system_health_interval`, `hdd_path`, `system.reserved_memory_mb`
    - `docker-compose.yml`: added `/sys:/host/sys:ro` mount for temperature reading
    - `restic_password_file` default changed to `data/` subdir (auto-generated in named volume)
- **Controller version:** v0.4.0 — deployed and verified on demo-felhom.eu

### What was previously completed (2026-02-15 session 9)
- **v0.3.0 — Structural refactoring (templates + server split + domain rename):**
  - **Templates: go:embed migration** — moved all 7 HTML templates + CSS from Go string constants to individual files in `internal/web/templates/`. Created `embed.go` with `//go:embed` directive. Template loading now uses `ParseFS()` instead of `Parse()`. CSS served from embed.FS via `ReadFile()`. Zero runtime file dependencies — still compiled into the binary.
  - **Server decomposition** — split monolithic `server.go` (540 lines) into focused files:
    - `auth.go`: session struct, auth middleware, login/logout handlers, session management
    - `handlers.go`: page handlers (dashboard, stacks, logs, deploy, app detail)
    - `funcmap.go`: template FuncMap with 14 custom functions
    - `server.go`: Server struct, NewServer, loadTemplates (3-liner), ServeHTTP routing, render helper, static file serving
  - **Domain rename** — controller subdomain changed from `dashboard.*` to `felhom.*` in Traefik labels and setup script
  - **Documentation updated** — CLAUDE.md, README.md, CONTEXT.md all reflect new file structure
  - **Reminder for Viktor:** Update Cloudflare Tunnel public hostname (`dashboard.demo-felhom.eu` → `felhom.demo-felhom.eu`) and Pi-hole DNS if needed
- **Controller version:** v0.3.0

### What was previously completed (2026-02-15 session 8)
- **FileBrowser as infrastructure service:**
  - Created `scripts/hdd-setup.sh` (adapted from deploy-portainer) — sets up HDD folder structure with `Dokumentumok` user dir
  - Created `scripts/docker-setup.sh` (adapted from deploy-portainer) — installs Docker, Traefik, FileBrowser as infra services
  - Added `filebrowser` to protected stacks in `controller.yaml.example`
  - Removed `templates/filebrowser/` from app-catalog-felhom.eu (no longer a catalog app)
- **Orphan stack detection and deletion:**
  - Added `Orphaned` field to Stack struct + `getCatalogTemplateSlugs()` helper
  - Orphan detection in `ScanStacks()` — deployed stacks with no matching catalog template marked as orphaned
  - New `delete.go`: `DeleteStack()` (compose down + HDD cleanup + dir removal), `GetStackHDDData()`, `parseComposeHDDMounts()`
  - Safety: protected HDD paths (root, media, storage, Dokumentumok, appdata) can never be deleted
  - New API endpoints: `DELETE /api/stacks/{name}` and `GET /api/stacks/{name}/hdd-data`
  - UI: orange "Elavult" badge on orphaned stacks, "Törlés" button, delete confirmation modal
  - Modal shows HDD data paths/sizes, checkbox for "Felhasználói adatok törlése a merevlemezről"
  - Hides "Frissítés" and "Részletek" buttons for orphaned stacks
- **Verified:** 1 orphaned stack detected on startup (filebrowser — now infra, removed from catalog)
- **Controller version:** v0.2.15

### Previously completed (2026-02-14 session 7)
- **Fixed YAML parse error in romm `.felhom.yml`** (app-catalog repo):
  - Root cause: Hungarian opening quote `„` (U+201E) paired with ASCII `"` (0x22) inside YAML double-quoted strings terminated the string prematurely
  - Affected lines: `help_text` for IGDB Client Secret and SteamGridDB API Key fields
  - Fix: escaped inner ASCII double quotes with `\"` in the YAML strings
  - This caused `LoadMetadata()` to silently fail and return empty defaults for ALL romm metadata (tagline, resources, category — everything)
- **Added error logging to `LoadMetadata()`** in `metadata.go`:
  - `[ERROR]` log on YAML parse failure (was silently swallowed — critical bug)
  - Temporary `[DEBUG]` log used for diagnosis, then removed
- **Fixed deploy command in CLAUDE.md**:
  - `sed` pattern now targets only `image:` lines (was matching service name too, breaking YAML)
  - Added `sudo` for both sed and docker compose (directory is root-owned)
- **Controller version:** v0.2.14

### Previously completed (2026-02-14 session 6)
- **Bug fix: App info logo SVG rendering** — `.app-info-logo` CSS in `templates.go`:
  - Added `min-width`, `min-height`, `max-width`, `max-height: 80px` and `overflow: hidden`
  - Prevents SVG images with explicit dimensions or no viewBox from overflowing container
  - Logo now reliably renders at 80x80 regardless of SVG intrinsic size
- **Controller version:** v0.2.12

### Previously completed (2026-02-14 session 5)
- **App detail/info pages** — new feature:
  - New route: `GET /apps/{slug}` renders a full info page (was redirect to deploy page)
  - Hero section with logo, tagline, resource badges
  - Screenshots section (graceful — hidden via `onerror` if assets don't exist)
  - Info cards: use cases, first steps, prerequisites, default credentials, docs link
  - Optional config form with AJAX save (POST `/api/stacks/{name}/optional-config`)
  - New `.felhom.yml` fields: `app_info` (tagline, use_cases, first_steps, prerequisites, default_creds, docs_url) and `optional_config` (groups of env var fields)
  - New structs in `metadata.go`: `AppInfo`, `OptionalConfigGroup`, `OptionalConfigField`
  - `UpdateOptionalConfig` in `deploy.go`: saves optional env vars to `app.yaml`, restarts deployed stacks with `docker compose up -d` to pick up new env vars
  - Navigation updated: stack cards on dashboard/stacks pages now link to `/apps/{slug}`, deploy page has "Részletek" link back to info page
- **RoMM metadata updated** (app-catalog repo):
  - Full `app_info` section: tagline, 5 use cases, 6 first steps, 3 prerequisites, default creds, docs URL
  - 6 optional config fields for metadata providers: IGDB (client_id + secret), SteamGridDB, ScreenScraper (user + password), MobyGames
  - docker-compose.yml updated with SCREENSCRAPER_USER, SCREENSCRAPER_PASSWORD, MOBYGAMES_API_KEY env vars
  - Display name fixed: "ROMM" → "RomM"
- **Controller version:** v0.2.11

### Previously completed (2026-02-14 session 4)
- **Fixed deploy race condition** in `internal/stacks/deploy.go`:
  - In-memory `Deployed` flag now set BEFORE `docker compose up -d` (compose up can take 30-60s for image pulls)
  - On failure: both in-memory state and disk (app.yaml) are reverted
  - Eliminates stale "Telepítés" button during long compose operations
- **Added `checkBeforeDeploy()` JS guard** in `internal/web/templates.go`:
  - Telepítés buttons on Vezérlőpult and Alkalmazások pages now fetch live state from `/api/stacks/{name}` before navigating
  - If app is already deployed (e.g., another tab deployed it), shows alert and reloads page instead of navigating to deploy form
  - Catches stale UI state gracefully

### Previously completed (2026-02-14 session 3)
- **Enhanced debug logging** across all stack operations in `internal/stacks/`:
  - **Operation timing**: All stack ops (start, stop, restart, update, deploy) now log elapsed time
  - **Post-start container state check**: Async goroutine after start/restart/update/deploy
  - **Image pull detection**: Checks local images before deploy/update (debug level)
  - **GetLogs/ScanStacks improvements**: Byte count logging, deployed/available counts
  - All verbose checks gated on `cfg.Logging.Level == "debug"`; timing always at INFO
- **UI improvements** in `internal/web/templates.go` and `server.go`:
  - **Memory bar fix on deploy page**: Bar segments now always visible (min-width: 3px), new app segment uses translucent green with distinct border for clear visual separation from committed memory
  - **Clickable app cards**: Cards on Vezérlőpult and Alkalmazások pages are now clickable (navigates to deploy/detail page). Uses `data-href` attribute + delegated click handler. Protected stacks excluded. Actions area (buttons, state labels) excluded from click-to-navigate
  - **Live-scrolling logs**: Logs page now auto-refreshes every 3s via AJAX polling (`?raw=1` returns plain text). Fixed-height container (70vh) with auto-scroll to bottom. Pulsing green "Élő" indicator. Pause/resume toggle ("Szüneteltetés"/"Folytatás"). User scroll position preserved when scrolled up to read history
  - **Deployment progress UI**: Deploy button no longer shows alert+redirect immediately. Instead shows 3-step progress panel: config saved → containers starting → app initializing. Polls `GET /api/stacks/{name}` every 3s to track actual container health state. Handles running (auto-redirect), starting (keep polling), unhealthy (warning), exited (error), and 120s timeout. Shows elapsed time counter
- **Mealie healthcheck fix** (app-catalog-felhom.eu):
  - `wget --spider` replaced with Python TCP socket check — mealie image doesn't include wget
  - `start_period` increased to 60s (DB migrations take ~40s on first start)
- **Healthcheck audit**: filebrowser (Alpine, has BusyBox wget — OK), stirling-pdf (Ubuntu, has wget — OK)

### Previously completed (2026-02-15 session 2)
- **Phase 4: Git Sync + App Catalog Audit** — major milestone
- **Git sync module** (`internal/sync/sync.go`):
  - Clones/pulls app-catalog-felhom.eu repo to local cache on startup
  - Periodic sync based on `git.sync_interval` (default 15m)
  - Copies `docker-compose.yml` + `.felhom.yml` to stacks dir (never overwrites `app.yaml`/`.env`)
  - SHA-256 content comparison — only writes changed files
  - Triggers `ScanStacks()` after sync so dashboard updates immediately
  - Uses `os/exec` git CLI — no Go git library dependency
- **Manual sync button** ("Sablonok frissítése") on Alkalmazások page:
  - `POST /api/sync` endpoint with 30s debounce
  - Toast notification shows result (success/failure/what changed)
  - Auto-reloads page if new apps or updates detected
- **Sync status** added to `/api/system/info` (last_sync, last_status, syncing flag)
- **.felhom.yml files created for all 10 apps** (paperless-ngx already had one):
  - actualbudget, docmost, filebrowser, homebox, immich, mealie, romm, stirling-pdf, vaultwarden
  - All follow the same format: display_name, description, category, subdomain, resources, deploy_fields
- **Docker Compose templates audited and fixed** for all 10 apps:
  - Fixed `{{DOMAIN}}` → `${DOMAIN}` syntax in homebox, mealie, romm, stirling-pdf
  - Fixed `{{HDD_PATH}}` → `${HDD_PATH}` in romm
  - Added `deploy.resources.limits.memory` to all services across all templates
  - Added `TZ=Europe/Budapest` to all sidecar services (postgres, redis, mariadb)
  - Added healthcheck to romm main service
  - Added `romm-redis` `condition: service_healthy` (was `service_started`)
  - Standardized header comment blocks across all templates
- **Documentation updated**: app-catalog README, CLAUDE.md, CONTEXT.md

### Previously completed (2026-02-15 session 1)
- **Memory validation during deployment**:
  - Pre-deploy memory check: compares `mem_request` sum against usable system RAM
  - Hard block if requests exceed usable memory (total - 384MB reserved)
  - Soft warning if `mem_limit` sum exceeds total RAM (overcommit OK for limits)
  - `ParseMemoryMB()` supports "500M", "1G", "1.5G", "1024" formats
  - `CommittedMemory()` sums requests/limits across all deployed stacks
  - Memory summary bar shown on deploy page before user clicks deploy
  - `system.reserved_memory_mb` configurable in controller.yaml (default: 384)
- **Display: `~` prefix on mem_request** in UI badges (display-only, exact value stored)
- **Felhom.eu logo** replaced text logos in sidebar and login page with actual SVG logo
  - Logo SVG embedded as Go string constant, served at `/static/felhom-logo.svg`

### Previously completed (2026-02-14)
- **System info bar on Vezérlőpult dashboard**: RAM, SSD, and optional HDD usage
  - Progress bars with color coding (green < 70%, yellow 70-85%, red > 85%)
  - New `internal/system` package reads `/proc/meminfo` + `syscall.Statfs`
  - Platform-specific: Linux impl + non-Linux stub (build tags)
  - Hungarian labels: "Memória", "SSD tárhely", "Külső HDD"
- **Docker Compose memory limits** on paperless-ngx template:
  - paperless-webserver: 768M, postgres: 256M, redis: 128M
  - Added `mem_limit` field to `.felhom.yml` ResourceHints (total: 1152M)
- **`/api/system/info` endpoint** now returns live system metrics (was customer info)
- **Config**: Added `paths.hdd_path` for external HDD monitoring
- Controller image builds via build.sh, pushes to Gitea container registry

### Previously completed (2026-02-13)
- Built the entire felhom-controller from scratch (Go, no frameworks)
- Debugged and fixed 7 issues during first real deployment:
  1. Password validation (empty passwords accepted)
  2. In-memory Deployed flag not updating after deploy
  3. Health-aware state parsing (starting/unhealthy detection)
  4. Random card ordering (Go map iteration)
  5. "Részletek" button redirect for deployed apps
  6. Paperless OCR language installation (LANGUAGES vs LANGUAGE env var)
  7. Documentation: restart vs up -d for image updates

### What's next (priorities)
1. **Test per-app backup** — enable backup for Paperless-ngx HDD data, trigger manual backup, verify restic snapshot includes HDD paths
2. **Test restore** — restore app data from snapshot, verify file recovery (now possible with /mnt:rw mount)
3. **Deploy Immich** — tests HDD path + secrets + multi-storage (biggest real-world test)
4. Add `app_info` + `optional_config` to more apps (Immich, Mealie, Vaultwarden)
5. Test on Raspberry Pi (pi-customer-1)
6. Self-update mechanism
7. Hub alerting (webhook to Healthchecks for stale customers)
8. Docker volume backup (mount `/var/lib/docker/volumes:ro` into controller)