controller v0.121.0: backups page truth pass — remove dead Részletek card, real Tier-3 off-box state, SQLite-honest DB messaging
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.
This commit is contained in:
@@ -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 → <host>`, 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
|
||||
|
||||
+21
-1
@@ -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
|
||||
|
||||
@@ -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 `<DataDir>/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/<name>/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.
|
||||
|
||||
+11
-4
@@ -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 → <host>` + 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 -> <host>" + relative last-run)
|
||||
|
||||
**Backup contents per app** (shown per tier):
|
||||
- Apps with DB + HDD: "DB + Konfig + Adatok"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 "<nil row>"
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -125,8 +125,8 @@
|
||||
|
||||
{{if .Backup}}
|
||||
<!-- Off-box (NAS) backup — Part B: encrypted restic repo over SFTP (the off-site 3-2-1 leg). -->
|
||||
<h3 class="backup-tier-divider">Külső (NAS) mentés — titkosított, off-site</h3>
|
||||
<p class="form-hint" style="margin:-0.25rem 0 1rem">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.</p>
|
||||
<h3 id="offbox-section" class="backup-tier-divider">Távoli mentés (3. mentés) — titkosított, offsite</h3>
|
||||
<p class="form-hint" style="margin:-0.25rem 0 1rem">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.</p>
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
{{if .Offbox}}
|
||||
<div class="stats-grid backup-page-cards">
|
||||
@@ -245,20 +245,25 @@
|
||||
{{if and .GuestBackup .GuestBackup.Offsite}}
|
||||
<div class="stat-card stat-running">
|
||||
<div class="stat-value">✓</div>
|
||||
<div class="stat-label">Távoli mentés<br><span class="relative-time">külön hardveren (PBS)</span></div>
|
||||
<div class="stat-label">Távoli rendszermentés<br><span class="relative-time">külön hardveren (PBS)</span></div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="stat-card" style="border-left-color: var(--text-3);">
|
||||
<div class="stat-value" style="background:var(--text-3);-webkit-background-clip:text;background-clip:text;">–</div>
|
||||
<div class="stat-label">Távoli mentés<br><span class="relative-time">nincs beállítva</span></div>
|
||||
<div class="stat-label">Távoli rendszermentés<br><span class="relative-time">nincs beállítva</span></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="stat-card stat-total">
|
||||
{{if eq .DBSectionState "embedded"}}
|
||||
<div class="stat-value">–</div>
|
||||
<div class="stat-label">Adatbázis mentve<br><span class="relative-time">beágyazott DB-k a kötetmentésben</span></div>
|
||||
{{else}}
|
||||
<div class="stat-value">
|
||||
{{if .Backup.LastDBDump}}{{len .Backup.LastDBDump.Results}}{{else}}{{len .Backup.DumpFiles}}{{end}}
|
||||
</div>
|
||||
<div class="stat-label">Adatbázis mentve</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -360,8 +365,10 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else if eq .DBSectionState "pending"}}
|
||||
<div class="backup-table-empty">Még nem készült adatbázis-mentés — az első ütemezett mentés éjjel fut le.</div>
|
||||
{{else}}
|
||||
<div class="backup-table-empty">Nem található adatbázis mentés.</div>
|
||||
<div class="backup-table-empty">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.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -406,7 +413,7 @@
|
||||
<span class="layer-badge">Auto</span>
|
||||
<span class="tier-location">helyi</span>
|
||||
{{if .Tier1LastRun}}
|
||||
<span class="layer-last">Utolsó: {{.Tier1LastRun}}
|
||||
<span class="layer-last">Utolsó: {{timeAgoStr .Tier1LastRun}}
|
||||
{{if eq .Tier1LastStatus "ok"}}<span class="text-ok"><svg class="ico ico-sm"><use href="#i-check"/></svg></span>
|
||||
{{else if eq .Tier1LastStatus "error"}}<span class="text-error"><svg class="ico ico-sm"><use href="#i-x"/></svg></span>{{end}}
|
||||
</span>
|
||||
@@ -429,7 +436,7 @@
|
||||
<span class="layer-dest" style="opacity:.6">→ {{.Tier2Dest}}</span>
|
||||
<span class="tag tag-warn">Cél meghajtó leválasztva</span>
|
||||
{{if .Tier2LastRun}}
|
||||
<span class="layer-last" style="opacity:.6">Utolsó: {{.Tier2LastRun}}</span>
|
||||
<span class="layer-last" style="opacity:.6">Utolsó: {{timeAgoStr .Tier2LastRun}}</span>
|
||||
{{end}}
|
||||
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
|
||||
<div class="layer-actions">
|
||||
@@ -440,7 +447,7 @@
|
||||
<span class="layer-dest" style="opacity:.6">→ {{.Tier2Dest}}</span>
|
||||
<span class="tag tag-warn">Cél meghajtó inaktív</span>
|
||||
{{if .Tier2LastRun}}
|
||||
<span class="layer-last" style="opacity:.6">Utolsó: {{.Tier2LastRun}}</span>
|
||||
<span class="layer-last" style="opacity:.6">Utolsó: {{timeAgoStr .Tier2LastRun}}</span>
|
||||
{{end}}
|
||||
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
|
||||
<div class="layer-actions">
|
||||
@@ -451,7 +458,7 @@
|
||||
<span class="layer-dest">→ {{.Tier2Dest}}</span>
|
||||
<span class="layer-schedule">{{.Tier2Schedule}}</span>
|
||||
{{if .Tier2LastRun}}
|
||||
<span class="layer-last">Utolsó: {{.Tier2LastRun}}
|
||||
<span class="layer-last">Utolsó: {{timeAgoStr .Tier2LastRun}}
|
||||
<span class="{{if eq .Tier2LastStatus "ok"}}text-ok{{else if eq .Tier2LastStatus "error"}}text-error{{else if eq .Tier2LastStatus "running"}}text-muted{{end}}">
|
||||
{{.Tier2StatusBadge}}
|
||||
</span>
|
||||
@@ -480,13 +487,41 @@
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<!-- Tier 3: Remote backup (future) -->
|
||||
<!-- Tier 3: Off-box (NAS) backup — real state (see tier3State). -->
|
||||
{{if eq .Tier3State "active"}}
|
||||
<div class="backup-layer-row">
|
||||
<span class="tier-label">3. mentés</span>
|
||||
<span class="layer-badge {{if eq $.Offbox.LastStatus "ok"}}text-ok{{else if eq $.Offbox.LastStatus "error"}}text-error{{else if eq $.Offbox.LastStatus "running"}}text-muted{{end}}">{{if eq $.Offbox.LastStatus "ok"}}Sikeres{{else if eq $.Offbox.LastStatus "error"}}Hiba{{else if eq $.Offbox.LastStatus "running"}}Fut...{{else}}—{{end}}</span>
|
||||
<span class="tier-location">restic → {{$.Offbox.Host}}</span>
|
||||
{{if $.Offbox.LastRun}}<span class="layer-last">Utolsó: {{timeAgoStr $.Offbox.LastRun}}</span>{{end}}
|
||||
<span class="tier-contents">Helyreállítási egység, titkosítva</span>
|
||||
</div>
|
||||
{{else if eq .Tier3State "escrow_pending"}}
|
||||
<div class="backup-layer-row">
|
||||
<span class="tier-label">3. mentés</span>
|
||||
<span class="layer-badge text-error">Kulcsletétre vár</span>
|
||||
<span class="tier-contents">A távoli mentés a titkosítási kulcs letétbe helyezéséig szünetel</span>
|
||||
</div>
|
||||
{{else if eq .Tier3State "off"}}
|
||||
<div class="backup-layer-row" style="opacity:.5">
|
||||
<span class="tier-label">3. mentés</span>
|
||||
<span class="layer-badge" style="background:var(--bg-2);color:var(--text-3)">Hamarosan</span>
|
||||
<span class="tier-location">távoli (offsite)</span>
|
||||
<span class="tier-contents" style="font-style:normal;color:var(--text-3)">B2 / S3 / SFTP — hamarosan elérhető</span>
|
||||
<span class="layer-badge">Kikapcsolva</span>
|
||||
<span class="tier-contents">Ez az alkalmazás nincs kijelölve távoli mentésre</span>
|
||||
<div class="layer-actions">
|
||||
<a href="#offbox-section" class="btn btn-xs btn-outline">Bekapcsolás</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="backup-layer-row" style="opacity:.5">
|
||||
<span class="tier-label">3. mentés</span>
|
||||
<span class="layer-badge" style="background:var(--bg-2);color:var(--text-3)">Nincs beállítva</span>
|
||||
<span class="tier-location">távoli (offsite)</span>
|
||||
<span class="tier-contents">Nincs távoli mentési cél beállítva</span>
|
||||
<div class="layer-actions">
|
||||
<a href="#offbox-section" class="btn btn-xs btn-outline">Beállítás</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Warnings}}
|
||||
<div class="layer-warnings">
|
||||
@@ -502,78 +537,6 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Section 6: Részletek (Details) -->
|
||||
<div class="repo-card">
|
||||
<h3>Részletek</h3>
|
||||
|
||||
<!-- Tier 1: Helyi mentés (collapsible, open by default) -->
|
||||
<div class="details-tier">
|
||||
<div class="details-tier-header" onclick="toggleTier(this)">
|
||||
<span class="expand-icon">▼</span>
|
||||
<h4 class="repo-tier-title">1. szint — Helyi mentés (adatbázis + konfiguráció)</h4>
|
||||
</div>
|
||||
<div class="details-tier-body">
|
||||
<div class="repo-info-rows" style="margin-top:0.5rem">
|
||||
<div class="repo-info-row">
|
||||
<span class="repo-label">Adatbázis mentések:</span>
|
||||
<span class="repo-value">{{if .Backup.DumpFiles}}{{len .Backup.DumpFiles}} dump fájl{{if gt .DBDumpTotalBytes 0}} — {{fmtBytes .DBDumpTotalBytes}}{{end}}{{else}}Nincs dump fájl{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Encryption key -->
|
||||
{{if $.ResticPassword}}
|
||||
<div class="repo-encryption">
|
||||
<span class="repo-label">Titkosítási kulcs:</span>
|
||||
<div class="repo-encryption-row">
|
||||
<input type="password" id="restic-pw" class="restic-pw-field mono" value="{{$.ResticPassword}}" readonly>
|
||||
<button type="button" class="btn btn-sm" onclick="toggleResticPw()">Megjelenítés</button>
|
||||
<button type="button" class="btn btn-sm" onclick="copyResticPw()">Másolás</button>
|
||||
</div>
|
||||
<div class="repo-encryption-warn">
|
||||
Mentse el biztonságos helyre! A kulcs nélkül a biztonsági mentések NEM állíthatók vissza.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tier 2: Másodlagos másolat (collapsible, collapsed by default) -->
|
||||
<div class="details-tier">
|
||||
<div class="details-tier-header" onclick="toggleTier(this)">
|
||||
<span class="expand-icon">▶</span>
|
||||
<h4 class="repo-tier-title">2. szint — Másodlagos másolat</h4>
|
||||
</div>
|
||||
<div class="details-tier-body" style="display:none">
|
||||
{{if .Tier2DriveGroups}}
|
||||
{{range .Tier2DriveGroups}}
|
||||
<div class="drive-detail-card">
|
||||
<div class="drive-detail-header">{{.DestLabel}} <span class="relative-time mono">({{.DestPath}})</span></div>
|
||||
{{range .Items}}
|
||||
<div class="repo-info-row">
|
||||
<span class="repo-label">{{.DisplayName}}</span>
|
||||
<span class="repo-value">{{if .SizeHuman}}{{.SizeHuman}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="tier-empty-state">Nincs 2. szintű mentés konfigurálva.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tier 3: Távoli mentés (collapsible, collapsed by default, placeholder) -->
|
||||
<div class="details-tier">
|
||||
<div class="details-tier-header" onclick="toggleTier(this)">
|
||||
<span class="expand-icon">▶</span>
|
||||
<h4 class="repo-tier-title" style="opacity:.6">3. szint — Távoli mentés (offsite)</h4>
|
||||
</div>
|
||||
<div class="details-tier-body" style="display:none">
|
||||
<div class="tier-empty-state">B2 / S3 / SFTP — hamarosan elérhető</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 7: Restore -->
|
||||
{{if .Backup.AppDataInfo}}
|
||||
<div class="backup-section-card">
|
||||
@@ -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) {
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<div class="cross-drive-nightly">
|
||||
<span class="form-hint" style="display:block;margin-top:.25rem">
|
||||
Az alkalmazás adatbázisa és Docker kötetei automatikusan bekerülnek az éjszakai biztonsági mentésbe.
|
||||
<a href="/backups" style="color:var(--blue)">Mentési állapot →</a>
|
||||
<a href="/backups" style="color:var(--blue)">Mentési állapot →</a> · <a href="/stacks/{{.Stack.Name}}/backup" style="color:var(--blue)">Mentési beállítások →</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user