diff --git a/CHANGELOG.md b/CHANGELOG.md index f444e1f..e6df385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +## v0.283.0 — apps go off-site by themselves (decision 50), with a size warning; a Stop holds during a backup (R-721); page slips (R-724, R-725) (2026-09-30) + +**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: `backups_offsite.offer_text`, +`backups_offsite.offer_all`, `backups_offsite.offer_no`, `backups_offsite.fit_head`, `backups_offsite.fit_total`, +`backups_offsite.fit_quota`, `backups_offsite.fit_largest`, `backups_offsite.fit_choose`, `flash.offbox.enabled_all` +(hu + en). Evidence: `felhom.eu/documentation/audits/evidence-fixes-first-tester-2026-09-30/`. + +- **Decision 50 (R-720):** a fresh install on a box whose customer has off-site switches the app's off-site copy ON + (`settings.DefaultOffboxOnForNewApp`, from the deploy-done hook). An app with an earlier backup choice keeps it (a + reinstall after a removal that kept the backups keeps the household's OFF). Apps already installed are never + switched by the release: both backup pages offer ONE press („Van alkalmazás, amelyről nem készül távoli mentés. + Bekapcsolod mindegyikre?" — `POST /backup/offbox/enable-all`, „Nem most" → `/backup/offbox/offer-dismiss`, shown + until answered). +- **The size warning:** the apps' off-site size (recovery unit + mandatory files) is estimated at the start of every + off-site run and, in the background, when the page is opened and the estimate is older than 6 h (the page never + runs `du`). Over the quota the Távoli mentés page names the three largest with their sizes and asks the household + to choose. **Measured first:** over the quota the box already refuses NEW pushes and runs only the ruled retention + (`forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6`), and an app whose files would cross the quota is pushed + settings + database only — it deletes no history to make room. Now pinned (`TestDecision50_OverQuotaDeletesNothingExtra`). +- **R-721 — a Stop holds:** the household's Stop pressed while a machine had the app down is honoured at that + machine's resume — the whole-guest backup's quiesce (`restartAll` asks `WantsStopped`), the nightly volume dump + (and its crash marker owes no restart), and the nightly update leg (`stopped_by_household` skip: an update would + bring the app up). Measured 2026-09-29: the backup's resume started a stopped app the same second. +- **R-724 / R-725 (box half):** Beállítások shows the schedule the box really runs (window + legs) and the update + check in local time; the dashboard's „Utolsó mentés" is in local time (R-500); the backups card reads „az előző + N órája készült" instead of a „0 órája" under „Következő mentés"; the restore-test card names the tier it read + from; the recovery-code wizard speaks „te" (formal-form ceiling 18 → 17). +- Tests: `TestDecision50_*`, `TestR721_*`. Red-proofs RP31–RP38, each seen failing on an assertion. + ## v0.282.0 — the app's own sign-up switch after the setup (after_setup); "close sign-up now" (decision 49); probes read lists and a "done" status (R-715) (2026-09-29 evening) **MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: `app_info.close_signup_text`, diff --git a/REUSE.md b/REUSE.md index 27a8234..b0f6a17 100644 --- a/REUSE.md +++ b/REUSE.md @@ -173,6 +173,8 @@ | `backup.Manager.RestoreTier2Files` | controller/internal/backup/tier2_restore.go | `(stackName) (filesRestored int, err error)` | In-place ADDITIVE-ONLY class-C file restore from the recorded Tier-2 copy (`POST /backup/tier2/restore`) | Never overwrites/deletes live files; refusals (Hungarian) before any stop; source = recorded `DestinationPath`, never re-selected. **C9-F1 (v0.183.0): reads `hdd/` + `userdata/` ONLY — never `recovery-unit/`.** For 43 of 53 catalog apps that is a guaranteed no-op, so it now refuses with `ErrTier2NoRestorableData` BEFORE stopping the app. Ask `Tier2RestoreCoverage` first | | `backup.Manager.Tier2RestoreCoverage` | controller/internal/backup/tier2_restore.go | `(stackName) (Tier2Coverage{Legs, HasUnit}, error)` | Answers what a Tier-2 restore CAN and CANNOT return for an app, from the RECORDED copy on disk | **C9-F1.** `Legs` = subtrees the restore reads; `HasUnit` = the copy also holds DB dumps + volume tarballs it will NEVER read. Use it to refuse up front and to decide whether the success message must disclose uncovered data. Judged from the copy, not the catalog, so a retemplated app is judged by what it actually has | | `Manager.acquireRunning`/`releaseRunning`, `acquireMigrating` | controller/internal/backup/backup.go, controller/internal/stacks/migrate.go | `() error` | Single-flight for long ops | Copy this mutex-flag pattern for any new long-running manager op | +| `stacks.Manager.WantsStopped` (v0.283.0, R-721) | controller/internal/stacks/desiredstate.go | `(name) bool` | The household's recorded intent is „stopped" | ASK IT before any machine restarts an app it stopped for its own purpose (quiesce, volume dump, update leg) — a Stop pressed meanwhile must hold. Never gate the household's own Start on it | +| `settings.DefaultOffboxOnForNewApp` / `AppsWithoutOffbox` / `EnableOffboxFor` (v0.283.0, decision 50) | controller/internal/settings/offbox_default.go | `(stack) (bool, why)` | A fresh install joins the off-site copy when the customer has off-site | Call only from the deploy-done hook; it never overrides an existing per-app choice | ### Secrets hygiene diff --git a/controller/README.md b/controller/README.md index 8c51904..32800da 100644 --- a/controller/README.md +++ b/controller/README.md @@ -155,6 +155,14 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated action block right; used by the dashboard installed-apps list, the Távoli mentés toggle list and the Visszaállítás restore-to-verify/.fab lists; the backups-apps expander header is ALIGNED to the same grammar (own markup — it carries the toggle). Protected infra stacks + **Apps go off-site by themselves (v0.283.0, decision 50):** a fresh install on a box whose customer has off-site + switches the app's off-site copy ON (`settings.DefaultOffboxOnForNewApp`, deploy-done hook); an earlier choice is + kept. Older apps: one press on both backup pages (`/backup/offbox/enable-all`, „Nem most" dismisses). The size + card (`backup/offbox_fit.go`) compares the selected apps' estimated off-site size with the quota — measured at the + start of each off-site run and in the background from the page (≤ every 6 h) — and names the largest when they do + not fit. Over the quota nothing but the ruled retention runs; no history is deleted to make room. + **A Stop holds (v0.283.0, R-721):** the quiesce resume, the nightly volume dump and the update leg skip an app whose + desired state is „stopped" (`stacks.Manager.WantsStopped`). **The off-site restore list is keyed on the STORE (v0.204.0, R-237):** `offsite_restore_list.go` builds it from `OffsiteInventoryList` (the repository's own snapshot tags), NOT from deployed + offsite-toggled apps. A rebuilt box has neither and used to be shown nothing to restore while its diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index a52cc9c..b8411bd 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1776,6 +1776,13 @@ func main() { if notifier != nil { notifier.NotifyAppDeployed(name, display) } + // v0.283.0 (decision 50, R-720): a FRESH install on a box whose customer has off-site is in the + // off-site copy by default. An app with an earlier backup choice keeps it (settings rule). + if on, why := sett.DefaultOffboxOnForNewApp(name); on { + log.Printf("[INFO] [backup] %s: off-site copy switched ON at install (decision 50)", name) + } else { + log.Printf("[DEBUG] [backup] %s: off-site default not applied: %s", name, why) + } // v0.279.0 (decision 45): a FRESH install replaces a known default login with its generated one, // when the template declares after_install. Never on a restore or a kept-data load — those do not // pass through the deploy-done hook. diff --git a/controller/cmd/controller/offsite_default_wiring_test.go b/controller/cmd/controller/offsite_default_wiring_test.go new file mode 100644 index 0000000..7c440ec --- /dev/null +++ b/controller/cmd/controller/offsite_default_wiring_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +// v0.283.0 (decision 50, R-720): the deploy-done hook's SUCCESS branch — the one place a fresh install ends — +// calls sett.DefaultOffboxOnForNewApp. The rule itself is pinned in internal/settings/offbox_default_test.go; +// this pins that it is reached. +// COMPANION RED-PROOF: delete the call from the hook → this test fails. +func TestDecision50_DefaultIsWiredIntoTheDeployDoneHook(t *testing.T) { + f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0) + if err != nil { + t.Fatal(err) + } + found := false + ast.Inspect(f, func(n ast.Node) bool { + c, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && len(c.Args) == 1 { + ast.Inspect(c.Args[0], func(m ast.Node) bool { + if cc, ok := m.(*ast.CallExpr); ok { + if ss, ok := cc.Fun.(*ast.SelectorExpr); ok && ss.Sel.Name == "DefaultOffboxOnForNewApp" { + found = true + } + } + return true + }) + } + return true + }) + if !found { + t.Fatal("the deploy-done hook does not call DefaultOffboxOnForNewApp — a new app would start with off-site OFF (R-720)") + } +} diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 332c416..a3cbbda 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -117,6 +117,10 @@ type Manager struct { // offboxSizer (3a) — the mandatory-set byte estimator for the pre-push enlargement gate, overridable // in tests so the gate is unit-testable without a real du. Nil → the real dirSizeBytes (du -sb). offboxSizer func(path string) int64 + // offsiteFit (decision 50, v0.283.0) — the last off-site size estimate against the quota, for the page. + offsiteFitMu sync.Mutex + offsiteFit OffsiteFit + offsiteFitRunning bool // offboxNow (v0.206.0, R-241) is the abandonment countdown's clock. Nil → time.Now. // // IT EXISTS SO THE TERMINAL STEP IS TESTABLE WITHOUT SHORTENING A LIVE TIMER (§7.4). The sweep is @@ -958,6 +962,12 @@ func (m *Manager) DumpAppVolumesSafe(stackName string) error { dumpErr := m.DumpAppVolumes(stackName) + if hs, ok := m.stackProvider.(interface{ WantsStopped(string) bool }); ok && hs.WantsStopped(stackName) { + // R-721: the household pressed Stop while the dump had the app down — it stays stopped. + m.logger.Printf("[INFO] [backup] %s NOT restarted after the volume dump: the household stopped it meanwhile", stackName) + m.appStop.End() // nothing is owed a restart — the household wants it stopped + return dumpErr + } m.logger.Printf("[INFO] [backup] Restarting %s after volume dump", stackName) startErr := m.stackProvider.StartStack(stackName) if startErr != nil { diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 3507619..19a9627 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -1301,6 +1301,7 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin // Pre-run hygiene: clear any lock restic can prove stale before we start (cheap; the --remove-all // crash-lock escalation lives in resticStep for the locks restic can't self-detect). m.unlockStale(ctx, base, env) + m.RefreshOffsiteFit() // decision 50: the page's size estimate is taken before every push var firstErr error for _, stack := range apps { src, ok := m.discoverOffboxUnit(stack) diff --git a/controller/internal/backup/offbox_fit.go b/controller/internal/backup/offbox_fit.go new file mode 100644 index 0000000..1b908ff --- /dev/null +++ b/controller/internal/backup/offbox_fit.go @@ -0,0 +1,101 @@ +package backup + +import ( + "sort" + "time" +) + +// ── Decision 50 (2026-09-30, R-720): will the apps fit the off-site quota? Say so BEFORE a push ────────── +// +// With every new app in the off-site copy by default, a household with a big photo library can select more +// than the customer's quota holds. What the box already does is SAFE and stays (measured 2026-09-30): +// * over the quota, NEW pushes are refused and only the ruled retention runs (`forget --keep-daily 7 +// --keep-weekly 4 --keep-monthly 6 --prune`, the same policy as every night — pinned by +// TestDecision50_OverQuotaDeletesNothingExtra), so no history is deleted to make room; +// * an app whose files would cross the quota is pushed UNIT-ONLY (settings + database), with a warning. +// What was missing is the page saying it BEFOREHAND, with names and sizes, so the household chooses which +// apps stay off-site. The estimate is taken at the start of every off-site run and, in the background, when +// the page is opened and the last one is older than offsiteFitMaxAge; the page only ever reads the cache +// (a `du` over a photo library is never run in a page request). + +// OffsiteAppSize is one app's estimated off-site size: its recovery unit plus its mandatory files. +type OffsiteAppSize struct { + Stack string + Bytes int64 +} + +// OffsiteFit is the estimate the page shows. +type OffsiteFit struct { + At time.Time + Apps []OffsiteAppSize // largest first + TotalBytes int64 + QuotaBytes int64 // 0 = no quota (dedicated box or the household's own NAS) → always fits + Fits bool +} + +const offsiteFitMaxAge = 6 * time.Hour + +// estimateOffsiteFit is the pure rule: sum the apps' sizes and compare against the quota. An estimate of 0 +// for an app (no unit yet) counts as 0 — it cannot make the verdict "does not fit" on its own. +func estimateOffsiteFit(apps []string, sizeOf func(stack string) int64, quotaGB int, now time.Time) OffsiteFit { + f := OffsiteFit{At: now, QuotaBytes: int64(quotaGB) * offboxGiB} + for _, a := range apps { + b := sizeOf(a) + f.Apps = append(f.Apps, OffsiteAppSize{Stack: a, Bytes: b}) + f.TotalBytes += b + } + sort.SliceStable(f.Apps, func(i, j int) bool { return f.Apps[i].Bytes > f.Apps[j].Bytes }) + f.Fits = f.QuotaBytes <= 0 || f.TotalBytes <= f.QuotaBytes + return f +} + +// offsiteAppBytes: the recovery unit on disk + the mandatory off-site capture set (what a push carries). +func (m *Manager) offsiteAppBytes(stack string) int64 { + var n int64 + if src, ok := m.discoverOffboxUnit(stack); ok { + n += m.offboxSize()(src) + } + extra, _, _ := m.offboxCaptureSet(stack) + for _, p := range extra { + n += m.offboxSize()(p) + } + return n +} + +// RefreshOffsiteFit measures now (blocking) and caches the result. +func (m *Manager) RefreshOffsiteFit() OffsiteFit { + t := m.settings.GetOffboxTarget() + quota := 0 + if t != nil { + quota = t.QuotaGB + } + f := estimateOffsiteFit(m.settings.GetOffboxApps(), m.offsiteAppBytes, quota, time.Now()) + m.offsiteFitMu.Lock() + m.offsiteFit = f + m.offsiteFitMu.Unlock() + if !f.Fits { + m.logger.Printf("[WARN] [offbox] the apps selected for off-site (~%s) do not fit the quota (%d GB) — the page asks the household to choose", + humanizeBytes(f.TotalBytes), quota) + } + return f +} + +// OffsiteFitForPage returns the cached estimate and, when it is missing or older than offsiteFitMaxAge, +// starts ONE background refresh. The page never waits on a measurement. +func (m *Manager) OffsiteFitForPage() OffsiteFit { + m.offsiteFitMu.Lock() + f := m.offsiteFit + stale := f.At.IsZero() || time.Since(f.At) > offsiteFitMaxAge + start := stale && !m.offsiteFitRunning + if start { + m.offsiteFitRunning = true + } + m.offsiteFitMu.Unlock() + if start { + go func() { + defer func() { m.offsiteFitMu.Lock(); m.offsiteFitRunning = false; m.offsiteFitMu.Unlock() }() + m.RefreshOffsiteFit() + }() + } + return f +} diff --git a/controller/internal/backup/offbox_fit_test.go b/controller/internal/backup/offbox_fit_test.go new file mode 100644 index 0000000..4af1b2e --- /dev/null +++ b/controller/internal/backup/offbox_fit_test.go @@ -0,0 +1,81 @@ +package backup + +import ( + "context" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// Decision 50 / R-95, measured 2026-09-30: over the quota the box deletes NOTHING beyond the ruled retention. +// The consequence asserted: the forget an over-quota run executes carries EXACTLY the retention arguments of a +// normal run — no stricter keep, no --keep-last, no extra forget. (If someone "helpfully" made the over-quota +// path prune harder to get the household back under quota, it would delete history nobody chose to delete.) +// COMPANION RED-PROOF: change offboxPruneOnly's "--keep-daily", "7" to "1" → this test fails with the two +// argument lists printed. +func TestDecision50_OverQuotaDeletesNothingExtra(t *testing.T) { + forgetArgs := func(over bool) []string { + m, sett := newOffboxManager(t) + _ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.QuotaGB = 50 + if over { + o.RepoSizeBytes = 51 << 30 + } else { + o.RepoSizeBytes = 1 << 30 + } + }) + var got []string + n := 0 + m.SetOffboxSizer(func(string) int64 { return 0 }) + m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { + switch { + case contains(args, "cat") && contains(args, "config"): + return []byte(`{}`), nil + case contains(args, "forget"): + n++ + for i, a := range args { + if a == "forget" { + got = append([]string{}, args[i:]...) + } + } + case contains(args, "stats"): + return []byte(`{"total_size":123}`), nil + case contains(args, "snapshots"): + return []byte(`[]`), nil + } + return nil, nil + }) + _ = m.RunOffboxBackup(context.Background()) + if n != 1 { + t.Fatalf("over=%v: expected exactly one forget, got %d", over, n) + } + return got + } + normal, over := forgetArgs(false), forgetArgs(true) + if strings.Join(normal, " ") != strings.Join(over, " ") { + t.Fatalf("the over-quota forget differs from the normal retention — it would delete history nobody chose to delete:\n normal: %v\n over: %v", normal, over) + } +} + +// The size rule: sum against the quota, largest first; no quota → always fits. +// COMPANION RED-PROOF: compare with < instead of <= (or drop the QuotaBytes<=0 arm) → a case fails. +func TestDecision50_EstimateOffsiteFit(t *testing.T) { + sizes := map[string]int64{"immich": 80 << 30, "nextcloud": 30 << 30, "bookstack": 1 << 20} + sizeOf := func(s string) int64 { return sizes[s] } + now := time.Now() + f := estimateOffsiteFit([]string{"bookstack", "immich", "nextcloud"}, sizeOf, 100, now) + if f.Fits { + t.Fatalf("110 GiB into 100 GiB reported as fitting: %+v", f) + } + if f.Apps[0].Stack != "immich" || f.Apps[1].Stack != "nextcloud" { + t.Fatalf("largest first expected: %+v", f.Apps) + } + if g := estimateOffsiteFit([]string{"immich"}, func(string) int64 { return 100 << 30 }, 100, now); !g.Fits { + t.Fatal("exactly the quota must fit") + } + if g := estimateOffsiteFit([]string{"immich"}, sizeOf, 0, now); !g.Fits { + t.Fatal("no quota (own NAS / dedicated box) must always fit") + } +} diff --git a/controller/internal/backup/r721_volume_dump_stop_test.go b/controller/internal/backup/r721_volume_dump_stop_test.go new file mode 100644 index 0000000..0340e36 --- /dev/null +++ b/controller/internal/backup/r721_volume_dump_stop_test.go @@ -0,0 +1,40 @@ +package backup + +import "testing" + +// stopDuringDump is the real DumpAppVolumesSafe's provider: the household presses Stop while the dump holds +// the app down (between StopStack and the restart). +type stopDuringDump struct { + suppressWatchProvider + wantsStopped bool + started int +} + +func (p *stopDuringDump) StopStack(n string) error { + p.wantsStopped = true // the household's Stop lands during the dump + return p.suppressWatchProvider.StopStack(n) +} +func (p *stopDuringDump) StartStack(n string) error { + p.started++ + return p.suppressWatchProvider.StartStack(n) +} +func (p *stopDuringDump) WantsStopped(string) bool { return p.wantsStopped } + +// R-721 (v0.283.0): the nightly volume dump does not start an app the household stopped while it was down. +// COMPANION RED-PROOF: remove the WantsStopped check in DumpAppVolumesSafe → started=1. +func TestR721_VolumeDumpKeepsTheHouseholdsStop(t *testing.T) { + p := &stopDuringDump{} + m := newSuppressManager(t, &p.suppressWatchProvider) + m.stackProvider = p + if err := m.DumpAppVolumesSafe("actualbudget"); err != nil { + t.Fatalf("DumpAppVolumesSafe: %v", err) + } + if p.started != 0 { + t.Fatalf("the dump started the app %d time(s) after the household stopped it (R-721)", p.started) + } + // The crash marker is what a restart after a controller crash reads: it must owe nothing. (The short + // alarm-grace set is a different thing and stays by design.) + if got := m.appStop.HeldStacks(); len(got) != 0 { + t.Fatalf("the stop marker still holds %v — the next startup would restart an app the household stopped", got) + } +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 094a46c..8a59a4e 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -224,7 +224,7 @@ "backups.meret_cel": "Size / target", "backups.naprakesz": "Up to date", "backups.nincs_beallitva": "not set up", - "backups.oraja_a_mentesi_ablakon_belul": "{{.AgeHours}} {{if eq .AgeHours 1}}hour{{else}}hours{{end}} ago — within the backup window", + "backups.oraja_a_mentesi_ablakon_belul": "the last one was {{.AgeHours}} {{if eq .AgeHours 1}}hour{{else}}hours{{end}} ago — within the backup window", "backups.rendszermentes_teljes_mentes": "System backup (full backup)", "backups.sikertelen": "✗ {{fmtTime .FailedAt}} — failed", "backups.tarhely_attekintes": "Storage overview", @@ -2494,5 +2494,14 @@ "app_info.close_signup_btn": "Close sign-up now", "app_info.signup_native_failed": "The app's own sign-up switch could not be closed. The address block still protects it.", "app_info.signup_window_restart": "The app restarts for this, and once more when the 15 minutes end.", - "err.setup_gate.close_signup_not_offered": "There is nothing to close on this app." + "err.setup_gate.close_signup_not_offered": "There is nothing to close on this app.", + "backups_offsite.offer_text": "Some apps have no off-site copy. Turn it on for all of them?", + "backups_offsite.offer_all": "Yes, all of them", + "backups_offsite.offer_no": "Not now", + "backups_offsite.fit_head": "The selected apps do not fit in the off-site storage.", + "backups_offsite.fit_total": "Estimated size:", + "backups_offsite.fit_quota": "storage limit:", + "backups_offsite.fit_largest": "The largest:", + "backups_offsite.fit_choose": "Turn off the off-site copy for the apps that do not need one outside the house. Until then the largest get only their settings and database uploaded, and once the limit is full no new off-site backup is made. The box does not delete old backups because of this.", + "flash.offbox.enabled_all": "Off-site backup is on for every app." } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index aadaa57..eb86964 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -220,7 +220,7 @@ "backups.meret_cel": "Méret / cél", "backups.naprakesz": "Naprakész", "backups.nincs_beallitva": "nincs beállítva", - "backups.oraja_a_mentesi_ablakon_belul": "{{.AgeHours}} órája — a mentési ablakon belül", + "backups.oraja_a_mentesi_ablakon_belul": "az előző {{.AgeHours}} órája készült — a mentési időn belül", "backups.rendszermentes_teljes_mentes": "Rendszermentés (teljes mentés)", "backups.sikertelen": "✗ {{fmtTime .FailedAt}} — sikertelen", "backups.tarhely_attekintes": "Tárhely áttekintés", @@ -312,17 +312,17 @@ "backups_escrow.a_dr_szint_nincs_alkalmazva": "a DR-szint nincs alkalmazva ezen a gépen", "backups_escrow.a_folyamat_a_piros_feltetelek": "A folyamat a piros feltételek teljesüléséig nem indítható.", "backups_escrow.a_folyamat_inditasa_nem_sikerult": "A folyamat indítása nem sikerült.", - "backups_escrow.a_folytatashoz_adja_meg_a": "A folytatáshoz adja meg a bejelentkezési jelszavát", + "backups_escrow.a_folytatashoz_adja_meg_a": "A folytatáshoz add meg a bejelentkezési jelszavadat", "backups_escrow.a_funkciohoz_az_ugynok_frissitese": "A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik. Próbálja újra később.", "backups_escrow.a_funkciohoz_az_ugynok_frissitese_2": "A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.", - "backups_escrow.a_helyreallitasi_kod_a_mentesei": "A helyreállítási kód a mentései utolsó kulcsa. Pontosan egyszer jelenik meg — a rendszer\n sehol nem tárolja, és a Felhom sem ismeri. Ha a szerver megsemmisül, a távoli mentések CSAK\n ezzel a kóddal állíthatók vissza. Írja fel papírra vagy mentse jelszókezelőbe — de NE ezen a\n szerveren tárolja.", + "backups_escrow.a_helyreallitasi_kod_a_mentesei": "A helyreállítási kód a mentéseid utolsó kulcsa. Pontosan egyszer jelenik meg — a rendszer\n sehol nem tárolja, és a Felhom sem ismeri. Ha a szerver megsemmisül, a távoli mentések CSAK\n ezzel a kóddal állíthatók vissza. Írd fel papírra, vagy mentsd jelszókezelőbe — de NE ezen a\n szerveren tárold.", "backups_escrow.a_kod_lekerese_nem_sikerult": "A kód lekérése nem sikerült.", "backups_escrow.a_kod_letrehozasa_folyamatban_altalaban": "A kód létrehozása folyamatban… (általában néhány másodperc)", - "backups_escrow.a_kod_letrejott_de_nem": "A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le.\n Indítsa újra a folyamatot: az új kód a régit érvényteleníti.", - "backups_escrow.a_kod_pontosan_egyszer_jelenitheto": "A kód pontosan egyszer jeleníthető meg. Készítsen elő papírt és tollat, mielőtt megnyomja a gombot.", + "backups_escrow.a_kod_letrejott_de_nem": "A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le.\n Indítsd újra a folyamatot: az új kód a régit érvényteleníti.", + "backups_escrow.a_kod_pontosan_egyszer_jelenitheto": "A kód pontosan egyszer jelenik meg. Készíts elő papírt és tollat, mielőtt megnyomod a gombot.", "backups_escrow.a_kozponti_feltoltes_nincs_beallitva": "a központi feltöltés nincs beállítva", "backups_escrow.a_letrehozas_nem_sikerult": "A létrehozás nem sikerült.", - "backups_escrow.a_megadott_szavak_nem_egyeznek": "A megadott szavak nem egyeznek — ellenőrizze a felírt kódot.", + "backups_escrow.a_megadott_szavak_nem_egyeznek": "A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.", "backups_escrow.a_rendszerjogosultsag_hianyzik_az_ugynok": "a rendszerjogosultság hiányzik — az ügynök frissítése szükséges", "backups_escrow.a_titkosito_eszkoz_age_nem": "a titkosító eszköz (age) nem található", "backups_escrow.az_elofeltetelek_ellenorzese_nem_sikerul": "Az előfeltételek ellenőrzése nem sikerült:", @@ -333,7 +333,7 @@ "backups_escrow.elokeszitve": "előkészítve", "backups_escrow.elrejtes": "Elrejtés", "backups_escrow.es": ". és", - "backups_escrow.ez_mostantol_az_elo_helyreallitasi": "Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentse el most: a kód többé nem jeleníthető meg.", + "backups_escrow.ez_mostantol_az_elo_helyreallitasi": "Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentsd el most: a kód többé nem jelenik meg.", "backups_escrow.felirtam_a_kodot_es_biztonsagos": "Felírtam a kódot, és biztonságos helyen — nem ezen a szerveren — tárolom.", "backups_escrow.helyreallitasi_dr_szint_aktiv": "Helyreállítási (DR) szint aktív", "backups_escrow.helyreallitasi_kod": "Helyreállítási kód", @@ -342,8 +342,8 @@ "backups_escrow.kod_letrehozasa": "Kód létrehozása", "backups_escrow.kod_megjelenitese": "Kód megjelenítése", "backups_escrow.kozponti_feltoltes_beallitva": "Központi feltöltés beállítva", - "backups_escrow.megerosites_irja_be_a_kod": "Megerősítés: írja be a kód szavait", - "backups_escrow.megerosites_irja_be_a_kod_2": "Megerősítés: írja be a kód", + "backups_escrow.megerosites_irja_be_a_kod": "Megerősítés: írd be a kód szavait", + "backups_escrow.megerosites_irja_be_a_kod_2": "Megerősítés: írd be a kód", "backups_escrow.megjelenites": "Megjelenítés", "backups_escrow.megsem": "Mégsem", "backups_escrow.mentesi_tarolo_pbs_beallitva": "Mentési tároló (PBS) beállítva", @@ -2482,5 +2482,14 @@ "app_info.close_signup_btn": "Regisztráció lezárása most", "app_info.signup_native_failed": "Az alkalmazás saját regisztrációs kapcsolóját nem sikerült lezárni. A cím zárolása így is véd.", "app_info.signup_window_restart": "Ehhez az alkalmazás újraindul, és a 15 perc végén még egyszer.", - "err.setup_gate.close_signup_not_offered": "Ennél az alkalmazásnál nincs mit lezárni." + "err.setup_gate.close_signup_not_offered": "Ennél az alkalmazásnál nincs mit lezárni.", + "backups_offsite.offer_text": "Van alkalmazás, amelyről nem készül távoli mentés. Bekapcsolod mindegyikre?", + "backups_offsite.offer_all": "Igen, mindegyikre", + "backups_offsite.offer_no": "Nem most", + "backups_offsite.fit_head": "A kijelölt alkalmazások nem férnek el a távoli tárhelyen.", + "backups_offsite.fit_total": "Becsült méret:", + "backups_offsite.fit_quota": "tárhelykeret:", + "backups_offsite.fit_largest": "A legnagyobbak:", + "backups_offsite.fit_choose": "Kapcsold ki a távoli mentést annál, amelyiknek nem kell a házon kívül is lennie. Addig a legnagyobbaknak csak a beállításai és az adatbázisa kerül fel, és ha a keret betelik, új távoli mentés nem készül. Régi mentést a doboz ezért nem töröl.", + "flash.offbox.enabled_all": "A távoli mentés be van kapcsolva minden alkalmazásra." } diff --git a/controller/internal/quiesce/quiesce.go b/controller/internal/quiesce/quiesce.go index 8e18000..45a3e65 100644 --- a/controller/internal/quiesce/quiesce.go +++ b/controller/internal/quiesce/quiesce.go @@ -744,9 +744,21 @@ func within(p, start, span int) bool { // no caller could learn that a customer's app had not come back — and the classifier downstream // therefore had nothing to key on. A restart failure is the single most important fact this loop // produces; it must leave the function. +// householdStopped is the optional question a Stacks implementation answers (R-721): did the household +// press Stop on this app while we had it down? The stacks manager implements it; test fakes may not. +type householdStopped interface { + WantsStopped(name string) bool +} + func (l *Loop) restartAll(stacks []string) []string { var failed []string + hs, _ := l.stacks.(householdStopped) for _, s := range stacks { + if hs != nil && hs.WantsStopped(s) { + // R-721: a Stop pressed while the backup had the app down holds — the backup does not undo it. + l.logger.Printf("[INFO] [quiesce] %s NOT restarted: the household stopped it during the backup", s) + continue + } if err := l.stacks.StartStack(s); err != nil { l.logger.Printf("[ERROR] [quiesce] restart %s: %v", s, err) failed = append(failed, s) diff --git a/controller/internal/quiesce/r721_stop_holds_test.go b/controller/internal/quiesce/r721_stop_holds_test.go new file mode 100644 index 0000000..feb63b8 --- /dev/null +++ b/controller/internal/quiesce/r721_stop_holds_test.go @@ -0,0 +1,63 @@ +package quiesce + +import ( + "context" + "reflect" + "sync" + "testing" +) + +// householdStacks is fakeStacks plus the household's recorded intent (the stacks manager's WantsStopped). +type householdStacks struct { + fakeStacks + mu2 sync.Mutex + stopped map[string]bool +} + +func (h *householdStacks) WantsStopped(name string) bool { + h.mu2.Lock() + defer h.mu2.Unlock() + return h.stopped[name] +} + +// pressStopDuringBackup is a backend that, on its first status poll (the backup is running, the apps are +// down for it), lets the household press Stop on one app — the 2026-09-29 19:37:16 shape. +type pressStopDuringBackup struct { + *fakeBackend + st *householdStacks + app string + once sync.Once +} + +func (b *pressStopDuringBackup) BackupStatus(ctx context.Context) (string, error) { + b.once.Do(func() { b.st.mu2.Lock(); b.st.stopped[b.app] = true; b.st.mu2.Unlock() }) + return b.fakeBackend.BackupStatus(ctx) +} + +// R-721 (v0.283.0). The CONSEQUENCE asserted: after the backup's resume, the app the household stopped during +// the backup was NOT started again; the other one was. +// COMPANION RED-PROOF: remove the WantsStopped skip in restartAll → this test fails with actualbudget in the +// started list (the measured 2026-09-29 defect). +func TestR721_AStopPressedDuringTheBackupHolds(t *testing.T) { + st := &householdStacks{fakeStacks: fakeStacks{running: []string{"actualbudget", "bookstack"}}, stopped: map[string]bool{}} + be := &pressStopDuringBackup{fakeBackend: &fakeBackend{due: true, phases: []string{"running", "snapshotted", "done"}}, st: st, app: "actualbudget"} + l := testLoop(t, be, st) + if err := l.runOnce(context.Background()); err != nil { + t.Fatalf("runOnce: %v", err) + } + if got := st.startedNames(); !reflect.DeepEqual(got, []string{"bookstack"}) { + t.Fatalf("the backup's resume started %v — the household's Stop on actualbudget must hold (R-721)", got) + } +} + +// Control: with no Stop pressed, both come back (the skip must not swallow a normal resume). +func TestR721_ControlBothResumeWithoutAStop(t *testing.T) { + st := &householdStacks{fakeStacks: fakeStacks{running: []string{"actualbudget", "bookstack"}}, stopped: map[string]bool{}} + l := testLoop(t, &fakeBackend{due: true, phases: []string{"running", "done"}}, st) + if err := l.runOnce(context.Background()); err != nil { + t.Fatal(err) + } + if got := st.startedNames(); !reflect.DeepEqual(got, []string{"actualbudget", "bookstack"}) { + t.Fatalf("got %v", got) + } +} diff --git a/controller/internal/settings/offbox_default.go b/controller/internal/settings/offbox_default.go new file mode 100644 index 0000000..a714ae4 --- /dev/null +++ b/controller/internal/settings/offbox_default.go @@ -0,0 +1,88 @@ +package settings + +// ── Decision 50 (2026-09-30, R-720): a new app is in the off-site copy by default ────────────────────── +// +// The 2026-09-16 ruling turned the off-site tier ON for every new customer, but each app's own switch +// (`app_backup[].offbox`) started OFF and only the backups page's „Bekapcsolás" set it. Measured on a +// fresh box 2026-09-29: three apps installed, every one „3. mentés Kikapcsolva", the night's off-site run +// „nincs kijelölt alkalmazás". The household was never told. +// +// DefaultOffboxOnForNewApp is called by the deploy-done hook (the one place a fresh install ends). It switches +// the app ON when, and only when: +// - an off-site target exists and is enabled (the customer has off-site — hub-provisioned or the +// household's own NAS), and +// - the app has NO per-app backup record yet. A record means someone already chose: an app removed +// WITHOUT deleting its backups keeps its record (R-474), so a reinstall keeps the household's earlier +// OFF. An app removed WITH its backups had its record deleted, so it is a new app again. +// +// It never turns anything OFF and never touches an app that is already installed (a release switches +// nothing by itself — the 2026-09-29 Part 0 rule). +// Pinned by internal/settings/offbox_default_test.go. +func (s *Settings) DefaultOffboxOnForNewApp(stackName string) (bool, string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.Offbox == nil || !s.Offbox.Enabled || s.Offbox.Host == "" { + return false, "no off-site target" + } + if _, chosen := s.AppBackup[stackName]; chosen { + return false, "the app already has a backup choice" + } + if s.AppBackup == nil { + s.AppBackup = make(map[string]AppBackupPrefs) + } + s.AppBackup[stackName] = AppBackupPrefs{Offbox: true} + if err := s.save(); err != nil { + delete(s.AppBackup, stackName) + return false, "save failed: " + err.Error() + } + return true, "" +} + +// AppsWithoutOffbox lists the given installed apps that are NOT in the off-site copy, when an off-site target +// exists (decision 50: the one-press offer for apps installed before the release). Empty when there is no +// target, or every app is on. +func (s *Settings) AppsWithoutOffbox(installed []string) []string { + s.mu.RLock() + defer s.mu.RUnlock() + if s.Offbox == nil || !s.Offbox.Enabled || s.Offbox.Host == "" { + return nil + } + var out []string + for _, n := range installed { + if !s.AppBackup[n].Offbox { + out = append(out, n) + } + } + return out +} + +// EnableOffboxFor switches off-site ON for each named app in one save (the one-press offer). +func (s *Settings) EnableOffboxFor(apps []string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.AppBackup == nil { + s.AppBackup = make(map[string]AppBackupPrefs) + } + for _, a := range apps { + p := s.AppBackup[a] + p.Offbox = true + s.AppBackup[a] = p + } + s.OffboxOfferDismissed = true // answered + return s.save() +} + +// DismissOffboxOffer records the household's „Nem most". +func (s *Settings) DismissOffboxOffer() error { + s.mu.Lock() + defer s.mu.Unlock() + s.OffboxOfferDismissed = true + return s.save() +} + +// OffboxOfferDismissedValue reports whether the offer was answered. +func (s *Settings) OffboxOfferDismissedValue() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.OffboxOfferDismissed +} diff --git a/controller/internal/settings/offbox_default_test.go b/controller/internal/settings/offbox_default_test.go new file mode 100644 index 0000000..4b7017a --- /dev/null +++ b/controller/internal/settings/offbox_default_test.go @@ -0,0 +1,87 @@ +package settings + +import ( + "io" + "log" + "path/filepath" + "reflect" + "testing" +) + +func loadTmp(t *testing.T) (*Settings, string) { + t.Helper() + p := filepath.Join(t.TempDir(), "settings.json") + s, err := Load(p, log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + return s, p +} + +// Decision 50 (R-720). The CONSEQUENCE is asserted: after a fresh install on a box with off-site, the app is in +// the list the off-site run reads (GetOffboxApps), and it survives a reload from disk. +// COMPANION RED-PROOF: make DefaultOffboxOnForNewApp return (false, "") at the top → this test fails with +// „the new app is not in the off-site run". +func TestDecision50_NewAppIsOffsiteByDefault(t *testing.T) { + s, p := loadTmp(t) + if err := s.SetOffboxTarget(&OffboxTarget{Enabled: true, Host: "u1.your-storagebox.de", Port: 23, User: "u1", RepoPath: "/home/r"}); err != nil { + t.Fatal(err) + } + on, why := s.DefaultOffboxOnForNewApp("bookstack") + if !on { + t.Fatalf("switched=false (%s)", why) + } + s2, err := Load(p, log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + if got := s2.GetOffboxApps(); !reflect.DeepEqual(got, []string{"bookstack"}) { + t.Fatalf("the new app is not in the off-site run after a reload: %v", got) + } +} + +// No off-site target → nothing is switched (a box without off-site keeps no phantom choice). +func TestDecision50_NoTargetSwitchesNothing(t *testing.T) { + for name, tgt := range map[string]*OffboxTarget{ + "none": nil, + "disabled": {Enabled: false, Host: "h"}, + "no host": {Enabled: true}, + } { + s, _ := loadTmp(t) + if tgt != nil { + _ = s.SetOffboxTarget(tgt) + } + if on, _ := s.DefaultOffboxOnForNewApp("bookstack"); on { + t.Fatalf("%s: switched an app on with no usable off-site target", name) + } + if len(s.GetOffboxApps()) != 0 || s.AppsWithoutOffbox([]string{"bookstack"}) != nil { + t.Fatalf("%s: phantom off-site state", name) + } + } +} + +// A household's earlier OFF survives a reinstall: an app removed WITHOUT deleting its backups keeps its record +// (R-474), and the default never overrides a record. +// COMPANION RED-PROOF: drop the „already has a backup choice" check → this test fails. +func TestDecision50_EarlierChoiceIsKept(t *testing.T) { + s, _ := loadTmp(t) + _ = s.SetOffboxTarget(&OffboxTarget{Enabled: true, Host: "h"}) + _ = s.SetAppOffbox("immich", false) // the household switched it off (e.g. too big) + if on, _ := s.DefaultOffboxOnForNewApp("immich"); on { + t.Fatal("the default overrode the household's own OFF") + } + if len(s.GetOffboxApps()) != 0 { + t.Fatalf("immich is off-site against the household's choice: %v", s.GetOffboxApps()) + } +} + +// The one-press offer lists exactly the installed apps that are off. +func TestDecision50_AppsWithoutOffbox(t *testing.T) { + s, _ := loadTmp(t) + _ = s.SetOffboxTarget(&OffboxTarget{Enabled: true, Host: "h"}) + _ = s.SetAppOffbox("bookstack", true) + got := s.AppsWithoutOffbox([]string{"actualbudget", "bookstack", "vikunja"}) + if !reflect.DeepEqual(got, []string{"actualbudget", "vikunja"}) { + t.Fatalf("got %v", got) + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 03f976a..55f9fe0 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -182,6 +182,9 @@ type Settings struct { // Per-app backup preferences AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"` + // OffboxOfferDismissed (decision 50): the household answered „Nem most" to the one-press offer that + // switches off-site on for apps installed before v0.283.0. The offer is shown once, not forever. + OffboxOfferDismissed bool `json:"offbox_offer_dismissed,omitempty"` // OffsiteDataAt (v0.275.0, R-696) — per app, the DATA time of the recovery unit this box last pushed // off-site, and when. An off-site snapshot's own time is when it was TAKEN; a run whose dump leg failed diff --git a/controller/internal/stacks/desiredstate.go b/controller/internal/stacks/desiredstate.go index 02fa00e..a65bc31 100644 --- a/controller/internal/stacks/desiredstate.go +++ b/controller/internal/stacks/desiredstate.go @@ -156,3 +156,16 @@ func isObservedUp(s Stack) bool { return false } } + +// WantsStopped (R-721, v0.283.0) reports whether the household's own recorded intent for the stack is +// „stopped". The machines that stop an app for their own purposes and start it again afterwards — the +// whole-guest backup's quiesce, the nightly volume dump — ask this before their restart, so a Stop the +// household pressed WHILE the app was down for them holds. Measured 2026-09-29 on a fresh box: Stop at +// 19:37:16 recorded `desired state … stopped`, and the backup's resume started the app the same second. +func (m *Manager) WantsStopped(name string) bool { + st, ok := m.GetStack(name) + if !ok { + return false + } + return DesiredStateOf(*st) == DesiredStateStopped +} diff --git a/controller/internal/stacks/r721_leg_stopped_test.go b/controller/internal/stacks/r721_leg_stopped_test.go new file mode 100644 index 0000000..212b59f --- /dev/null +++ b/controller/internal/stacks/r721_leg_stopped_test.go @@ -0,0 +1,23 @@ +package stacks + +import "testing" + +// R-721 (v0.283.0): the nightly update leg never presses an app the household stopped — an update brings the +// app up with its new images, which would undo the Stop. Measured from source 2026-09-30: legCandidate had no +// desired-state check at all. +// COMPANION RED-PROOF: remove the DesiredStateStopped check in legCandidate → the step is pressed. +func TestR721_UpdateLegLeavesAStoppedAppAlone(t *testing.T) { + m, dir, _, ups, _ := legManager(t) + writeLadder(t, m, ladderLine(ladderA, ladderB), ladderLine(ladderB, ladderC)) + legOpts(m, nil) + if err := m.SetDesiredState("nextcloud", DesiredStateStopped); err != nil { + t.Fatal(err) + } + s := mustLeg(t, m) + if len(*ups) != 0 || pinOf(t, dir) != ladderA { + t.Fatalf("the leg updated an app the household stopped (ups=%v)", *ups) + } + if st := legStepFor(s, "nextcloud"); st.Reason != LegSkipStoppedByHousehold { + t.Fatalf("skip reason %+v, want %q", st, LegSkipStoppedByHousehold) + } +} diff --git a/controller/internal/stacks/unattended.go b/controller/internal/stacks/unattended.go index 4f19c9b..abbdc7d 100644 --- a/controller/internal/stacks/unattended.go +++ b/controller/internal/stacks/unattended.go @@ -50,7 +50,9 @@ const ( LegSkipWindowEnd = "window_end" LegSkipSwitchedOff = "switched_off" LegSkipCancelled = "cancelled" - legCurrent = "current" // not a skip: nothing to do + // LegSkipStoppedByHousehold (R-721, v0.283.0): the household stopped the app; an update would start it. + LegSkipStoppedByHousehold = "stopped_by_household" + legCurrent = "current" // not a skip: nothing to do LegOutcomeDone = "done" LegOutcomeUndone = "undone" @@ -395,6 +397,9 @@ func (m *Manager) legCandidate(ctx context.Context, name string, o *UpdateLegOpt if st.HoldReason != "" || st.updateHeld { return LadderEntry{}, LegSkipHeld } + if DesiredStateOf(*st) == DesiredStateStopped { + return LadderEntry{}, LegSkipStoppedByHousehold + } if g := m.guards(); g != nil { if held, _ := g.HoldFor(name); held { return LadderEntry{}, LegSkipHeld diff --git a/controller/internal/web/backup_handlers.go b/controller/internal/web/backup_handlers.go index 552b90a..e6b80cd 100644 --- a/controller/internal/web/backup_handlers.go +++ b/controller/internal/web/backup_handlers.go @@ -111,6 +111,10 @@ type guestBackupView struct { RestorePass bool RestoreVerified string RestoreTestedAt time.Time + // RestoreTier (R-727, v0.283.0) names the tier the last restore test read from, so a ✗ says WHERE — + // measured 2026-09-30: a bare ✗ above the local tier's heading read as „the local tier failed" while + // the PBS tier had. + RestoreTier string CanTrigger bool // a backup trigger (quiesce loop) is wired @@ -277,6 +281,13 @@ func (s *Server) loadGuestBackup(ctx context.Context, lang string) *guestBackupV v.HasRestoreTest = true v.RestorePass = rt.Pass v.RestoreVerified = rt.Verified + switch strings.ToLower(rt.SourceTier) { + case "": + case "pbs", "felhom-pbs": + v.RestoreTier = msg("backup.tier.pbs") + default: + v.RestoreTier = msg("backup.tier.local", rt.SourceTier) + } if t, perr := time.Parse(time.RFC3339, rt.TestedAt); perr == nil { v.RestoreTestedAt = t } diff --git a/controller/internal/web/escrow_wizard_test.go b/controller/internal/web/escrow_wizard_test.go index d7f6486..f481c30 100644 --- a/controller/internal/web/escrow_wizard_test.go +++ b/controller/internal/web/escrow_wizard_test.go @@ -358,13 +358,13 @@ func TestEscrowTemplates_Render(t *testing.T) { html := renderBackupPage(t, "backups_escrow", base()) for _, want := range []string{ "Előfeltételek ellenőrzése", - "a mentései utolsó kulcsa", - "A folytatáshoz adja meg a bejelentkezési jelszavát", + "a mentéseid utolsó kulcsa", + "A folytatáshoz add meg a bejelentkezési jelszavadat", "Kód létrehozása", "Kód megjelenítése", // v0.127.3 (Viktor): the reveal states EXPLICITLY that the shown code is already the // live one (the supersede happened at upload, before display). - "Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentse el most: a kód többé nem jeleníthető meg.", + "Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentsd el most: a kód többé nem jelenik meg.", // The pre-generation cancel must stay next to the start button (nothing runs until then). `>Mégsem`, "nem ezen a szerveren", diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 575607f..c4f973f 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow" "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec" "log" "net/http" @@ -1013,6 +1014,31 @@ func (s *Server) backupsOffboxData(data map[string]interface{}, lang string) { } } data["OffboxBlockedSet"] = blocked + // Decision 50 (v0.283.0): the one-press offer for apps installed before apps went off-site by default, + // shown until answered; and the size card when the selected apps will not fit the quota. + if s.backupMgr != nil && s.backupMgr.OffboxConfigured() && !s.settings.OffboxOfferDismissedValue() { + if off := s.appsWithoutOffbox(); len(off) > 0 { + data["OffboxOfferCount"] = len(off) + } + } + if s.backupMgr != nil && offboxTgt != nil && offboxTgt.QuotaGB > 0 && s.backupMgr.OffboxConfigured() { + if f := s.backupMgr.OffsiteFitForPage(); !f.At.IsZero() && !f.Fits { + data["OffsiteFitTotal"] = appbackup.HumanizeBytes(f.TotalBytes) + data["OffsiteFitQuota"] = offboxTgt.QuotaGB + var top []string + for i, a := range f.Apps { + if i == 3 || a.Bytes == 0 { + break + } + name := a.Stack + if st, ok := s.stackMgr.GetStack(a.Stack); ok && st.Meta.DisplayName != "" { + name = st.Meta.DisplayName + } + top = append(top, name+" ("+appbackup.HumanizeBytes(a.Bytes)+")") + } + data["OffsiteFitTop"] = strings.Join(top, ", ") + } + } } // offboxStaleWarningMarker is the substring the zero-toggled offbox run wrote into LastWarning before @@ -2244,8 +2270,14 @@ func (s *Server) systemPageData() map[string]interface{} { data["GitRepoURL"] = s.cfg.Git.RepoURL data["GitSyncInterval"] = s.cfg.Git.SyncInterval data["BackupEnabled"] = s.cfg.Backup.Enabled - data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule - data["ResticSchedule"] = s.cfg.Backup.ResticSchedule + // R-724 (v0.283.0): the schedule the box really runs — the household's backup window and its derived + // legs, the same numbers the backups page shows — not the controller.yaml seed (measured 2026-09-29: + // Beállítások said „02:30 / 03:00" while the backups page said 02:30 / 03:30 / 04:15). + { + db, tier2, offbox := backupwindow.LegTimes(s.effectiveBackupWindow()) + data["DBDumpSchedule"] = db + data["ResticSchedule"] = tier2 + " / " + offbox + } data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase data["HubEnabled"] = s.cfg.Hub.Enabled @@ -2264,7 +2296,11 @@ func (s *Server) systemPageData() map[string]interface{} { if status.LastCheck != nil { data["UpdateAvailable"] = status.LastCheck.UpdateAvailable data["LatestVersion"] = status.LastCheck.LatestVersion + // R-724: the box's local time, not a raw RFC3339 stamp. data["LastCheckTime"] = status.LastCheck.CheckedAt + if t, err := time.Parse(time.RFC3339, status.LastCheck.CheckedAt); err == nil { + data["LastCheckTime"] = t.In(getTimezone()).Format("2006-01-02 15:04") + } data["LastCheckError"] = status.LastCheck.Error } if status.LastState != nil { diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index eba8339..ee36607 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -356,6 +356,18 @@ func i18nCases() []i18nCase { d["SignupNative"], d["SignupNativeFailed"] = true, true return d }}) + // v0.283.0 (decision 50): the one-press off-site offer on both backup pages, and the size card. + base = append(base, i18nCase{"backups_remote_offsite_offer_fit", "backups_remote", func() map[string]interface{} { + return i18nRemoteData(func(d, o m) { + d["EscrowAgentOK"] = true + d["OffboxOfferCount"] = 2 + d["OffsiteFitTotal"], d["OffsiteFitQuota"], d["OffsiteFitTop"] = "130.0 GB", 100, "Immich (95.0 GB), Nextcloud (34.0 GB)" + }) + }}, i18nCase{"backups_apps_offsite_offer", "backups_apps", func() map[string]interface{} { + d := i18nAppsData("ok", false, true, "dumps") + d["OffboxOfferCount"] = 2 + return d + }}) base = append(base, i18nCase{"app_info_known_login_changed", "app_info", func() map[string]interface{} { d := i18nLayoutData("stacks", "Calibre-Web") st := stacks.Stack{Name: "calibre-web", Deployed: true, State: stacks.StateRunning} diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go index 5367b3f..b8f277c 100644 --- a/controller/internal/web/offbox_handlers.go +++ b/controller/internal/web/offbox_handlers.go @@ -224,6 +224,37 @@ func (s *Server) offboxToggleHandler(w http.ResponseWriter, r *http.Request) { offboxRedirect(w, r, "flash.offbox.app_setting_updated", false) } +// offboxEnableAllHandler (decision 50): the one press that puts every installed app that is not yet +// off-site into the off-site copy. Redirects back to the page it came from (the remote or the apps page). +func (s *Server) offboxEnableAllHandler(w http.ResponseWriter, r *http.Request) { + off := s.appsWithoutOffbox() + if err := s.settings.EnableOffboxFor(off); err != nil { + offboxRedirect(w, r, "flash.offbox.save_failed", true) + return + } + s.logger.Printf("[INFO] [web] off-site switched ON for %d app(s) by the household's one press: %v", len(off), off) + s.reportTriggerNow() + offboxRedirect(w, r, "flash.offbox.enabled_all", false) +} + +// offboxOfferDismissHandler records „Nem most" — the offer is not shown again. +func (s *Server) offboxOfferDismissHandler(w http.ResponseWriter, r *http.Request) { + if err := s.settings.DismissOffboxOffer(); err != nil { + offboxRedirect(w, r, "flash.offbox.save_failed", true) + return + } + offboxRedirect(w, r, "flash.offbox.app_setting_updated", false) +} + +// appsWithoutOffbox: the installed apps (off-site-selectable, as the page lists them) that are not off-site. +func (s *Server) appsWithoutOffbox() []string { + var names []string + for _, a := range s.buildOffboxApps() { + names = append(names, a.Name) + } + return s.settings.AppsWithoutOffbox(names) +} + // offboxRunHandler triggers an off-box backup now (async — it can run for minutes). func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) { if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() { diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 759f215..4fac20e 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -731,6 +731,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.offboxConfigHandler(w, r) case path == "/backup/offbox/toggle" && r.Method == http.MethodPost: s.offboxToggleHandler(w, r) + case path == "/backup/offbox/enable-all" && r.Method == http.MethodPost: // decision 50 + s.offboxEnableAllHandler(w, r) + case path == "/backup/offbox/offer-dismiss" && r.Method == http.MethodPost: // decision 50 + s.offboxOfferDismissHandler(w, r) case path == "/backup/offbox/run" && r.Method == http.MethodPost: s.offboxRunHandler(w, r) case path == "/backup/offbox/reset" && r.Method == http.MethodPost: diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index b8496b6..e05cf5f 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -105,7 +105,7 @@
{{if .HasRestoreTest}}{{if .RestorePass}}✓{{else}}✗{{end}}{{else}}–{{end}}
{{T "backups.visszaallitas_ellenorizve"}} - {{if .HasRestoreTest}}
{{fmtTime .RestoreTestedAt}}{{else}}
{{T "backups.meg_nem_futott"}}{{end}} + {{if .HasRestoreTest}}
{{fmtTime .RestoreTestedAt}}{{if .RestoreTier}} · {{.RestoreTier}}{{end}}{{else}}
{{T "backups.meg_nem_futott"}}{{end}}
diff --git a/controller/internal/web/templates/backups_apps.html b/controller/internal/web/templates/backups_apps.html index 1b4f143..5022810 100644 --- a/controller/internal/web/templates/backups_apps.html +++ b/controller/internal/web/templates/backups_apps.html @@ -18,7 +18,13 @@ {{template "backups_empty" .}} {{else}} -

