From 9d05fa5c3590de4225a9fbbeb2b4b57e52cc6450 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 12 Jul 2026 12:26:30 +0200 Subject: [PATCH] =?UTF-8?q?controller=20v0.121.0:=20backups=20page=20truth?= =?UTF-8?q?=20pass=20=E2=80=94=20remove=20dead=20R=C3=A9szletek=20card,=20?= =?UTF-8?q?real=20Tier-3=20off-box=20state,=20SQLite-honest=20DB=20messagi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MinAgent: 0.81.0 (unchanged). Controller-only; no agent-API change, no backup-engine behavior change. Fixes the self-contradicting /backups page (v0.120.0 live): - Remove the dead "Részletek" card (redundant; kills never-set Tier2DriveGroups/ ResticPassword fields + restic-pw element + toggleTier/toggleResticPw/copyResticPw JS). - Per-app "3. mentés" row shows real off-box state via pure tier3State (unconfigured/off/escrow_pending/active) — "Hamarosan" placeholder gone. - SQLite-honest DB messaging via pure dbSectionState (dumps/pending/embedded). - Populate Tier1LastRun/Tier1LastStatus from ListRestorePoints; Tier-1/Tier-2 labels via timeAgoStr (relative), confirm() dialog keeps raw timestamp. - Terminology split: "Távoli mentés (3. mentés)" (off-box, +#offbox-section anchor) vs "Távoli rendszermentés" (PBS whole-CT). - Deploy page: add "Mentési beállítások →" link. +9 internal/web tests (pure helper tables + buildAppBackupRows wiring + template renders), 4 companion red-proofs run→fail→revert. --- CHANGELOG.md | 39 ++ CONTEXT.md | 22 +- REPORT.md | 123 ++++--- controller/README.md | 15 +- controller/internal/web/backup_page_state.go | 49 +++ .../internal/web/backup_page_state_test.go | 332 ++++++++++++++++++ controller/internal/web/handlers.go | 37 +- .../internal/web/templates/backups.html | 159 +++------ controller/internal/web/templates/deploy.html | 2 +- 9 files changed, 604 insertions(+), 174 deletions(-) create mode 100644 controller/internal/web/backup_page_state.go create mode 100644 controller/internal/web/backup_page_state_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ab61f..57c99ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ ## Changelog +### v0.121.0 — backups page truth pass (dead sections removed, real Tier-3 state, SQLite-honest DB) (2026-07-12) — MinAgent: 0.81.0 + +Pure UI/data-plumbing on `/backups`; no backup-engine behavior change, no agent-API change, MinAgent +UNCHANGED (0.81.0). The live demo page (v0.120.0) contradicted itself — a dead "Részletek" card +claimed "Nincs 2. szintű mentés konfigurálva" while six Tier-2 runs showed above it; every per-app +"3. mentés" row said "Hamarosan — B2/S3/SFTP" while the off-box (Storage Box) tier was live with 6 +snapshots; and an embedded-SQLite-only box rendered "Adatbázis mentve: 0" / "Nem található adatbázis +mentés." as if backups were failing. + +- **Dead "Részletek" card removed** (operator-approved as redundant — per-app rows + the Adatbázisok + section already carry the truth). This deletes the last references to the never-set template fields + `Tier2DriveGroups` and `ResticPassword`, the `restic-pw` element, and the `toggleTier` / + `toggleResticPw` / `copyResticPw` JS. +- **Per-app "3. mentés" row now shows real off-box state** — a four-state row driven by a new pure + `tier3State` (configured → toggle → escrow precedence): `unconfigured` ("Nincs beállítva" + + Beállítás link), `off` ("Kikapcsolva" + Bekapcsolás link → `#offbox-section`), `escrow_pending` + ("Kulcsletétre vár" — never a false success while the fork-4 escrow gate holds), `active` (status + badge from the global off-box `LastStatus`, `restic → `, relative last-run). The "hamarosan" + placeholder is gone everywhere. +- **SQLite-honest DB messaging** — a new pure `dbSectionState(discovered, dumps)` picks + `dumps` / `pending` / `embedded`. Embedded-only boxes now render "–" + "beágyazott DB-k a + kötetmentésben" on the stat card and an explanation ("…beágyazott adatbázist használnak (pl. + SQLite)…") instead of a bare "0" / "Nem található adatbázis mentés."; a discovered-but-not-yet-dumped + box shows "…az első ütemezett mentés éjjel fut le." +- **Three dead/raw display fields fixed** — `Tier1LastRun`/`Tier1LastStatus` (previously never + assigned) are now populated from `ListRestorePoints` (newest recovery-unit artifact time; correct + per-drive resolution — no fabricated time for unit-less apps); Tier-1 and Tier-2 last-run labels + now render via `timeAgoStr` (relative time) instead of raw RFC3339 (the restore confirm() dialog + keeps the precise timestamp by design). +- **Terminology split** — the off-box section is retitled "Távoli mentés (3. mentés) — titkosított, + offsite" (with an `#offbox-section` anchor); the whole-guest PBS stat card is relabeled "Távoli + rendszermentés" so two different features no longer share one customer-facing name on one page. +- **Deploy page** — the app backup card gains a "Mentési beállítások →" link to the tier-2 config panel. +- **Tests:** +9 in `internal/web` (pure `dbSectionState`/`tier3State` truth tables; `buildAppBackupRows` + off-box mapping + escrow-pending precedence + Tier-1-from-restore-points wiring; template renders for + all four Tier-3 states, embedded/pending DB messaging, and relative-time formatting). Four companion + red-proofs run→fail→revert (re-insert "hamarosan"; hardcode OffboxEnabled=false; dbSectionState + ignores discovered; drop the Tier1LastRun assignment). + ### v0.120.0 — dead-app alerting (fix-3) + debug-ring revision (fix-6) — CLOSES CAMPAIGN-3 (2026-07-12) — MinAgent: 0.81.0 The last CAMPAIGN-3 findings (`felhom.eu/documentation/audits/CAMPAIGN-3-2026-07-11.md`). MinAgent diff --git a/CONTEXT.md b/CONTEXT.md index 0062390..3a5b07d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,27 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-12 (v0.120.0 — dead-app alerting fix-3 + ring revision fix-6; CLOSES CAMPAIGN-3) +Last updated: 2026-07-12 (v0.121.0 — backups page truth pass) + +> **2026-07-12 — v0.121.0: BACKUPS PAGE TRUTH PASS. MinAgent 0.81.0 unchanged. Controller-only, no +> agent-API change.** Pure UI/data-plumbing on `/backups`; no backup-engine behavior change. Fixes the +> self-contradicting live page: (1) **removed the dead "Részletek" card** (operator decision — redundant; +> per-app rows + Adatbázisok section already carry the truth) — kills the last uses of the never-set +> template fields `Tier2DriveGroups`/`ResticPassword`, the `restic-pw` element, and the `toggleTier`/ +> `toggleResticPw`/`copyResticPw` JS. (2) **per-app "3. mentés" row now shows real off-box state** via a +> new pure `tier3State` (configured→toggle→escrow precedence): unconfigured/off/escrow_pending/active — +> the "Hamarosan — B2/S3/SFTP" placeholder is gone. (3) **SQLite-honest DB messaging** via pure +> `dbSectionState(discovered,dumps)` → dumps/pending/embedded (embedded-only box shows "–" + "beágyazott +> DB-k a kötetmentésben", not a bare "0"). (4) **dead/raw fields fixed** — `Tier1LastRun`/`Tier1LastStatus` +> now populated from `ListRestorePoints`; Tier-1/Tier-2 labels via `timeAgoStr` (relative), confirm() +> dialog keeps raw. (5) **terminology split** — off-box section = "Távoli mentés (3. mentés)" (+ +> `#offbox-section` anchor); whole-guest PBS card = "Távoli rendszermentés" (was both "Távoli mentés"). +> (6) deploy page gains a "Mentési beállítások →" link. DECISIONS: Részletek removed as redundant +> (operator-approved); "Távoli mentés (3. mentés)" (app off-box) vs "Távoli rendszermentés" (PBS whole-CT) +> are two distinct customer-facing names. Pure helpers in `internal/web/backup_page_state.go`. +9 web +> tests, 4 red-proofs. Observations: orphaned style.css classes from the Részletek removal left in place +> (details-tier*, repo-encryption*, restic-pw-field, drive-detail-*, tier-empty-state, repo-info-row*, +> repo-tier-title) — noted, not cleaned. Backlog: felhom.eu backup-architecture.md offbox refresh (separate task). > **2026-07-12 — v0.120.0: fix-3 + fix-6 → CAMPAIGN-3 CLOSED (LIVE on 9201 + hub 0.48.0). > MinAgent 0.81.0 unchanged.** **fix-3:** a `deadapp-check` job (30s, 90s boot grace) flags a DEPLOYED diff --git a/REPORT.md b/REPORT.md index df5a45d..c0b3220 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,68 +1,79 @@ -# REPORT — v0.120.0: dead-app alerting (fix-3) + debug-ring revision (fix-6) — CLOSES CAMPAIGN-3 +# REPORT — v0.121.0: backups page truth pass -**Date:** 2026-07-12 · **Version:** controller v0.120.0 (from v0.119.0) · **MinAgent:** 0.81.0 (UNCHANGED) -· **Pairs with:** hub v0.48.0 (accepts `app_start_failed`) · **Deployed:** guest 9201 (`0.120.0` healthy). -**Source:** `felhom.eu/documentation/audits/CAMPAIGN-3-2026-07-11.md`. +**Date:** 2026-07-12 · **Version:** controller v0.121.0 (from v0.120.0) · **MinAgent:** 0.81.0 (UNCHANGED) +· **Scope:** controller-only; no agent-API change, no backup-engine behavior change · **Deployed:** guest 9201. + +**Confirmed baseline:** `felhom-controller` @ `92d670e8a6` (main), v0.120.0 → v0.121.0. + +## Problem + +On the live demo (`/backups`, v0.120.0) the page contradicted itself: a dead "Részletek" card claimed +"Nincs 2. szintű mentés konfigurálva" (the never-set `.Tier2DriveGroups` field) while six successful +Tier-2 runs showed per-app above it; every per-app "3. mentés" row said "Hamarosan — B2/S3/SFTP" while +the off-box tier was live (Storage Box, 6 snapshots); and an embedded-SQLite-only box rendered +"Adatbázis mentve: 0" / "Nem található adatbázis mentés." as if backups were failing. ## What shipped -- **fix-3 (MED) — a dead deployed app is loud.** A `deadapp-check` job (every 30 s, after a 90 s boot - grace) scans the deployed apps: `stopped`/`exited` ones (`stacks.IsDownState`) raise a self-clearing - WARN dashboard banner (grouped above 3) and fire an `app_start_failed` hub event ONCE per running→down - transition (`Notifier.NotifyAppStartFailures`; the hub owns cooldown — no controller timer). -- **fix-6 (MED) — the post-incident window survives.** Ring cap 1000→5000 (display cap raised to - match); periodic scheduler/refresh success lines demoted to a `[TRACE]` level the ring drops at - write-time (failures never TRACE); atomic JSON-lines spill to `/debug-ring.log` (SSD only) - every 30 s + on shutdown, loaded back on boot. Corruption-safe. -- **hub v0.48.0:** `app_start_failed` added to `allowedEventTypes` + `customerMessages` (else the event - 400s at ingest — the known allowlist gotcha). +1. **Dead "Részletek" card removed** — operator-approved as redundant (per-app rows + Adatbázisok + section carry the truth). Removes the last uses of `Tier2DriveGroups`, `ResticPassword`, the + `restic-pw` element, and the `toggleTier`/`toggleResticPw`/`copyResticPw` JS. +2. **Per-app "3. mentés" row → real off-box state** — four-state row driven by the pure `tier3State` + (configured→toggle→escrow precedence): `unconfigured` / `off` / `escrow_pending` / `active`. Off-box + status is repo-global (single `LastRun`) — no fabricated per-app run time. "hamarosan" gone. +3. **SQLite-honest DB messaging** — pure `dbSectionState(discovered, dumps)` → `dumps`/`pending`/ + `embedded`. Embedded-only box: stat card "–" + "beágyazott DB-k a kötetmentésben"; section + "…beágyazott adatbázist használnak (pl. SQLite)…". Discovered-not-dumped: "…az első ütemezett mentés + éjjel fut le." +4. **Dead/raw display fields fixed** — `Tier1LastRun`/`Tier1LastStatus` populated from + `ListRestorePoints` (correct per-drive resolution; no time for unit-less apps); Tier-1/Tier-2 labels + via `timeAgoStr` (relative), confirm() dialog keeps the precise timestamp. +5. **Terminology split** — off-box section retitled "Távoli mentés (3. mentés) — titkosított, offsite" + (+ `#offbox-section` anchor); whole-guest PBS stat card relabeled "Távoli rendszermentés". +6. **Deploy page** — app backup card gains "Mentési beállítások →" → `/stacks//backup`. -## Tests + red-proofs (all green) +## Files -- fix-3: dead-app banner present + self-clears (companion: skip SetDeadAppAlerts → silent → fail); - grouped above threshold; `IsDownState` table (stopped/exited down; starting/unhealthy/restarting/ - deploying/paused/unknown NOT); notifier one-event-per-transition (companion: drop tracking → fires - each cycle → fail); first-seen-down (dead-at-boot) fires; healthy never fires. -- fix-6: TRACE dropped from ring while a WARN failure is kept (companion: demote too broadly → failure - lost → fail); spill→load round-trip; load keeps newest N; corrupt/truncated spill loads valid + never - panics; missing file no-op. +- **Created:** `controller/internal/web/backup_page_state.go` (pure `dbSectionState`, `tier3State`); + `controller/internal/web/backup_page_state_test.go` (9 tests). +- **Modified:** `controller/internal/web/handlers.go` (`AppBackupRow` +`OffboxEnabled`/`Tier3State`; + `buildAppBackupRows` Tier-1 + Tier-3 plumbing; `backupsHandler` `DBSectionState`); + `controller/internal/web/templates/backups.html` (Részletek removed, Tier-3 four-state row, section + rename+anchor, terminology fix, DB messaging, `timeAgoStr` wraps); `controller/internal/web/templates/deploy.html` + (settings link); `CHANGELOG.md`, `REPORT.md`, `CONTEXT.md`, `controller/README.md`. -## Live validation (demo 9201 + hub) +## Tests -- **fix-3:** `docker stop seerr` → within a cycle the dashboard banner "Telepített alkalmazás nem fut: - Jellyseerr (stopped)" appeared AND the hub logged exactly ONE `app_start_failed` event - (`Event from demo-felhom: app_start_failed`); across 3 down-cycles still ONE event (anti-spam); - `docker start seerr` → the banner self-cleared (0 banners). This makes the campaign's 4-hour silent - death impossible. -- **fix-6:** the ring held 0 periodic-spam lines (TRACE demotion); a controller restart PRESERVED the - pre-restart window — oldest entry unchanged across `systemctl restart` (total 390→464, oldest - 08:11:28 both sides), with a 63 KB spill on the persistent SSD data volume (survives container - recreation, never a NAS path). +- **New (9, all green):** `TestDBSectionState` (table), `TestTier3State` (table, all 4 states + + precedence), `TestBuildAppBackupRows_OffboxMapping` (Scenario A), `_EscrowPendingPrecedence` + (Scenario B), `_Tier1FromRestorePoints` (Scenario D — unit→RFC3339, no-unit→""), + `TestBackupsTemplate_Tier3Live` (Scenario A + removal assertions), `_Tier3EscrowPending` (Scenario B), + `_DBMessaging` (Scenario C embedded/pending), `_Tier2RelativeTime` (Scenario D). +- **Test count (internal/web):** 108 → 117 test funcs. +- **Companion red-proofs (run → FAIL → revert), all verified:** + 1. Re-insert "hamarosan elérhető" into the rendered Tier-3 `off` block → `TestBackupsTemplate_Tier3Live` + FAILED on the no-"hamarosan" assertion → reverted. ✓ + 2. Hardcode `row.OffboxEnabled = false` in `buildAppBackupRows` → `TestBuildAppBackupRows_OffboxMapping` + FAILED (calibre OffboxEnabled:false, Tier3State:off) → reverted. ✓ + 3. `dbSectionState` drops the `discovered` branch → `TestDBSectionState` FAILED on `(2,0)` want "pending" + → reverted. ✓ + 4. Drop the `row.Tier1LastRun = pts[0].Time` assignment → `TestBuildAppBackupRows_Tier1FromRestorePoints` + FAILED (hasunit Tier1LastRun "") → reverted. ✓ +- **Green gate:** `go build ./...` + `go vet ./internal/web/` + `go test ./internal/web/` all green. + Template gates `template_id_gate.py` + `emoji_gate.py` OK. (Pre-existing, unrelated: `internal/backup` + `TestDumpAppVolumes_*` fail in this environment — docker-dependent; confirmed failing on the clean + `92d670e` baseline too, and I did not touch `internal/backup`.) -## CAMPAIGN-3 finding ledger — RESOLVED +## Deploy & live validation -| Finding | Fix | Ships in | -|---|---|---| -| F12 (CRIT) boot ordering cycle; F11/F10/F9 (HIGH) automount re-arm; F2/F1 residue | agent boot/recovery plane + appliance self-heal | **agent v0.85.0** | -| F7 (HIGH) in-place volume-dump truncation; F6/F5 (LOW) single-copy + stale dirs | atomic dumps + tier-2 for volume-only + stale sweep | **controller v0.118.0** | -| F8 (MED) storage-health contradiction; F4 (LOW) mapped_uid | share-row classifier fusion + range check | **controller v0.119.0** | -| fix-3 (MED) silent dead app; fix-6 (MED) ring window loss | dead-app alerting + ring cap/spill/spam | **controller v0.120.0** (+ hub v0.48.0) | +- **Deployed:** guest 9201 — _see "Live validation" below (filled after deploy)._ -CAMPAIGN-3 is closed. +## Observations (documented, NOT acted on) -## Standing follow-ups (explicit) - -- **Agent-ring persistence** — the agent's own in-memory ring has the same restart-wipe gap; deferred - (this task fixed the CONTROLLER ring only, to avoid an agent train). -- **F13 (HIGH, from Task A)** — an active nfs4 under the mp8 bind can fail PVE's rbind with rc255; - deferred (needs a pre-start idle-unmount design or an idmapped nfs mount). -- **Backup-locality option B** — retarget NAS tier-1 to a local drive (operator chose A/keep locality). -- **The agentless-on-proxmox2 cluster gap** on Peti's box (roadmap). -- **The publish train** — agent 0.85 + controller 0.118/0.119/0.120 + hub 0.48 + MinAgent 0.81 + the - journal-group one-liner + temp-creds deletion delivers this whole wave to Peti (on 0.113 / agent 0.81 - today; reaches it at his next train). - -## Box state at wrap - -controller 0.120.0 healthy on 9201; hub 0.48.0 live (Synced/Healthy); 8 apps healthy; seerr recovered; -debug-ring spill on the SSD data volume; no test residue. +- **Orphaned style.css classes** left by the Részletek removal (not cleaned per task §9.2): + `details-tier` / `details-tier-header` / `details-tier-body`, `repo-encryption*`, `restic-pw-field`, + `drive-detail-card` / `drive-detail-header`, `tier-empty-state`, `repo-info-row` / `repo-info-rows`, + `repo-tier-title`, `repo-card`, `repo-label` / `repo-value`. Candidates for a later CSS sweep. +- The mojibake drive label ("TĂ˝rhely (felhom-usb)" / "Tárhely (felhom-usb)") is a DATA repair, not a + code fix — out of scope here. +- `felhom.eu/documentation/controller/backup-architecture.md` off-box refresh is a separate queued doc task. diff --git a/controller/README.md b/controller/README.md index 6cc103f..7accbec 100644 --- a/controller/README.md +++ b/controller/README.md @@ -628,10 +628,15 @@ data back up config + DB + user data + Docker volumes; apps without HDD back up **Protects against:** primary drive failure, drive theft/damage. -#### Tier 3: Remote Backup (future) +#### Tier 3: Off-box (NAS) Backup — live -Complete offsite backup for disaster recovery. Not yet implemented. -Placeholder shown in UI ("3. mentés — Hamarosan"). +The off-site "1" of 3-2-1: each off-box-toggled app's recovery unit + DB dumps + volume tars are +backed up to the customer's NAS / Felhom offsite as an encrypted restic repo over SFTP (see the +off-box section below and `internal/backup/offbox.go`). The per-app "3. mentés" row on the backups +page renders one of four real states via the pure `tier3State` helper (`internal/web/backup_page_state.go`): +`unconfigured` (no target) / `off` (app not toggled) / `escrow_pending` (fork-4 key-escrow gate holds — +never a false success) / `active` (status badge + `restic → ` + relative last-run). Off-box run +status is repo-global (one `LastRun`); no per-app run time is fabricated. #### Restore (`internal/backup/restore.go`) @@ -697,7 +702,9 @@ Every app starts as yellow (1 tier only). Green requires Tier 2 configured with - **2. mentes** (Tier 2, configurable for ALL apps) — one of: - Configured: method (rsync/restic) + destination + schedule + last run + status + contents + browsable indicator (folder icon for rsync) + action buttons - Not configured: "1. mentes auto" + "Nincs 2. masolat" + settings link -- **3. mentes** (Tier 3, placeholder) — grayed out "Hamarosan" + "tavoli (offsite)" + future note +- **3. mentes** (Tier 3, off-box/NAS — live) — one of four `tier3State` states: `unconfigured` + ("Nincs beallitva" + Beallitas link), `off` ("Kikapcsolva" + Bekapcsolas link), `escrow_pending` + ("Kulcsletetre var"), `active` (status badge + "restic -> " + relative last-run) **Backup contents per app** (shown per tier): - Apps with DB + HDD: "DB + Konfig + Adatok" diff --git a/controller/internal/web/backup_page_state.go b/controller/internal/web/backup_page_state.go new file mode 100644 index 0000000..0cb05ac --- /dev/null +++ b/controller/internal/web/backup_page_state.go @@ -0,0 +1,49 @@ +package web + +// backup_page_state holds the small pure state-pick helpers that drive the honest +// per-app / whole-page messaging on the /backups page. They are pure (no *Server, +// no I/O) so the truth-table can be unit-tested directly without a fixture Server. + +// dbSectionState decides how the "Adatbázisok" section (and the "Adatbázis mentve" +// stat card) should read, given how many databases were discovered live at render +// time and how many dump files exist on disk: +// - "dumps" — dump files exist → show the real dump table / count. +// - "pending" — a database was discovered but no dump written yet → "first run tonight". +// - "embedded" — neither: the deployed apps carry embedded DBs (e.g. SQLite) that ride +// the file/volume backup; no separate dump is expected, so a bare "0" would lie. +// +// dumps wins over discovered: stale dumps from a since-removed DB app are still a real, +// restorable artifact and must be shown normally (see the §8 edge-case table). +func dbSectionState(discovered, dumps int) string { + switch { + case dumps > 0: + return "dumps" + case discovered > 0: + return "pending" + default: + return "embedded" + } +} + +// tier3State decides the per-app "3. mentés" (off-box / off-site) row state: +// - "unconfigured" — no off-box target set (configured wins over a stale per-app toggle). +// - "off" — target set but this app is not toggled for off-box backup. +// - "escrow_pending" — toggled, but the repo password is not yet escrowed (fork-4 gate): +// runs are paused, so we must NOT imply a successful run. +// - "active" — toggled and escrowed: the app is included in the off-box runs. +// +// Precedence is strict: configured → toggle → escrow. Anything other than "escrowed" +// while toggled is treated as escrow-pending (never "active"), so a pre-pending LastStatus +// of "ok" can never surface a false "Sikeres". +func tier3State(configured, toggled bool, escrowState string) string { + switch { + case !configured: + return "unconfigured" + case !toggled: + return "off" + case escrowState != "escrowed": + return "escrow_pending" + default: + return "active" + } +} diff --git a/controller/internal/web/backup_page_state_test.go b/controller/internal/web/backup_page_state_test.go new file mode 100644 index 0000000..d7d5118 --- /dev/null +++ b/controller/internal/web/backup_page_state_test.go @@ -0,0 +1,332 @@ +package web + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// --------------------------------------------------------------------------- +// Pure state-pick helpers (Group C logic + tier3State precedence for Group B) +// --------------------------------------------------------------------------- + +// TestDBSectionState — table-driven truth table for the embedded-DB-honest messaging pick. +// COMPANION red-proof: make dbSectionState ignore `discovered` (return "embedded" when dumps==0) +// → the (2,0)="pending" case below FAILS. Run → fail → revert (recorded in REPORT). +func TestDBSectionState(t *testing.T) { + cases := []struct { + discovered, dumps int + want string + }{ + {0, 0, "embedded"}, // Scenario C: SQLite-only box — neither dump nor discovery + {2, 0, "pending"}, // discovered but no dump yet → "first run tonight" (NOT embedded) + {2, 3, "dumps"}, // real dumps present → show the table + {0, 3, "dumps"}, // stale dumps from a removed DB app still show (dumps wins) + } + for _, c := range cases { + if got := dbSectionState(c.discovered, c.dumps); got != c.want { + t.Errorf("dbSectionState(%d,%d) = %q, want %q", c.discovered, c.dumps, got, c.want) + } + } +} + +// TestTier3State — the per-app off-box row state, with strict configured→toggle→escrow precedence. +func TestTier3State(t *testing.T) { + cases := []struct { + name string + configured bool + toggled bool + escrow string + want string + }{ + {"unconfigured wins over stale toggle", false, true, "escrowed", "unconfigured"}, + {"configured but app off", true, false, "escrowed", "off"}, + {"toggled + escrowed → active", true, true, "escrowed", "active"}, + {"toggled + pending escrow → escrow_pending", true, true, "pending", "escrow_pending"}, + {"toggled + empty escrow → escrow_pending", true, true, "", "escrow_pending"}, + } + for _, c := range cases { + if got := tier3State(c.configured, c.toggled, c.escrow); got != c.want { + t.Errorf("%s: tier3State(%v,%v,%q) = %q, want %q", c.name, c.configured, c.toggled, c.escrow, got, c.want) + } + } +} + +// --------------------------------------------------------------------------- +// Handler wiring: buildAppBackupRows maps OffboxEnabled + Tier3State (Scenario A/B) +// --------------------------------------------------------------------------- + +// configureOffbox sets up a real, configured off-box target (secrets + settings) so +// OffboxConfigured() reports true, with the given escrow state. +func configureOffbox(t *testing.T, sett *settings.Settings, m *backup.Manager, escrow string) { + t.Helper() + if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil { + t.Fatal(err) + } + if err := sett.SetOffboxTarget(&settings.OffboxTarget{ + Enabled: true, Host: "u629488-sub1.your-storagebox.de", Port: 22, User: "felhom", + RepoPath: "/home/repo", Schedule: "daily", EscrowState: escrow, + LastStatus: "ok", LastRun: "2026-07-11T02:00:00Z", + }); err != nil { + t.Fatal(err) + } + if !m.OffboxConfigured() { + t.Fatal("target should be configured") + } +} + +func findRow(rows []AppBackupRow, stack string) *AppBackupRow { + for i := range rows { + if rows[i].StackName == stack { + return &rows[i] + } + } + return nil +} + +// TestBuildAppBackupRows_OffboxMapping (Scenario A + B wiring): the per-app Tier-3 state is +// (global configured) × (global escrow) × (per-app toggle). calibre toggled ON → active; radarr +// OFF → off. COMPANION red-proof: hardcode row.OffboxEnabled=false in buildAppBackupRows → the +// calibre "active"/OffboxEnabled assertions FAIL. Run → fail → revert (recorded in REPORT). +func TestBuildAppBackupRows_OffboxMapping(t *testing.T) { + s, sett, m := newOffboxWebServer(t) + configureOffbox(t, sett, m, "escrowed") + if err := sett.SetAppOffbox("calibre-web", true); err != nil { + t.Fatal(err) + } + status := &backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{ + {StackName: "calibre-web", DisplayName: "Calibre-Web"}, + {StackName: "radarr", DisplayName: "Radarr"}, + }} + rows := s.buildAppBackupRows(status) + + cal := findRow(rows, "calibre-web") + if cal == nil || !cal.OffboxEnabled || cal.Tier3State != "active" { + t.Fatalf("calibre-web: OffboxEnabled/Tier3State wrong: %+v", cal) + } + rad := findRow(rows, "radarr") + if rad == nil || rad.OffboxEnabled || rad.Tier3State != "off" { + t.Fatalf("radarr: OffboxEnabled/Tier3State wrong: %+v", rad) + } +} + +// TestBuildAppBackupRows_EscrowPendingPrecedence (Scenario B): a toggled app while escrow is +// pending is escrow_pending, never active — even though LastStatus is "ok" from a prior run. +func TestBuildAppBackupRows_EscrowPendingPrecedence(t *testing.T) { + s, sett, m := newOffboxWebServer(t) + configureOffbox(t, sett, m, "pending") + if err := sett.SetAppOffbox("calibre-web", true); err != nil { + t.Fatal(err) + } + rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{ + {StackName: "calibre-web", DisplayName: "Calibre-Web"}, + }}) + cal := findRow(rows, "calibre-web") + if cal == nil || cal.Tier3State != "escrow_pending" { + t.Fatalf("escrow-pending must win over a prior ok run: %+v", cal) + } +} + +// TestBuildAppBackupRows_Tier1FromRestorePoints (Scenario D): a recovery unit on disk → Tier1LastRun +// is its newest-artifact RFC3339 time; an app with no unit → Tier1LastRun stays "" (no fabrication). +// COMPANION red-proof: drop the ListRestorePoints assignment (leave Tier1LastRun unset) → the +// "with-unit" assertion FAILS. Run → fail → revert (recorded in REPORT). +func TestBuildAppBackupRows_Tier1FromRestorePoints(t *testing.T) { + s, _, m := newOffboxWebServer(t) + drive := filepath.Join(t.TempDir(), "drive") + m.SetStackProvider(&unitProvider{blockProvider{hdd: drive}}) + + // Write a recovery-unit manifest for "hasunit" with a known, decisively-past mtime. + manifest := backup.RecoveryUnitManifestPath(drive, "hasunit") + if err := os.MkdirAll(filepath.Dir(manifest), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifest, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + mtime := time.Date(2026, 7, 10, 3, 30, 0, 0, time.UTC) + if err := os.Chtimes(manifest, mtime, mtime); err != nil { + t.Fatal(err) + } + + rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{ + {StackName: "hasunit", DisplayName: "Has Unit"}, + {StackName: "nounit", DisplayName: "No Unit"}, + }}) + + hu := findRow(rows, "hasunit") + if hu == nil || hu.Tier1LastRun != mtime.Format(time.RFC3339) { + t.Fatalf("hasunit Tier1LastRun = %q, want %q", huTier1(hu), mtime.Format(time.RFC3339)) + } + if hu.Tier1LastStatus != "ok" { + t.Errorf("hasunit Tier1LastStatus = %q, want ok", hu.Tier1LastStatus) + } + nu := findRow(rows, "nounit") + if nu == nil || nu.Tier1LastRun != "" { + t.Fatalf("nounit must have NO fabricated Tier1LastRun, got %q", huTier1(nu)) + } +} + +func huTier1(r *AppBackupRow) string { + if r == nil { + return "" + } + return r.Tier1LastRun +} + +// unitProvider makes every stack resolvable (GetStackComposePath ok) with the embedded hdd drive, +// so ListRestorePoints resolves the namespace to that drive. +type unitProvider struct{ blockProvider } + +func (u *unitProvider) GetStackComposePath(string) (string, bool) { return "compose", true } + +// --------------------------------------------------------------------------- +// Template rendering (Group A/B/C/D): the real "backups" template tree +// --------------------------------------------------------------------------- + +func renderBackups(t *testing.T, data map[string]interface{}) string { + t.Helper() + s := testServer(t) + s.loadTemplates() + var buf bytes.Buffer + if err := s.tmpl.ExecuteTemplate(&buf, "backups", data); err != nil { + t.Fatalf("render backups: %v", err) + } + return buf.String() +} + +// baseBackupData is the minimal data map that makes the "backups" body render (Backup truthy + +// one AppDataInfo so Section 4/7 appear). Callers overlay AppBackupRows / Offbox / DBSectionState. +func baseBackupData(rows []AppBackupRow) map[string]interface{} { + return map[string]interface{}{ + "Page": "backups", "Title": "Biztonsági mentés", + "Backup": &backup.FullBackupStatus{ + AppDataInfo: []backup.AppBackupInfo{{StackName: "calibre-web", DisplayName: "Calibre-Web"}}, + }, + "AppBackupRows": rows, + "DBSectionState": "dumps", + } +} + +// TestBackupsTemplate_Tier3Live (Scenario A) — active + off rows render truthfully, and every +// removed marker is gone. COMPANION red-proof: re-insert "hamarosan elérhető" into the tier-3 block +// → the no-"hamarosan" assertion FAILS. Run → fail → revert (recorded in REPORT). +func TestBackupsTemplate_Tier3Live(t *testing.T) { + data := baseBackupData([]AppBackupRow{ + {StackName: "calibre-web", DisplayName: "Calibre-Web", OffboxEnabled: true, Tier3State: "active"}, + {StackName: "radarr", DisplayName: "Radarr", OffboxEnabled: false, Tier3State: "off"}, + }) + data["Offbox"] = &settings.OffboxTarget{ + Enabled: true, Host: "u629488-sub1.your-storagebox.de", LastStatus: "ok", + LastRun: "2026-07-11T02:00:00Z", EscrowState: "escrowed", + } + data["OffboxConfigured"] = true + html := renderBackups(t, data) + + for _, want := range []string{ + "Sikeres", // active badge (LastStatus ok) + "restic → u629488-sub1.your-storagebox.de", // the real off-box host + "Utolsó:", // a relative last-run time is shown + "Kikapcsolva", // radarr off row + "Bekapcsolás", // radarr enable link + `href="#offbox-section"`, // the anchor jump target + "Távoli mentés (3. mentés)", // the renamed section header + } { + if !strings.Contains(html, want) { + t.Errorf("rendered page missing %q", want) + } + } + for _, banned := range []string{ + "hamarosan", // the dead placeholder copy + "Tier2DriveGroups", // dead template field + "ResticPassword", // dead template field + "restic-pw", // dead element id + "toggleTier", // dead JS fn + "details-tier", // dead Részletek markup + "Részletek", // the removed card title + "2026-07-11T02:00:00Z", // raw RFC3339 must never leak (timeAgoStr wraps it) + } { + if strings.Contains(html, banned) { + t.Errorf("rendered page still contains removed/raw marker %q", banned) + } + } +} + +// TestBackupsTemplate_Tier3EscrowPending (Scenario B) — the escrow-pending row shows the wait +// state and NOT a false success. The single row carries no Tier-2, so "Sikeres" would only come +// from a (wrong) tier-3 active badge. +func TestBackupsTemplate_Tier3EscrowPending(t *testing.T) { + data := baseBackupData([]AppBackupRow{ + {StackName: "calibre-web", DisplayName: "Calibre-Web", OffboxEnabled: true, Tier3State: "escrow_pending"}, + }) + data["Offbox"] = &settings.OffboxTarget{Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "pending"} + data["OffboxConfigured"] = true + html := renderBackups(t, data) + + if !strings.Contains(html, "Kulcsletétre vár") { + t.Error("escrow-pending row must show 'Kulcsletétre vár'") + } + // The active-state marker must be absent — no false success. (Bare "Sikeres" would + // false-match the "Sikeresen törölve" JS toast, so assert the active contents string.) + if strings.Contains(html, "Helyreállítási egység, titkosítva") { + t.Error("escrow-pending must NOT render the active tier-3 row (false success)") + } +} + +// TestBackupsTemplate_DBMessaging (Scenario C) — embedded vs pending messaging in the stat card +// and the Adatbázisok empty state. +func TestBackupsTemplate_DBMessaging(t *testing.T) { + t.Run("embedded", func(t *testing.T) { + data := baseBackupData(nil) + data["DBSectionState"] = "embedded" + html := renderBackups(t, data) + for _, want := range []string{ + "beágyazott DB-k a kötetmentésben", // stat-card sublabel + "beágyazott adatbázist használnak", // Adatbázisok empty-state explanation + } { + if !strings.Contains(html, want) { + t.Errorf("embedded page missing %q", want) + } + } + if strings.Contains(html, "Nem található adatbázis mentés.") { + t.Error("embedded box must NOT show the old bare 'not found' message") + } + }) + t.Run("pending", func(t *testing.T) { + data := baseBackupData(nil) + data["DBSectionState"] = "pending" + html := renderBackups(t, data) + if !strings.Contains(html, "az első ütemezett mentés éjjel fut le") { + t.Error("pending page missing the 'first run tonight' message") + } + if strings.Contains(html, "beágyazott adatbázist használnak") { + t.Error("pending box must NOT show the embedded-DB message") + } + }) +} + +// TestBackupsTemplate_Tier2RelativeTime (Scenario D) — a Tier-2 last-run renders as relative time, +// never the raw RFC3339 literal. +func TestBackupsTemplate_Tier2RelativeTime(t *testing.T) { + data := baseBackupData([]AppBackupRow{{ + StackName: "calibre-web", DisplayName: "Calibre-Web", + Tier2Configured: true, Tier2Dest: "USB", Tier2Schedule: "Naponta", + Tier2LastRun: "2026-07-10T03:30:00Z", Tier2LastStatus: "ok", Tier2StatusBadge: "Sikeres", + Tier3State: "unconfigured", + }}) + html := renderBackups(t, data) + // The visible "Utolsó:" label must be a relative time, never the raw RFC3339. (The raw + // timestamp legitimately survives in the restore confirm() dialog — precise there by design.) + if strings.Contains(html, "Utolsó: 2026-07-10T03:30:00Z") { + t.Error("Tier-2 last-run label must render via timeAgoStr, not the raw RFC3339 literal") + } + if !strings.Contains(html, "Utolsó:") { + t.Error("Tier-2 row should still show an 'Utolsó:' label") + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index be87c44..55fd1a6 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -653,6 +653,10 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { data["Backup"] = fullStatus + // DB-section state — honest messaging for embedded-DB-only boxes (SQLite etc.): + // "dumps" (real dumps) | "pending" (discovered, first run tonight) | "embedded". + data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles)) + // DB dump total size var dbDumpTotalBytes int64 for _, f := range fullStatus.DumpFiles { @@ -719,7 +723,7 @@ type AppBackupRow struct { BackupContents string // Tier 1: Nightly backup (always exists) - Tier1LastRun string // formatted time of last restic snapshot + Tier1LastRun string // RFC3339 time of the newest recovery-unit artifact ("" = no unit yet) Tier1LastStatus string // "ok", "error", "" Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning @@ -742,6 +746,10 @@ type AppBackupRow struct { // Tier2UserDisabled — customer turned Tier 2 off for this app from the config panel. Tier2UserDisabled bool + // Tier 3: Off-box (NAS) restic-SFTP backup — the off-site 3-2-1 leg. + OffboxEnabled bool // this app toggled for off-box inclusion (IsAppOffbox) + Tier3State string // "unconfigured" | "off" | "escrow_pending" | "active" (see tier3State) + // Warnings accumulated for this app Warnings []string } @@ -774,6 +782,14 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup disconnectedPaths[dp.Path] = true } + // Off-box (Tier 3) globals — resolved once for all rows. The per-app state is + // (global configured) × (global escrow) × (per-app toggle); see tier3State. + offboxConfigured := s.backupMgr != nil && s.backupMgr.OffboxConfigured() + offboxEscrowState := "" + if t := s.settings.GetOffboxTarget(); t != nil { + offboxEscrowState = t.EscrowState + } + var rows []AppBackupRow for _, app := range status.AppDataInfo { hasDB := dbStacks[app.StackName] || app.HasDBDump @@ -818,6 +834,25 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup Tier1DBStatus: tier1DBStatus, } + // Tier 1: newest recovery-unit artifact time. ListRestorePoints does the correct + // per-drive namespace resolution (do NOT re-derive paths — the offbox DIAG trap). + // A known stack with no unit yet returns an empty list → no fabricated time. + if s.backupMgr != nil { + if pts, ok := s.backupMgr.ListRestorePoints(app.StackName); ok && len(pts) > 0 { + row.Tier1LastRun = pts[0].Time + // A unit exists: green unless the DB dump failed (keep tier1DBStatus as the source). + if status.LastDBDump != nil && !status.LastDBDump.Success { + row.Tier1LastStatus = "error" + } else { + row.Tier1LastStatus = "ok" + } + } + } + + // Tier 3: off-box (NAS) inclusion state for this app. + row.OffboxEnabled = s.settings.IsAppOffbox(app.StackName) + row.Tier3State = tier3State(offboxConfigured, row.OffboxEnabled, offboxEscrowState) + // Status dot — app-data backup status row.Status = "green" row.StatusText = "Alkalmazás-adat mentés rendben" diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index b9efcb8..c5b0b31 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -125,8 +125,8 @@ {{if .Backup}} -

Külső (NAS) mentés — titkosított, off-site

-

Az alkalmazások adatainak + adatbázis-kiírásainak titkosított mentése a saját NAS-odra (restic, SFTP-n). A NAS csak titkosított adatot lát. Ez a 3-2-1 „1 off-site" lába — független a helyi másodpéldánytól és a teljes rendszermentéstől.

+

Távoli mentés (3. mentés) — titkosított, offsite

+

Az alkalmazás-mentések titkosított másolata egy távoli tárolóra — saját NAS vagy Felhom offsite tárhely — restic + SFTP kapcsolaton. A tároló csak titkosított adatot lát. Ez a 3-2-1 szabály „1 off-site" lába — független a helyi másodpéldánytól és a teljes rendszermentéstől.

{{if .Offbox}}
@@ -245,20 +245,25 @@ {{if and .GuestBackup .GuestBackup.Offsite}}
-
Távoli mentés
külön hardveren (PBS)
+
Távoli rendszermentés
külön hardveren (PBS)
{{else}}
-
Távoli mentés
nincs beállítva
+
Távoli rendszermentés
nincs beállítva
{{end}}
+ {{if eq .DBSectionState "embedded"}} +
+
Adatbázis mentve
beágyazott DB-k a kötetmentésben
+ {{else}}
{{if .Backup.LastDBDump}}{{len .Backup.LastDBDump.Results}}{{else}}{{len .Backup.DumpFiles}}{{end}}
Adatbázis mentve
+ {{end}}
@@ -360,8 +365,10 @@
+ {{else if eq .DBSectionState "pending"}} +
Még nem készült adatbázis-mentés — az első ütemezett mentés éjjel fut le.
{{else}} -
Nem található adatbázis mentés.
+
A telepített alkalmazások beágyazott adatbázist használnak (pl. SQLite) — adatbázisuk a fájl- és kötetmentés része, külön adatbázis-kiírás nem szükséges.
{{end}} @@ -406,7 +413,7 @@ Auto helyi {{if .Tier1LastRun}} - Utolsó: {{.Tier1LastRun}} + Utolsó: {{timeAgoStr .Tier1LastRun}} {{if eq .Tier1LastStatus "ok"}} {{else if eq .Tier1LastStatus "error"}}{{end}} @@ -429,7 +436,7 @@ → {{.Tier2Dest}} Cél meghajtó leválasztva {{if .Tier2LastRun}} - Utolsó: {{.Tier2LastRun}} + Utolsó: {{timeAgoStr .Tier2LastRun}} {{end}} {{.BackupContents}}
@@ -440,7 +447,7 @@ → {{.Tier2Dest}} Cél meghajtó inaktív {{if .Tier2LastRun}} - Utolsó: {{.Tier2LastRun}} + Utolsó: {{timeAgoStr .Tier2LastRun}} {{end}} {{.BackupContents}}
@@ -451,7 +458,7 @@ → {{.Tier2Dest}} {{.Tier2Schedule}} {{if .Tier2LastRun}} - Utolsó: {{.Tier2LastRun}} + Utolsó: {{timeAgoStr .Tier2LastRun}} {{.Tier2StatusBadge}} @@ -480,13 +487,41 @@
{{end}}
- + + {{if eq .Tier3State "active"}} +
+ 3. mentés + {{if eq $.Offbox.LastStatus "ok"}}Sikeres{{else if eq $.Offbox.LastStatus "error"}}Hiba{{else if eq $.Offbox.LastStatus "running"}}Fut...{{else}}—{{end}} + restic → {{$.Offbox.Host}} + {{if $.Offbox.LastRun}}Utolsó: {{timeAgoStr $.Offbox.LastRun}}{{end}} + Helyreállítási egység, titkosítva +
+ {{else if eq .Tier3State "escrow_pending"}} +
+ 3. mentés + Kulcsletétre vár + A távoli mentés a titkosítási kulcs letétbe helyezéséig szünetel +
+ {{else if eq .Tier3State "off"}}
3. mentés - Hamarosan - távoli (offsite) - B2 / S3 / SFTP — hamarosan elérhető + Kikapcsolva + Ez az alkalmazás nincs kijelölve távoli mentésre +
+ {{else}} +
+ 3. mentés + Nincs beállítva + távoli (offsite) + Nincs távoli mentési cél beállítva + +
+ {{end}} {{if .Warnings}}
@@ -502,78 +537,6 @@
{{end}} - -
-

Részletek

- - -
-
- -

1. szint — Helyi mentés (adatbázis + konfiguráció)

-
-
-
-
- Adatbázis mentések: - {{if .Backup.DumpFiles}}{{len .Backup.DumpFiles}} dump fájl{{if gt .DBDumpTotalBytes 0}} — {{fmtBytes .DBDumpTotalBytes}}{{end}}{{else}}Nincs dump fájl{{end}} -
-
- - - {{if $.ResticPassword}} -
- Titkosítási kulcs: -
- - - -
-
- Mentse el biztonságos helyre! A kulcs nélkül a biztonsági mentések NEM állíthatók vissza. -
-
- {{end}} -
-
- - -
-
- -

2. szint — Másodlagos másolat

-
- -
- - -
-
- -

3. szint — Távoli mentés (offsite)

-
- -
-
- {{if .Backup.AppDataInfo}}
@@ -671,18 +634,6 @@ function toggleBackupDetail(header) { } } -function toggleTier(header) { - var body = header.nextElementSibling; - var icon = header.querySelector('.expand-icon'); - if (body.style.display === 'none') { - body.style.display = 'block'; - icon.textContent = '▼'; - } else { - body.style.display = 'none'; - icon.textContent = '▶'; - } -} - function triggerBackupFromPage() { const btn = document.getElementById('backup-page-btn'); btn.disabled = true; @@ -756,20 +707,6 @@ function pollGuestBackup(out, btn) { }, 5000); } -// Restic password toggle/copy -function toggleResticPw() { - var el = document.getElementById('restic-pw'); - el.type = el.type === 'password' ? 'text' : 'password'; -} -function copyResticPw() { - var el = document.getElementById('restic-pw'); - navigator.clipboard.writeText(el.value).then(function() { - var btn = event.target; - btn.textContent = 'Másolva!'; - setTimeout(function() { btn.textContent = 'Másolás'; }, 2000); - }); -} - // Restore section var huDays = ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek', 'szombat']; function formatSnapshot(s) { diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index f982c4f..f5ff45a 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -113,7 +113,7 @@
Az alkalmazás adatbázisa és Docker kötetei automatikusan bekerülnek az éjszakai biztonsági mentésbe. - Mentési állapot → + Mentési állapot → · Mentési beállítások →