diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a01a8..353721a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## v0.296.0 — a backup cut off by a power cut or a restart is said on the page; the whole-system backup text states today's measurement (R-519, R-518) (2026-10-05) + +**MinAgent: 0.131.0** (unchanged). New household string: `backups.interrupted_run` (Hungarian and English); the +whole-system backup text and its confirm changed (`backups.a_mentes_alatt_az_alkalmazasok`, +`backups.elinditod_a_teljes_rendszermentest_most`). + +- **R-519.** `internal/backup/run_record.go`: the app-data run is on record (`appdata-run.json` in DataDir, atomic, at + both ends — the restore record's shape, R-550). A start that finds a run still marked running turns it into an + INTERRUPTED notice on /backups and /backups/apps, kept until a run ends with every step OK (a run with a failed step + keeps it). Measured 2026-09-14 (BIGNIGHT F2): after a power cut mid-volume-dump both pages said nothing and „Utolsó + adatbázis mentés … OK". After a restart that line is SYNTHESISED from the dump files — now it reads failed when the + newest run was cut. The restore point's own time is already its OLDEST part (data block, v0.275.0, R-696). Wired in + `main()` (`loadRunRecordAtStartup`, AST-pinned). Tests `TestRunRecord_*` (incl. the production path through + `runDBDumpsInternal`), parity cases `backups_interrupted_run`, `backups_apps_interrupted_run`; 3 red-proofs. +- **R-518.** The whole-system „Mentés most" text and confirm now give both measurements — ≈6 minutes on a 9-app box + (demo-hp today: stopped 09:19:08Z, last app back 09:24:55Z, only the local tier ran) and ≈8 minutes on a 12-app box + (2026-09-14) — say "minutes, not seconds", and that the off-site copy in the same run makes it longer. 1 red-proof. +- Evidence: `felhom.eu/documentation/audits/hub-safety-2026-10-05/partE/`. + ## v0.295.0 — a box that was off at its backup time catches up once; the household sees why in a banner (R-871, `09` decisions 109–110) (2026-10-05) **MinAgent: 0.131.0** (unchanged). New household strings: the timeline line `event.backup_catchup_done` and the banner diff --git a/controller/README.md b/controller/README.md index ec26565..99f5f9e 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1128,6 +1128,13 @@ was off at the backup time (from the metrics record, one sample a minute), and s on — it never changes the time itself. Closing it lasts until the next missed backup time; a successful night removes it. Design: `felhom.eu/documentation/architecture/07-backup-architecture.md` §6.1.1. +**A backup run cut off by a power cut or a restart is said (v0.296.0, R-519).** `internal/backup/run_record.go`. The +app-data run is on record (`/appdata-run.json`) while it runs; a start that finds it still "running" turns it +into a notice on /backups and /backups/apps („A legutóbbi mentés (…) megszakadt…"), kept until a run ends with every +step OK. After a restart the page's "last database backup" (synthesised from the files) no longer reads OK over a cut +run. Each restore point already carries the time of its OLDEST part (the data block, v0.275.0). The whole-system +"Mentés most" text states two measurements: ≈6 min on a 9-app box (demo-hp, 2026-10-05), ≈8 min on a 12-app box. + **The restore carries the customer's own previous answers (v0.217.0, R-351).** `internal/backup/offbox_placement.go`. Every recovery unit's `manifest.json` records `drive` and `namespace_root`, and its `compose/app.yaml` records `SUBDOMAIN`/`DOMAIN`. Until v0.217.0 nothing read diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 2cc96a8..22ddd98 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -553,6 +553,8 @@ func main() { backupMgr = backup.NewManager(cfg, sett, logger) // R-550: load the persisted restore record BEFORE any page can ask for it. interruptedRestore = loadRestoreRecordAtStartup(backupMgr, cfg.Paths.DataDir, logger) + // R-519: an app-data run the stop cut off is said on the backup pages until the next complete run. + loadRunRecordAtStartup(backupMgr, cfg.Paths.DataDir, logger) // R-166: use the guard that already ran Recover at startup, not a second one over the same // file (see SetAppStopGuard — one file, one owner). backupMgr.SetAppStopGuard(appStopGuard) diff --git a/controller/cmd/controller/run_record_wiring.go b/controller/cmd/controller/run_record_wiring.go new file mode 100644 index 0000000..52beb1b --- /dev/null +++ b/controller/cmd/controller/run_record_wiring.go @@ -0,0 +1,33 @@ +package main + +import ( + "log" + "path/filepath" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" +) + +// R-519 (v0.296.0) — an app-data backup run cut off by a power cut or a restart is said on the backup pages until +// the next complete run (internal/backup/run_record.go). main() calls this right after the restore record, before +// any page or any backup run can read the manager. Pinned by TestMainWiresRunRecord. + +// runRecordFileName lives in DataDir, beside settings.json and the restore record. +const runRecordFileName = "appdata-run.json" + +func loadRunRecordAtStartup(mgr *backup.Manager, dataDir string, logger *log.Logger) time.Time { + if mgr == nil { + return time.Time{} + } + path := filepath.Join(dataDir, runRecordFileName) + mgr.SetRunRecordPath(path) + cut := mgr.LoadRunRecord() + if logger != nil { + if !cut.IsZero() { + logger.Printf("[WARN] [backup] the app-data backup run started %s was cut off by the stop — the backup pages say so until the next complete run (R-519)", cut.UTC().Format(time.RFC3339)) + } else { + logger.Printf("[INFO] [backup] app-data run record wired: %s (an earlier cut still shown: %t)", path, !mgr.InterruptedRunAt().IsZero()) + } + } + return cut +} diff --git a/controller/cmd/controller/run_record_wiring_test.go b/controller/cmd/controller/run_record_wiring_test.go new file mode 100644 index 0000000..98802c8 --- /dev/null +++ b/controller/cmd/controller/run_record_wiring_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "io" + "log" + "os" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" +) + +// R-519: the function main() calls finds a cut run. +func TestRunRecordAtStartup_FindsACutRun(t *testing.T) { + dir := t.TempDir() + lg := log.New(io.Discard, "", 0) + before := new(backup.Manager) + before.SetRunRecordPath(dir + "/" + runRecordFileName) + // the record a run leaves when the power goes mid-run (markRunStarted is unexported — write its file shape) + writeRunning(t, dir+"/"+runRecordFileName) + after := new(backup.Manager) + if cut := loadRunRecordAtStartup(after, dir, lg); cut.IsZero() || after.InterruptedRunAt().IsZero() { + t.Fatal("a cut run was not found at startup") + } +} + +// The seam must be CALLED from main() — an AST call, not a string (a comment would match a string). +// RED-PROOF: delete the call in main() → this fails. +func TestMainWiresRunRecord(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 { + if c, ok := n.(*ast.CallExpr); ok { + if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "loadRunRecordAtStartup" { + found = true + } + } + return true + }) + if !found { + t.Fatal("main() never calls loadRunRecordAtStartup — a cut run is never said on the page") + } +} + +func writeRunning(t *testing.T, path string) { + t.Helper() + b := []byte(`{"running":true,"started_at":"` + time.Date(2026, 9, 14, 19, 40, 3, 0, time.UTC).Format(time.RFC3339) + `"}`) + if err := writeFileForTest(path, b); err != nil { + t.Fatal(err) + } +} + +func writeFileForTest(path string, b []byte) error { return os.WriteFile(path, b, 0o600) } diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 48de179..85c7dce 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -299,6 +299,9 @@ type Manager struct { // R-550: persistence of the above (restore_record.go) and the per-app interrupted notices. opRecordPath string opInterrupted map[string]RestoreOpResult + // R-519: the app-data run record (run_record.go) and the start of the last run a stop cut off (zero = none). + runRecordPath string + runInterrupted time.Time // Cached status for page rendering (refreshed periodically) cachedStatus *FullBackupStatus @@ -311,9 +314,13 @@ type FullBackupStatus struct { Running bool // DB Dumps - LastDBDump *DBDumpStatus - DumpFiles []DumpFileInfo - DiscoveredDBs []DiscoveredDB + LastDBDump *DBDumpStatus + // InterruptedRunAt (R-519): the start of the last app-data run a power cut or a restart cut off; zero = the + // newest run finished. Shown on /backups and /backups/apps until a run ends with every step OK. + InterruptedRunAt time.Time + Interrupted bool // InterruptedRunAt is set (the template gate: a time.Time is always "true" to `if`) + DumpFiles []DumpFileInfo + DiscoveredDBs []DiscoveredDB // Schedule DBDumpSchedule string @@ -548,6 +555,11 @@ func (m *Manager) beginOffsiteRunStamp(runID string) func() { func (m *Manager) runDBDumpsInternal(ctx context.Context) error { start := time.Now() m.logger.Printf("[INFO] [backup] Starting database dump run") + // R-519: the run is on record while it runs; a stop before markRunEnded leaves it "running", which the next + // start turns into the page's interrupted-run notice (run_record.go). Every return path ends the record. + m.markRunStarted(start) + runOK := false + defer func() { m.markRunEnded(runOK) }() // R-181: open the per-run admission scope HERE, because this function is the single orchestrator // of all three write legs. Each app's reserve verdict is taken at its first write of this run and @@ -688,6 +700,7 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error { // residue, invisible in the snapshot list. Guarded (deployed + different current drive only). m.pruneStalePrimaryDirs() + runOK = allOK // No silent partials: a DB-dump or volume-dump failure fails the whole run. if !allOK { return fmt.Errorf("some backup steps failed: %s", strings.Join(failedSummaryLines(summary), "; ")) @@ -1210,6 +1223,7 @@ func (m *Manager) RefreshCache(nextDBDump time.Time) { m.mu.Lock() status.Running = m.running status.LastDBDump = m.lastDBDump + status.InterruptedRunAt, status.Interrupted = m.runInterrupted, !m.runInterrupted.IsZero() // Cross-check lastDBDump results inside lock to prevent torn writes. if m.lastDBDump != nil && len(files) > 0 { @@ -1252,7 +1266,8 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus { // Update dynamic fields that don't need subprocess calls status.Running = m.running status.NextDBDump = nextDBDump - status.SingleCopyWarning = m.singleCopyWarning() // F6: honest single-drive signal + status.SingleCopyWarning = m.singleCopyWarning() // F6: honest single-drive signal + status.InterruptedRunAt, status.Interrupted = m.runInterrupted, !m.runInterrupted.IsZero() // R-519 // Deep-copy lastDBDump so callers cannot mutate shared state. if m.lastDBDump != nil { copyDump := *m.lastDBDump @@ -1278,10 +1293,12 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus { latestTime = f.ModTime } } + // R-519: after a restart the run's result is not in memory, so it is SYNTHESISED from the files. A run + // the stop cut off left a fresh .sql beside last night's tars — "Success" must not be read off that. status.LastDBDump = &DBDumpStatus{ LastRun: latestTime, Results: results, - Success: true, + Success: m.runInterrupted.IsZero(), } } @@ -1295,6 +1312,8 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus { DBDumpSchedule: m.cfg.Backup.DBDumpSchedule, NextDBDump: nextDBDump, SingleCopyWarning: m.singleCopyWarning(), // F6 + InterruptedRunAt: m.runInterrupted, // R-519 + Interrupted: !m.runInterrupted.IsZero(), } if m.lastDBDump != nil { copyDump := *m.lastDBDump diff --git a/controller/internal/backup/run_record.go b/controller/internal/backup/run_record.go new file mode 100644 index 0000000..411507a --- /dev/null +++ b/controller/internal/backup/run_record.go @@ -0,0 +1,113 @@ +package backup + +import ( + "encoding/json" + "os" + "time" +) + +// R-519 (controller v0.296.0) — an app-data backup run cut off by a power cut or a restart is SAID on the page. +// +// MEASURED 2026-09-14 (BIGNIGHT F2): the power was cut during a volume dump; afterwards /backups and /backups/apps +// said nothing of an interruption, and every app read „Utolsó: 5 perce". The controller knew only when apps had +// been stopped (the app-stop marker); a cut during the DATABASE leg, with every app running, left no trace at all. +// +// Shape (the restore record's, R-550): one JSON file in DataDir, written atomically at BOTH ends of a run. At +// startup a file still marked running is, by construction, a run nothing is running any more: it becomes the +// INTERRUPTED notice, kept until the next app-data run that ends with every step OK. The restore points of a torn +// run already carry the time of their OLDEST part (the data block, v0.275.0) — this adds the sentence. +// No path set (a bare Manager in a test, a box with backup disabled) = no persistence, silently. +// +// Pinned by run_record_test.go (TestRunRecord_*). + +type runRecordFile struct { + Running bool `json:"running"` + StartedAt time.Time `json:"started_at,omitempty"` + Interrupted time.Time `json:"interrupted,omitempty"` // the start of the run that was cut off; zero = none +} + +// SetRunRecordPath wires persistence. Call before LoadRunRecord and before any backup runs. +func (m *Manager) SetRunRecordPath(path string) { + m.mu.Lock() + defer m.mu.Unlock() + m.runRecordPath = path +} + +// LoadRunRecord reads the record at startup. It returns the start time of a run the stop cut off — non-zero +// exactly once per interruption (the conversion is written back), so the caller logs it once. +func (m *Manager) LoadRunRecord() time.Time { + m.mu.Lock() + defer m.mu.Unlock() + if m.runRecordPath == "" { + return time.Time{} + } + rec, ok := m.readRunRecordLocked() + if !ok { + return time.Time{} + } + m.runInterrupted = rec.Interrupted + if !rec.Running { + return time.Time{} + } + rec.Running = false + rec.Interrupted = rec.StartedAt + m.runInterrupted = rec.StartedAt + m.writeRunRecordLocked(rec) + return rec.StartedAt +} + +// InterruptedRunAt is the start of the last app-data run that was cut off, or zero when the newest run finished. +func (m *Manager) InterruptedRunAt() time.Time { + m.mu.Lock() + defer m.mu.Unlock() + return m.runInterrupted +} + +// markRunStarted is called at the start of an app-data run (runDBDumpsInternal). +func (m *Manager) markRunStarted(at time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + if m.runRecordPath == "" { + return + } + m.writeRunRecordLocked(runRecordFile{Running: true, StartedAt: at, Interrupted: m.runInterrupted}) +} + +// markRunEnded is called on EVERY return of the run. allOK clears the interrupted notice: the household's copies +// are whole again. A run that ended with a failed step keeps it (that run has its own failure line too). +func (m *Manager) markRunEnded(allOK bool) { + m.mu.Lock() + defer m.mu.Unlock() + if allOK { + m.runInterrupted = time.Time{} + } + if m.runRecordPath == "" { + return + } + m.writeRunRecordLocked(runRecordFile{Running: false, Interrupted: m.runInterrupted}) +} + +func (m *Manager) readRunRecordLocked() (runRecordFile, bool) { + var rec runRecordFile + b, err := os.ReadFile(m.runRecordPath) + if err != nil { + if !os.IsNotExist(err) && m.logger != nil { + m.logger.Printf("[WARN] [backup] run record unreadable (%v) — no interrupted-run notice", err) + } + return rec, false + } + if err := json.Unmarshal(b, &rec); err != nil { + if m.logger != nil { + m.logger.Printf("[WARN] [backup] run record is not JSON (%v) — no interrupted-run notice", err) + } + return rec, false + } + return rec, true +} + +func (m *Manager) writeRunRecordLocked(rec runRecordFile) { + b, _ := json.Marshal(rec) + if err := atomicWrite(m.runRecordPath, b, 0o600); err != nil && m.logger != nil { + m.logger.Printf("[WARN] [backup] could not persist the run record to %s: %v", m.runRecordPath, err) + } +} diff --git a/controller/internal/backup/run_record_test.go b/controller/internal/backup/run_record_test.go new file mode 100644 index 0000000..d56f1b8 --- /dev/null +++ b/controller/internal/backup/run_record_test.go @@ -0,0 +1,99 @@ +package backup + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +// R-519 (controller v0.296.0). RED-PROOFS recorded in felhom.eu/documentation/audits/hub-safety-2026-10-05/partE/: +// 1. delete the m.markRunStarted call in runDBDumpsInternal → TestRunRecord_TheRealRunIsOnRecordWhileItRuns fails; +// 2. synthesise LastDBDump with `Success: true` again → TestRunRecord_SynthesisedStatusIsNotOKAfterACut fails. + +// A run the stop cut off is said — once on load, then on the page until a run ends with every step OK. A run that +// ended with a failed step does not clear it. +func TestRunRecord_CutRunIsSaidUntilACompleteOne(t *testing.T) { + path := filepath.Join(t.TempDir(), "appdata-run.json") + t0 := time.Date(2026, 9, 14, 19, 40, 3, 0, time.UTC) + + before, _ := newTestManager(t, "/srv/sys") + before.SetRunRecordPath(path) + before.markRunStarted(t0) // the power goes here: no markRunEnded + + after, _ := newTestManager(t, "/srv/sys") + after.SetRunRecordPath(path) + if got := after.LoadRunRecord(); !got.Equal(t0) { + t.Fatalf("the cut run was not found at startup: %v", got) + } + if !after.InterruptedRunAt().Equal(t0) || !after.GetFullStatus(time.Time{}).Interrupted { + t.Fatal("the page status does not carry the cut run") + } + + again, _ := newTestManager(t, "/srv/sys") + again.SetRunRecordPath(path) + if got := again.LoadRunRecord(); !got.IsZero() { + t.Fatalf("the same cut was reported twice: %v", got) + } + if !again.InterruptedRunAt().Equal(t0) { + t.Fatal("the notice must survive a second restart until a complete run") + } + + again.markRunStarted(t0.Add(time.Hour)) + again.markRunEnded(false) // a run with a failed step + if !again.InterruptedRunAt().Equal(t0) { + t.Fatal("a run with a failed step cleared the notice") + } + again.markRunStarted(t0.Add(2 * time.Hour)) + again.markRunEnded(true) + if !again.InterruptedRunAt().IsZero() || again.GetFullStatus(time.Time{}).Interrupted { + t.Fatal("a complete run did not clear the notice") + } + last, _ := newTestManager(t, "/srv/sys") + last.SetRunRecordPath(path) + if got := last.LoadRunRecord(); !got.IsZero() || !last.InterruptedRunAt().IsZero() { + t.Fatal("the cleared notice came back after a restart") + } +} + +// The production path: runDBDumpsInternal itself puts the run on record (running=true) before its first step, +// and takes it off on an early error return. +func TestRunRecord_TheRealRunIsOnRecordWhileItRuns(t *testing.T) { + path := filepath.Join(t.TempDir(), "appdata-run.json") + m, _ := newTestManager(t, t.TempDir()) + m.SetRunRecordPath(path) + sawRunning := false + m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) { + var rec runRecordFile + if b, err := os.ReadFile(path); err == nil && json.Unmarshal(b, &rec) == nil && rec.Running { + sawRunning = true + } + return nil, errors.New("docker is not reachable") + } + _ = m.runDBDumpsInternal(context.Background()) + if !sawRunning { + t.Fatal("the run was not on record while it ran — a cut here would go unnoticed") + } + var rec runRecordFile + b, _ := os.ReadFile(path) + if json.Unmarshal(b, &rec) != nil || rec.Running { + t.Fatalf("an ended run is still on record as running: %s", b) + } +} + +// After a restart the page's "last database backup" is synthesised from the files. After a cut it must not read OK +// (BIGNIGHT F2: „Utolsó adatbázis mentés … OK" over a torn run). +func TestRunRecord_SynthesisedStatusIsNotOKAfterACut(t *testing.T) { + m, _ := newTestManager(t, "/srv/sys") + m.cachedStatus = &FullBackupStatus{DumpFiles: []DumpFileInfo{{StackName: "adventurelog", FileName: "adventurelog-postgres.sql", ModTime: time.Now()}}} + if st := m.GetFullStatus(time.Time{}); st.LastDBDump == nil || !st.LastDBDump.Success { + t.Fatal("control: with no cut the synthesised status reads OK") + } + m.runInterrupted = time.Now().Add(-time.Minute) + if st := m.GetFullStatus(time.Time{}); st.LastDBDump == nil || st.LastDBDump.Success || !st.Interrupted { + t.Fatalf("after a cut the synthesised status still reads OK: %+v", st.LastDBDump) + } +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index f85b932..6ee7bd8 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -189,7 +189,7 @@ "backups.a_kijeloles_nem_sikerult": "The drive could not be assigned:", "backups.a_meghajto_kijelolve": "The drive is assigned.", "backups.a_meghajto_kijelolve_a_beallitas": "The drive is assigned. The setting takes effect after the host agent next restarts.", - "backups.a_mentes_alatt_az_alkalmazasok": "Every app stops for as long as the full system backup takes — that can be several minutes (about 8 minutes, measured on a box with 12 apps), longer with more data.", + "backups.a_mentes_alatt_az_alkalmazasok": "Every app stops for as long as the full system backup takes. That means minutes, not seconds: about 6 minutes on a box with 9 apps, about 8 minutes on a box with 12 — longer with more data, and longer when the off-site copy is made in the same run.", "backups.a_mentes_sikertelen": "The backup failed.", "backups.a_mentesek_egymas_utan_futnak": "The backups run one after the other: database backup, local copy, remote backup, then the full system backup.", "backups.a_rendszermentes_elkeszult": "The system backup is done.", @@ -203,10 +203,11 @@ "backups.beagyazott_db_k_a_kotetmentesben": "embedded databases, inside the volume backup", "backups.biztonsagi_mentes": "Backup", "backups.db_mentesek": "Database backups", - "backups.elinditod_a_teljes_rendszermentest_most": "Start the full system backup now? Every app stops for as long as the full system backup takes — that can be several minutes (about 8 minutes, measured on a box with 12 apps), longer with more data.", + "backups.elinditod_a_teljes_rendszermentest_most": "Start the full system backup now? Every app stops for as long as the full system backup takes. That means minutes, not seconds: about 6 minutes on a box with 9 apps, about 8 minutes on a box with 12 — longer with more data, and longer when the off-site copy is made in the same run.", "backups.esedekes": "Due", "backups.fajl": "{{$n := len .Backup.DumpFiles}}{{$n}} {{if eq $n 1}}file{{else}}files{{end}}", "backups.helyi_masolat": "Local copy: {{.BackupLegTier2}}", + "backups.interrupted_run": "The last backup ({{fmtTime .Backup.InterruptedRunAt}}) was cut off because the box or the controller restarted. What it did not finish keeps its earlier copy — every restore point is as fresh as its oldest part. This message goes away after the next complete backup.", "backups.kijeloles_folyamatban": "Assigning the drive…", "backups.kijelolod_ezt_a_meghajtot_a": "Use this drive for the system backup?", "backups.kijelolom": "Use this drive", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 041f451..7fd594d 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -185,7 +185,7 @@ "backups.a_kijeloles_nem_sikerult": "A kijelölés nem sikerült:", "backups.a_meghajto_kijelolve": "A meghajtó kijelölve.", "backups.a_meghajto_kijelolve_a_beallitas": "A meghajtó kijelölve. A beállítás a host-ügynök következő újraindulása után lép életbe.", - "backups.a_mentes_alatt_az_alkalmazasok": "A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.", + "backups.a_mentes_alatt_az_alkalmazasok": "A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart. Ez perceket jelent, nem másodperceket: egy 9 alkalmazásos gépen kb. 6 perc, egy 12 alkalmazásos gépen kb. 8 perc volt — több adatnál, és ha a távoli másolat is ugyanekkor készül, tovább.", "backups.a_mentes_sikertelen": "A mentés sikertelen.", "backups.a_mentesek_egymas_utan_futnak": "A mentések egymás után futnak: adatbázis-mentés, helyi másolat, távoli mentés, majd a teljes rendszermentés.", "backups.a_rendszermentes_elkeszult": "A rendszermentés elkészült.", @@ -199,10 +199,11 @@ "backups.beagyazott_db_k_a_kotetmentesben": "beágyazott DB-k a kötetmentésben", "backups.biztonsagi_mentes": "Biztonsági mentés", "backups.db_mentesek": "DB mentések", - "backups.elinditod_a_teljes_rendszermentest_most": "Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.", + "backups.elinditod_a_teljes_rendszermentest_most": "Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart. Ez perceket jelent, nem másodperceket: egy 9 alkalmazásos gépen kb. 6 perc, egy 12 alkalmazásos gépen kb. 8 perc volt — több adatnál, és ha a távoli másolat is ugyanekkor készül, tovább.", "backups.esedekes": "Esedékes", "backups.fajl": "{{len .Backup.DumpFiles}} fájl", "backups.helyi_masolat": "Helyi másolat: {{.BackupLegTier2}}", + "backups.interrupted_run": "A legutóbbi mentés ({{fmtTime .Backup.InterruptedRunAt}}) megszakadt, mert a doboz vagy a vezérlő újraindult. Amit nem fejezett be, annak a korábbi mentése maradt meg — minden visszaállítási pont annyira friss, amennyire a legrégebbi része. A következő teljes mentés után ez az üzenet eltűnik.", "backups.kijeloles_folyamatban": "Kijelölés folyamatban…", "backups.kijelolod_ezt_a_meghajtot_a": "Kijelölöd ezt a meghajtót a rendszermentés helyéül?", "backups.kijelolom": "Kijelölöm", diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index 0a3d523..70cd75a 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -401,6 +401,22 @@ func i18nCases() []i18nCase { d["OffboxOfferCount"] = 2 return d }}) + // v0.296.0 (R-519): the interrupted-run line, captured when it was born — on both backup pages. + base = append(base, i18nCase{"backups_interrupted_run", "backups", func() map[string]interface{} { + d := i18nLayoutData("backups", "Biztonsági mentés") + d["Backup"] = map[string]interface{}{"DumpFiles": []string{"a.sql"}, "Interrupted": true, "InterruptedRunAt": i18nFixtureTime(), + "LastDBDump": map[string]interface{}{"Success": false, "Results": []int{1}}} + d["DBSectionState"] = "dumps" + return d + }}, i18nCase{"backups_apps_interrupted_run", "backups_apps", func() map[string]interface{} { + d := i18nAppsData("ok", false, true, "dumps") + if b, ok := d["Backup"].(map[string]interface{}); ok { + b["Interrupted"], b["InterruptedRunAt"] = true, i18nFixtureTime() + } else { + d["Backup"] = map[string]interface{}{"Interrupted": true, "InterruptedRunAt": i18nFixtureTime()} + } + 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/r518_backup_downtime_copy_test.go b/controller/internal/web/r518_backup_downtime_copy_test.go index 5dfcdf6..f8bc6b2 100644 --- a/controller/internal/web/r518_backup_downtime_copy_test.go +++ b/controller/internal/web/r518_backup_downtime_copy_test.go @@ -21,12 +21,17 @@ func TestR518_BackupButtonStatesTheMeasuredDowntime(t *testing.T) { } for _, lang := range []string{"hu", "en"} { body := renderI18nCase(t, s, lang, c) - measured, vague := "8 perc", "" // „kb. 8 perc" + // v0.296.0: BOTH measurements — 2026-09-14 (12 apps, ≈8 min) and 2026-10-05 on demo-hp (9 apps, 5 min 47 s, + // audits/hub-safety-2026-10-05/partE/r518-measure.txt) — and that it is minutes, not seconds. + measured := []string{"kb. 6 perc", "kb. 8 perc", "nem másodperceket"} + vague := "" if lang == "en" { - measured, vague = "about 8 minutes", "usually for a few minutes" + measured, vague = []string{"about 6 minutes", "about 8 minutes", "not seconds"}, "usually for a few minutes" } - if n := strings.Count(body, measured); n < 2 { - t.Errorf("%s: the measured downtime appears %d times, want it on the page AND in the confirm", lang, n) + for _, m := range measured { + if n := strings.Count(body, m); n < 2 { + t.Errorf("%s: %q appears %d times, want it on the page AND in the confirm", lang, m, n) + } } if lang == "en" && strings.Contains(body, vague) { t.Errorf("en: the old vague promise %q is still on the page", vague) diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index e05cf5f..8ca09a3 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -9,6 +9,8 @@ {{template "backups_flash" .}} {{if .Backup}}{{if .Backup.SingleCopyWarning}}
{{.Backup.SingleCopyWarning}}
+{{end}}{{if .Backup.Interrupted}} +
{{T "backups.interrupted_run"}}
{{end}}{{end}} {{/* R-112: the whole-system backup-target state. nil = healthy or unknown = render NOTHING. */}} diff --git a/controller/internal/web/templates/backups_apps.html b/controller/internal/web/templates/backups_apps.html index 5022810..b946060 100644 --- a/controller/internal/web/templates/backups_apps.html +++ b/controller/internal/web/templates/backups_apps.html @@ -7,7 +7,9 @@ {{template "backups_flash" .}} -{{template "restore_banner" .}} +{{template "restore_banner" .}}{{if .Backup}}{{if .Backup.Interrupted}} +
{{T "backups.interrupted_run"}}
+{{end}}{{end}} {{- if .HollowCopyLines}}
{{range .HollowCopyLines}}
{{.}}
{{end}} diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_interrupted_run.html b/controller/internal/web/testdata/i18n_parity/backups_apps_interrupted_run.html new file mode 100644 index 0000000..85b4b4f --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_interrupted_run.html @@ -0,0 +1,1616 @@ + + + + + + + + Alkalmazás mentések — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + +
A legutóbbi mentés (2026-09-14 05:12) megszakadt, mert a doboz vagy a vezérlő újraindult. Amit nem fejezett be, annak a korábbi mentése maradt meg — minden visszaállítási pont annyira friss, amennyire a legrégebbi része. A következő teljes mentés után ez az üzenet eltűnik.
+ + + + +

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_degraded.html b/controller/internal/web/testdata/i18n_parity/backups_degraded.html index 07af29b..3eead4c 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_degraded.html +++ b/controller/internal/web/testdata/i18n_parity/backups_degraded.html @@ -268,7 +268,7 @@ function triggerGuestBackup() { var btn = document.getElementById('wg-backup-btn'); - felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () { + felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart. Ez perceket jelent, nem másodperceket: egy 9 alkalmazásos gépen kb. 6 perc, egy 12 alkalmazásos gépen kb. 8 perc volt — több adatnál, és ha a távoli másolat is ugyanekkor készül, tovább.', function () { var out = document.getElementById('wg-backup-result'); btn.disabled = true; out.innerHTML = 'Mentés indítása…'; diff --git a/controller/internal/web/testdata/i18n_parity/backups_empty.html b/controller/internal/web/testdata/i18n_parity/backups_empty.html index d68cdc7..0905b3e 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_empty.html +++ b/controller/internal/web/testdata/i18n_parity/backups_empty.html @@ -197,7 +197,7 @@ function triggerGuestBackup() { var btn = document.getElementById('wg-backup-btn'); - felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () { + felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart. Ez perceket jelent, nem másodperceket: egy 9 alkalmazásos gépen kb. 6 perc, egy 12 alkalmazásos gépen kb. 8 perc volt — több adatnál, és ha a távoli másolat is ugyanekkor készül, tovább.', function () { var out = document.getElementById('wg-backup-result'); btn.disabled = true; out.innerHTML = 'Mentés indítása…'; diff --git a/controller/internal/web/testdata/i18n_parity/backups_full.html b/controller/internal/web/testdata/i18n_parity/backups_full.html index 73e75bc..4251ced 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_full.html +++ b/controller/internal/web/testdata/i18n_parity/backups_full.html @@ -341,7 +341,7 @@
- A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több. + A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart. Ez perceket jelent, nem másodperceket: egy 9 alkalmazásos gépen kb. 6 perc, egy 12 alkalmazásos gépen kb. 8 perc volt — több adatnál, és ha a távoli másolat is ugyanekkor készül, tovább.
@@ -404,7 +404,7 @@ function triggerGuestBackup() { var btn = document.getElementById('wg-backup-btn'); - felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () { + felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart. Ez perceket jelent, nem másodperceket: egy 9 alkalmazásos gépen kb. 6 perc, egy 12 alkalmazásos gépen kb. 8 perc volt — több adatnál, és ha a távoli másolat is ugyanekkor készül, tovább.', function () { var out = document.getElementById('wg-backup-result'); btn.disabled = true; out.innerHTML = 'Mentés indítása…'; diff --git a/controller/internal/web/testdata/i18n_parity/backups_interrupted_run.html b/controller/internal/web/testdata/i18n_parity/backups_interrupted_run.html new file mode 100644 index 0000000..5f34381 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/backups_interrupted_run.html @@ -0,0 +1,685 @@ + + + + + + + + Biztonsági mentés — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
A legutóbbi mentés (2026-09-14 05:12) megszakadt, mert a doboz vagy a vezérlő újraindult. Amit nem fejezett be, annak a korábbi mentése maradt meg — minden visszaállítási pont annyira friss, amennyire a legrégebbi része. A következő teljes mentés után ez az üzenet eltűnik.
+ + + + + + + + +
+

Tárhely áttekintés

+
+
+ +
+
+
+ DB mentések + 1 fájl +
+
+
+
+ + + + + +
+

Mentési időablak

+

A mentések egymás után futnak: adatbázis-mentés, helyi másolat, távoli mentés, majd a teljes rendszermentés.

+
+ +
+ + +
+ +
+
    +
  • Adatbázis-mentés:
  • +
  • Helyi másolat:
  • +
  • Távoli mentés:
  • +
  • Teljes rendszermentés: kb. – között
  • +
+
+ + +
+ + +
+
✗
+
Adatmentés sikertelen
+
+ + + + +
+
–
+
Távoli rendszermentés
nincs beállítva
+
+ + +
+ +
+ 1 +
+
Adatbázis mentve
+ +
+
+ + + + + + +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html b/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html index 498a035..e5d87d6 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html +++ b/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html @@ -289,7 +289,7 @@ function triggerGuestBackup() { var btn = document.getElementById('wg-backup-btn'); - felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () { + felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart. Ez perceket jelent, nem másodperceket: egy 9 alkalmazásos gépen kb. 6 perc, egy 12 alkalmazásos gépen kb. 8 perc volt — több adatnál, és ha a távoli másolat is ugyanekkor készül, tovább.', function () { var out = document.getElementById('wg-backup-result'); btn.disabled = true; out.innerHTML = 'Mentés indítása…'; diff --git a/controller/internal/web/testdata/i18n_parity/backups_tier_due.html b/controller/internal/web/testdata/i18n_parity/backups_tier_due.html index 573b553..18f73d6 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_tier_due.html +++ b/controller/internal/web/testdata/i18n_parity/backups_tier_due.html @@ -251,7 +251,7 @@
- A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több. + A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart. Ez perceket jelent, nem másodperceket: egy 9 alkalmazásos gépen kb. 6 perc, egy 12 alkalmazásos gépen kb. 8 perc volt — több adatnál, és ha a távoli másolat is ugyanekkor készül, tovább.
@@ -312,7 +312,7 @@ function triggerGuestBackup() { var btn = document.getElementById('wg-backup-btn'); - felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart — ez több perc is lehet (egy 12 alkalmazásos gépen kb. 8 perc volt), nagyobb adatnál több.', function () { + felhomConfirm(btn, 'Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart. Ez perceket jelent, nem másodperceket: egy 9 alkalmazásos gépen kb. 6 perc, egy 12 alkalmazásos gépen kb. 8 perc volt — több adatnál, és ha a távoli másolat is ugyanekkor készül, tovább.', function () { var out = document.getElementById('wg-backup-result'); btn.disabled = true; out.innerHTML = 'Mentés indítása…';