{{T "backups_apps.alkalmazas_mentesek_adatbazis_konfigurac"}}

+{{if .OffboxOfferCount}} +
+ {{T "backups_offsite.offer_text"}} ({{.OffboxOfferCount}}) +
{{$.CSRFField}}
+
{{$.CSRFField}}
+
+{{end}}

{{T "backups_apps.alkalmazas_mentesek_adatbazis_konfigurac"}}

{{T "backups_apps.az_egyes_alkalmazasok_reszletes_granulal"}}

diff --git a/controller/internal/web/templates/backups_remote.html b/controller/internal/web/templates/backups_remote.html index 8770c3b..4d29150 100644 --- a/controller/internal/web/templates/backups_remote.html +++ b/controller/internal/web/templates/backups_remote.html @@ -211,7 +211,20 @@
-

{{T "backups_remote.mely_alkalmazasok_mentodnek_a_tavoli"}}

+ {{if .OffboxOfferCount}} +
+ {{T "backups_offsite.offer_text"}} ({{.OffboxOfferCount}}) +
{{$.CSRFField}}
+
{{$.CSRFField}}
+
+ {{end}}{{if .OffsiteFitTotal}} +
+ {{T "backups_offsite.fit_head"}} +
{{T "backups_offsite.fit_total"}} {{.OffsiteFitTotal}} · {{T "backups_offsite.fit_quota"}} {{.OffsiteFitQuota}} GB
+
{{T "backups_offsite.fit_largest"}} {{.OffsiteFitTop}}
+
{{T "backups_offsite.fit_choose"}}
+
+ {{end}}

{{T "backups_remote.mely_alkalmazasok_mentodnek_a_tavoli"}}

{{if and .OffboxApps (eq .Offbox.EscrowState "escrowed") (eq .OffboxToggledCount 0)}}

{{T "backups_remote.nincs_tavoli_mentesre_jelolt_alkalmazas"}}

{{end}} diff --git a/controller/internal/web/templates/dashboard.html b/controller/internal/web/templates/dashboard.html index 27161cf..8e2c963 100644 --- a/controller/internal/web/templates/dashboard.html +++ b/controller/internal/web/templates/dashboard.html @@ -151,7 +151,7 @@ {{T "dashboard.utolso_mentes"}} {{if .BackupStatus.Success}} - {{.BackupStatus.LastRun.Format "2006-01-02 15:04"}} + {{fmtTime .BackupStatus.LastRun}} {{else}} {{T "dashboard.sikertelen"}} {{end}} diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_offsite_offer.html b/controller/internal/web/testdata/i18n_parity/backups_apps_offsite_offer.html new file mode 100644 index 0000000..ab7ed10 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_offsite_offer.html @@ -0,0 +1,1602 @@ + + + + + + + + Alkalmazás mentések — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ Van alkalmazás, amelyről nem készül távoli mentés. Bekapcsolod mindegyikre? (2) +
+
+
+

Alkalmazás-mentések (adatbázis + konfiguráció)

+

Az egyes alkalmazások részletes, granulált mentése — adatbázis-kiírások, beállítások és alkalmazás-fájlok. A fenti teljes mentéstől függetlenül, alkalmazásonként visszaállítható.

+ + +
+

Ütemezés

+
+
+ Adatbázis mentés + 02:30 + Következő: 2026-09-14 05:12 +
+
+
+ +
+ Utolsó adatbázis mentés: + 2026-09-14 05:12 (# napja) +
+ +
+
+ +
+
+ + +
+

Adatbázisok

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AlkalmazásTípusMéretUtolsóÉrvényesítésÁllapot
kimaiMariaDB–– + + – + + + + Hiba + +
bookstackMariaDB1.0 MB09-14 05:12 + + 12 tábla + + + + OK + +
immichPostgreSQL2.0 MB09-14 05:12 + + Hiba + + + + OK + +
rommPostgreSQL3.0 MB09-14 05:12 + + – + + + + OK + +
+
+ +
+ + + +
+

Alkalmazások mentési állapota

+ + +
+ Felhasználói adatokról nincs biztonsági mentés.
+ A szerveren tárolt fotók, dokumentumok és egyéb fájlok jelenleg csak egy példányban léteznek. + Külső meghajtó csatlakoztatásával biztonsági másolat készíthető a 3-2-1 szabály szerint. + Meghajtó beállítása → +
+ + + +
+ +
+ + App removed +
+ + Eltávolítva — visszaállítható + HDD egy + + +
+ ▶ +
+ +
+ +
+ +
+ + App disconnected +
+ + Meghajtó leválasztva + + +
+ ▶ +
+ +
+ +
+ +
+ + App hdd +
+ + HDD egy + 12 GB + + +
+ ▶ +
+ +
+ +
+ +
+ + App volume +
+ + Konfig + DB + Adatok + + +
+ ▶ +
+ +
+ +
+ +
+ + App confonly +
+ + Konfig + + +
+ ▶ +
+ +
+ +
+ +
+ + App inact1 +
+ + Konfig + Adatok + + +
+ ▶ +
+ +
+ +
+ +
+ + App inact2 +
+ + Konfig + Adatok + + +
+ ▶ +
+ +
+ +
+ +
+ + App inact3 +
+ + Konfig + Adatok + + +
+ ▶ +
+ +
+ +
+ +
+ + App okrun +
+ + Konfig + + +
+ ▶ +
+ +
+ +
+ +
+ + App oksucc +
+ + Konfig + + +
+ ▶ +
+ +
+ +
+ +
+ + App nosucc +
+ + Konfig + + +
+ ▶ +
+ +
+ +
+ +
+ + App nosucc2 +
+ + Konfig + + +
+ ▶ +
+ +
+ +
+ +
+ + App t2err +
+ + Konfig + + +
+ ▶ +
+ +
+ + +
+ + + + + + + +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/backups_full.html b/controller/internal/web/testdata/i18n_parity/backups_full.html index 5076188..167a66f 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_full.html +++ b/controller/internal/web/testdata/i18n_parity/backups_full.html @@ -284,7 +284,7 @@
Naprakész
Következő mentés -
# órája — a mentési ablakon belül +
az előző # órája készült — a mentési időn belül
diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_offsite_offer_fit.html b/controller/internal/web/testdata/i18n_parity/backups_remote_offsite_offer_fit.html new file mode 100644 index 0000000..5a3a8bf --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_offsite_offer_fit.html @@ -0,0 +1,704 @@ + + + + + + + + Távoli mentés beállítása — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +

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.

+
+ +
+
+
✓ Rendben
+
Utolsó távoli mentés
# napja
+
+
+
5 GB
+
Tároló méret · 10 pillanatkép
+
+
+
felhom@nas.example
+
/srv/repo
+
+
+ + + +
+
Tárhelykeret: 5 GB / 100 GB (5%)
+
+
+
+
+ + + + + + + + + + + +
+

A helyreállítási kód letétbe helyezve.

+ Új helyreállítási kód készítése +
+ + + +
+
+ +
+
+ + + +
+ Van alkalmazás, amelyről nem készül távoli mentés. Bekapcsolod mindegyikre? (2) +
+
+
+ +
+ A kijelölt alkalmazások nem férnek el a távoli tárhelyen. +
Becsült méret: 130.0 GB · tárhelykeret: 100 GB
+
A legnagyobbak: Immich (95.0 GB), Nextcloud (34.0 GB)
+
Kapcsold ki a távoli mentést annál, amelyiknek nem kell a házon kívül is lennie. Addig a legnagyobbaknak csak a beállításai és az adatbázisa kerül fel, és ha a keret betelik, új távoli mentés nem készül. Régi mentést a doboz ezért nem töröl.
+
+

Mely alkalmazások mentődnek a távoli tárolóra?

+ +

Nincs telepített alkalmazás.

+ + + +
+ Távoli mentési cél beállítása +
+
+
+
+
+
+
+ + A kulcsot 0600-as fájlba írjuk; sosem naplózzuk és nem tároljuk a beállításokban.
+
+ + A host-kulcs rögzítése (no blind TOFU). Lekérdezhető: ssh-keyscan -p <port> <host>
+ +
+
+
+ + + + + +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/dashboard_full.html b/controller/internal/web/testdata/i18n_parity/dashboard_full.html index 6405740..066bad1 100644 --- a/controller/internal/web/testdata/i18n_parity/dashboard_full.html +++ b/controller/internal/web/testdata/i18n_parity/dashboard_full.html @@ -304,7 +304,7 @@ Utolsó mentés: - 2026-09-14 03:12 + 2026-09-14 05:12
diff --git a/controller/internal/web/testdata/i18n_parity/escrow_first.html b/controller/internal/web/testdata/i18n_parity/escrow_first.html index 8cd9bd3..9b1da27 100644 --- a/controller/internal/web/testdata/i18n_parity/escrow_first.html +++ b/controller/internal/web/testdata/i18n_parity/escrow_first.html @@ -190,16 +190,16 @@