diff --git a/controller/internal/web/backup_page_state_test.go b/controller/internal/web/backup_page_state_test.go index d7d5118..768a018 100644 --- a/controller/internal/web/backup_page_state_test.go +++ b/controller/internal/web/backup_page_state_test.go @@ -191,12 +191,18 @@ func (u *unitProvider) GetStackComposePath(string) (string, bool) { return "comp // --------------------------------------------------------------------------- func renderBackups(t *testing.T, data map[string]interface{}) string { + return renderBackupPage(t, "backups", data) +} + +// renderBackupPage renders one of the four backups sub-page templates (v0.124.0 IA split) +// through the PRODUCTION template tree. +func renderBackupPage(t *testing.T, page string, data map[string]interface{}) string { t.Helper() s := testServer(t) s.loadTemplates() var buf bytes.Buffer - if err := s.tmpl.ExecuteTemplate(&buf, "backups", data); err != nil { - t.Fatalf("render backups: %v", err) + if err := s.tmpl.ExecuteTemplate(&buf, page, data); err != nil { + t.Fatalf("render %s: %v", page, err) } return buf.String() } @@ -227,7 +233,7 @@ func TestBackupsTemplate_Tier3Live(t *testing.T) { LastRun: "2026-07-11T02:00:00Z", EscrowState: "escrowed", } data["OffboxConfigured"] = true - html := renderBackups(t, data) + html := renderBackupPage(t, "backups_apps", data) for _, want := range []string{ "Sikeres", // active badge (LastStatus ok) @@ -235,11 +241,19 @@ func TestBackupsTemplate_Tier3Live(t *testing.T) { "Utolsó:", // a relative last-run time is shown "Kikapcsolva", // radarr off row "Bekapcsolás", // radarr enable link - `href="#offbox-section"`, // the anchor jump target - "Távoli mentés (3. mentés)", // the renamed section header + `href="/backups/remote#offbox-section"`, // the cross-page anchor jump target (IA split) } { if !strings.Contains(html, want) { - t.Errorf("rendered page missing %q", want) + t.Errorf("rendered apps page missing %q", want) + } + } + remoteHTML := renderBackupPage(t, "backups_remote", data) + for _, want := range []string{ + "Távoli mentés (3. mentés)", // the renamed section header + `id="offbox-section"`, // the anchor target the apps-page links jump to + } { + if !strings.Contains(remoteHTML, want) { + t.Errorf("rendered remote page missing %q", want) } } for _, banned := range []string{ @@ -267,7 +281,7 @@ func TestBackupsTemplate_Tier3EscrowPending(t *testing.T) { }) data["Offbox"] = &settings.OffboxTarget{Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "pending"} data["OffboxConfigured"] = true - html := renderBackups(t, data) + html := renderBackupPage(t, "backups_apps", data) if !strings.Contains(html, "Kulcsletétre vár") { t.Error("escrow-pending row must show 'Kulcsletétre vár'") @@ -285,23 +299,22 @@ func TestBackupsTemplate_DBMessaging(t *testing.T) { t.Run("embedded", func(t *testing.T) { data := baseBackupData(nil) data["DBSectionState"] = "embedded" - html := renderBackups(t, data) - for _, want := range []string{ - "beágyazott DB-k a kötetmentésben", // stat-card sublabel - "beágyazott adatbázist használnak", // Adatbázisok empty-state explanation - } { - if !strings.Contains(html, want) { - t.Errorf("embedded page missing %q", want) - } + overviewHTML := renderBackups(t, data) + if !strings.Contains(overviewHTML, "beágyazott DB-k a kötetmentésben") { // stat-card sublabel + t.Error("embedded overview page missing the stat-card sublabel") } - if strings.Contains(html, "Nem található adatbázis mentés.") { + appsHTML := renderBackupPage(t, "backups_apps", data) + if !strings.Contains(appsHTML, "beágyazott adatbázist használnak") { // Adatbázisok empty state + t.Error("embedded apps page missing the Adatbázisok explanation") + } + if strings.Contains(appsHTML, "Nem található adatbázis mentés.") { t.Error("embedded box must NOT show the old bare 'not found' message") } }) t.Run("pending", func(t *testing.T) { data := baseBackupData(nil) data["DBSectionState"] = "pending" - html := renderBackups(t, data) + html := renderBackupPage(t, "backups_apps", data) if !strings.Contains(html, "az első ütemezett mentés éjjel fut le") { t.Error("pending page missing the 'first run tonight' message") } @@ -320,7 +333,7 @@ func TestBackupsTemplate_Tier2RelativeTime(t *testing.T) { Tier2LastRun: "2026-07-10T03:30:00Z", Tier2LastStatus: "ok", Tier2StatusBadge: "Sikeres", Tier3State: "unconfigured", }}) - html := renderBackups(t, data) + html := renderBackupPage(t, "backups_apps", data) // The visible "Utolsó:" label must be a relative time, never the raw RFC3339. (The raw // timestamp legitimately survives in the restore confirm() dialog — precise there by design.) if strings.Contains(html, "Utolsó: 2026-07-10T03:30:00Z") { diff --git a/controller/internal/web/backups_split_test.go b/controller/internal/web/backups_split_test.go new file mode 100644 index 0000000..e513238 --- /dev/null +++ b/controller/internal/web/backups_split_test.go @@ -0,0 +1,120 @@ +package web + +import ( + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// v0.124.0 IA split (§10 nav/state): each backups sub-page renders ONLY its mapped sections — +// a marker string per section, asserted present on its page and absent on every other page. + +func splitTestData() map[string]interface{} { + return map[string]interface{}{ + "Page": "backups", "Title": "Biztonsági mentés", + "Backup": &backup.FullBackupStatus{ + AppDataInfo: []backup.AppBackupInfo{{StackName: "calibre-web", DisplayName: "Calibre-Web"}}, + }, + "AppBackupRows": []AppBackupRow{ + {StackName: "calibre-web", DisplayName: "Calibre-Web", OffboxEnabled: true, Tier3State: "active"}, + }, + "DBSectionState": "dumps", + "Offbox": &settings.OffboxTarget{ + Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "escrowed", + }, + "OffboxConfigured": true, + "OffboxApps": []OffboxAppRow{{Name: "calibre-web", DisplayName: "Calibre-Web", Enabled: true}}, + "OffboxToggledCount": 1, + "OffboxQuotaPct": 0, + "GuestBackup": map[string]interface{}{"Available": false, "Note": "n/a"}, + } +} + +func TestBackupsSplit_SectionsOnExactlyOnePage(t *testing.T) { + pages := map[string]string{ + "backups": renderBackupPage(t, "backups", splitTestData()), + "backups_remote": renderBackupPage(t, "backups_remote", splitTestData()), + "backups_apps": renderBackupPage(t, "backups_apps", splitTestData()), + "backups_restore": renderBackupPage(t, "backups_restore", splitTestData()), + } + + // marker → the ONE page it belongs to + markers := map[string]string{ + "Tárhely áttekintés": "backups", // Section 0 + "Rendszermentés (teljes mentés)": "backups", // whole-guest + ">Adatmentés<": "backups", // stat cards (neutral branch) + `id="offbox-section"`: "backups_remote", // the offbox anchor target + "Távoli mentési cél beállítása": "backups_remote", // manual-target form + "Mely alkalmazások mentődnek": "backups_remote", // toggle list + "

Ütemezés

": "backups_apps", // schedule + "

Adatbázisok

": "backups_apps", // databases + "Alkalmazások mentési állapota": "backups_apps", // per-app rows + `id="restore-app"`: "backups_restore", // restore panel + "Ellenőrző visszaállítás a távoli tárolóból": "backups_restore", // moved restore-to-verify + `action="/backup/offbox/restore"`: "backups_restore", // the MOVED form itself + } + for marker, home := range markers { + for page, html := range pages { + has := strings.Contains(html, marker) + if page == home && !has { + t.Errorf("%s: missing its own section marker %q", page, marker) + } + if page != home && has { + t.Errorf("%s: renders %q which belongs to %s only", page, marker, home) + } + } + } +} + +// The tier-3 row actions must deep-link cross-page to the offbox section (the pre-split +// #offbox-section anchors would be orphaned on the apps page). +func TestBackupsSplit_CrossPageAnchors(t *testing.T) { + data := splitTestData() + data["AppBackupRows"] = []AppBackupRow{ + {StackName: "radarr", DisplayName: "Radarr", Tier3State: "off"}, + } + html := renderBackupPage(t, "backups_apps", data) + if !strings.Contains(html, `href="/backups/remote#offbox-section"`) { + t.Error("tier-3 'Bekapcsolás' must link cross-page to /backups/remote#offbox-section") + } + if strings.Contains(html, `href="#offbox-section"`) { + t.Error("orphaned same-page #offbox-section anchor survives on the apps page") + } +} + +// Route → handler wiring: each sub-route sets its own .Page id, so the sidebar child renders +// active on the right page (backupMgr nil → the empty state renders, the nav still does). +func TestBackupsSplit_RoutesSetPageIDs(t *testing.T) { + s := testServer(t) + s.loadTemplates() + + cases := []struct { + path string + active string + }{ + {"/backups/remote", `href="/backups/remote" class="active"`}, + {"/backups/apps", `href="/backups/apps" class="active"`}, + {"/backups/restore", `href="/backups/restore" class="active"`}, + } + for _, c := range cases { + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", c.path, nil) + switch c.path { + case "/backups/remote": + s.backupsRemoteHandler(rr, req) + case "/backups/apps": + s.backupsAppsHandler(rr, req) + case "/backups/restore": + s.backupsRestoreHandler(rr, req) + } + if rr.Code != 200 { + t.Fatalf("%s: status %d", c.path, rr.Code) + } + if !strings.Contains(rr.Body.String(), c.active) { + t.Errorf("%s: sidebar child not active (want %q)", c.path, c.active) + } + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index f6604e2..566a9ba 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -612,8 +612,55 @@ func isPingConfigured(uuid string) bool { return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME") } +// backupsCommonData builds what every backups sub-page shares (v0.124.0 IA split): the page +// chrome + the backup full-status with the redirect flash. Backup stays nil (empty-state) when +// the manager is absent. Each page handler adds ONLY the data its sections render — no +// duplicated computation across the four pages. +func (s *Server) backupsCommonData(page, title string, r *http.Request) map[string]interface{} { + data := s.baseData(page, title) + if s.backupMgr == nil { + data["Backup"] = nil + return data + } + nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule) + fullStatus := s.backupMgr.GetFullStatus(nextDBDump) + + // Pass flash messages from query params (set by redirect handlers) + if flash := r.URL.Query().Get("flash"); flash != "" { + fullStatus.FlashSuccess = flash + } + if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" { + fullStatus.FlashError = flashErr + } + data["Backup"] = fullStatus + return data +} + +// backupsOffboxData adds the offbox target + per-app toggle state (the remote, apps and restore +// pages all render some of it: status card / toggle list / tier-3 rows / restore-to-verify). +func (s *Server) backupsOffboxData(data map[string]interface{}) { + offboxTgt := s.settings.GetOffboxTarget() + data["Offbox"] = offboxTgt + data["OffboxConfigured"] = s.backupMgr != nil && s.backupMgr.OffboxConfigured() + offboxApps := s.buildOffboxApps() + data["OffboxApps"] = offboxApps + // Zero-toggle hint (take-two obs.): configured + escrowed but no app selected — nothing is + // actually covered by the offsite leg until the customer toggles at least one. + offboxToggled := 0 + for _, a := range offboxApps { + if a.Enabled { + offboxToggled++ + } + } + data["OffboxToggledCount"] = offboxToggled + // SLICE 4 soft-quota usage bar (rendered only when a quota is set — shared model). + data["OffboxQuotaPct"] = backup.OffboxQuotaPercent(offboxTgt) +} + +// backupsHandler renders the Áttekintés page: storage overview, whole-guest Rendszermentés and +// the status stat cards. func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { - data := s.baseData("backups", "Biztonsági mentés") + data := s.backupsCommonData("backups", "Biztonsági mentés", r) // System info for storage overview bars data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector) @@ -622,18 +669,30 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { // Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section. data["GuestBackup"] = s.loadGuestBackup(r.Context()) - if s.backupMgr != nil { - nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule) - fullStatus := s.backupMgr.GetFullStatus(nextDBDump) + if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil { + // DB-section state — honest messaging for embedded-DB-only boxes (SQLite etc.): + // "dumps" (real dumps) | "pending" (discovered, first run tonight) | "embedded". + data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles)) + } - // Pass flash messages from query params (set by redirect handlers) - if flash := r.URL.Query().Get("flash"); flash != "" { - fullStatus.FlashSuccess = flash - } - if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" { - fullStatus.FlashError = flashErr - } + s.executeTemplate(w, r, "backups", data) +} +// backupsRemoteHandler renders the Távoli mentés page: the Felhom-offsite status card, the +// participation toggles and the manual-target form. +func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) { + data := s.backupsCommonData("backups-remote", "Biztonsági mentés — Távoli mentés", r) + s.backupsOffboxData(data) + s.executeTemplate(w, r, "backups_remote", data) +} + +// backupsAppsHandler renders the Alkalmazások page: schedule, databases and the per-app +// 1./2./3. tier rows. +func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) { + data := s.backupsCommonData("backups-apps", "Biztonsági mentés — Alkalmazások", r) + s.backupsOffboxData(data) // the tier-3 rows render $.Offbox status + + if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil { // Enrich AppDataInfo with storage labels storagePaths := s.settings.GetStoragePaths() for i := range fullStatus.AppDataInfo { @@ -653,42 +712,18 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { // Build unified per-app backup rows for the app-data backup UI. // Disk-tier (cross-drive / restic) backup has moved to the host agent. data["AppBackupRows"] = s.buildAppBackupRows(fullStatus) - - data["Backup"] = fullStatus - - // DB-section state — honest messaging for embedded-DB-only boxes (SQLite etc.): - // "dumps" (real dumps) | "pending" (discovered, first run tonight) | "embedded". data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles)) - - // DB dump total size - var dbDumpTotalBytes int64 - for _, f := range fullStatus.DumpFiles { - dbDumpTotalBytes += f.Size - } - data["DBDumpTotalBytes"] = dbDumpTotalBytes - - // Off-box (NAS) restic-SFTP backup (Part B): the target status + per-app off-box toggles. - offboxTgt := s.settings.GetOffboxTarget() - data["Offbox"] = offboxTgt - data["OffboxConfigured"] = s.backupMgr.OffboxConfigured() - offboxApps := s.buildOffboxApps() - data["OffboxApps"] = offboxApps - // Zero-toggle hint (take-two obs.): configured + escrowed but no app selected — nothing is - // actually covered by the offsite leg until the customer toggles at least one. - offboxToggled := 0 - for _, a := range offboxApps { - if a.Enabled { - offboxToggled++ - } - } - data["OffboxToggledCount"] = offboxToggled - // SLICE 4 soft-quota usage bar (rendered only when a quota is set — shared model). - data["OffboxQuotaPct"] = backup.OffboxQuotaPercent(offboxTgt) - } else { - data["Backup"] = nil } - s.executeTemplate(w, r, "backups", data) + s.executeTemplate(w, r, "backups_apps", data) +} + +// backupsRestoreHandler renders the Visszaállítás page: the restore panel, the offbox +// restore-to-verify list and the .fab export/import loop. +func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) { + data := s.backupsCommonData("backups-restore", "Biztonsági mentés — Visszaállítás", r) + s.backupsOffboxData(data) // restore-to-verify lists the offbox-toggled apps + s.executeTemplate(w, r, "backups_restore", data) } // OffboxAppRow is one deployed app's off-box toggle state for the backups page. @@ -930,26 +965,26 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { } if stackName == "" || snapshotID == "" { - http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound) + http.Redirect(w, r, "/backups/restore?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound) return } // F2 (defense-in-depth): a stack name is a single segment, never a path. Reject traversal before any // restore work — never let it reach RestoreFromRecoveryUnit. if !validStackName(stackName) { s.logger.Printf("[WARN] [web] restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr) - http.Redirect(w, r, "/backups?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound) + http.Redirect(w, r, "/backups/restore?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound) return } if s.backupMgr == nil { - http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound) + http.Redirect(w, r, "/backups/restore?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound) return } // Part B: restore is a long SYNCHRONOUS op (F4 — through cloudflared's hard 100s cap the customer // got an error page while it silently succeeded). Fast-path refuse a concurrent op, then run it in // a BACKGROUND goroutine (survives the request; the poll banner shows progress → result). if s.backupMgr.IsRunning() { - http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound) + http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound) return } s.logger.Printf("[WARN] [web] Restore requested (async): stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr) @@ -966,7 +1001,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s", stackName, time.Since(start)) s.backupMgr.EndRestoreOp(true, stackName+" visszaállítva ("+snapshotID+").") }() - http.Redirect(w, r, "/backups?flash="+url.QueryEscape("Visszaállítás elindult — az állapot itt frissül."), http.StatusFound) + http.Redirect(w, r, "/backups/restore?flash="+url.QueryEscape("Visszaállítás elindult — az állapot itt frissül."), http.StatusFound) } // backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its @@ -977,22 +1012,22 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques stackName := r.FormValue("stack_name") if stackName == "" { - http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound) + http.Redirect(w, r, "/backups/apps?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound) return } // Same F2-defense as the unit restore: a stack name is a single segment, never a path. if !validStackName(stackName) { s.logger.Printf("[WARN] [web] Tier-2 file restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr) - http.Redirect(w, r, "/backups?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound) + http.Redirect(w, r, "/backups/apps?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound) return } if s.backupMgr == nil { - http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound) + http.Redirect(w, r, "/backups/apps?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound) return } // Part B (same async shape as backupRestoreHandler): fast-path refuse, then background goroutine. if s.backupMgr.IsRunning() { - http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound) + http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound) return } s.logger.Printf("[WARN] [web] Tier-2 file restore requested (async): stack=%s from %s", stackName, r.RemoteAddr) @@ -1011,7 +1046,7 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques s.logger.Printf("[INFO] [web] Tier-2 file restore completed (async): stack=%s (%d files)", stackName, n) s.backupMgr.EndRestoreOp(true, msg) }() - http.Redirect(w, r, "/backups?flash="+url.QueryEscape("Fájl-visszaállítás elindult — az állapot itt frissül."), http.StatusFound) + http.Redirect(w, r, "/backups/apps?flash="+url.QueryEscape("Fájl-visszaállítás elindult — az állapot itt frissül."), http.StatusFound) } // settingsBaseData is the shared identity block used by every settings-family subpage diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go index c98d799..cae061d 100644 --- a/controller/internal/web/offbox_handlers.go +++ b/controller/internal/web/offbox_handlers.go @@ -17,13 +17,19 @@ import ( // The SSH private key + known-host line are provided out-of-band by the operator (textareas) and written // to 0600/0644 files by the backup Manager; they are NEVER echoed back, logged, or stored in settings. -// offboxRedirect sends the operator back to the backups page with a flash (success or error) message. +// offboxRedirect sends the customer back to the Távoli mentés page with a flash (success or +// error) message (v0.124.0 IA split: the offbox controls live on /backups/remote; the +// restore-to-verify flow redirects to /backups/restore via offboxRedirectTo). func offboxRedirect(w http.ResponseWriter, r *http.Request, msg string, isErr bool) { + offboxRedirectTo(w, r, "/backups/remote", msg, isErr) +} + +func offboxRedirectTo(w http.ResponseWriter, r *http.Request, page, msg string, isErr bool) { q := "flash" if isErr { q = "flash_error" } - http.Redirect(w, r, "/backups?"+q+"="+url.QueryEscape(msg), http.StatusFound) + http.Redirect(w, r, page+"?"+q+"="+url.QueryEscape(msg), http.StatusFound) } // offboxConfigHandler saves the off-box target + (out-of-band) SSH key + known_hosts. @@ -211,20 +217,20 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) { // overwrite live data; the operator inspects the restored files). func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) { if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() { - offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true) + offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true) return } _ = r.ParseForm() app := strings.TrimSpace(r.FormValue("app")) if app == "" { - offboxRedirect(w, r, "Hiányzó alkalmazás.", true) + offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true) return } // Part B: fast-path refuse a concurrent op, then run async on a BACKGROUND context. The old code // bounded on r.Context()+30m — a proxy read-timeout then CANCELED the SFTP restore mid-flight // (worse than F4: not just an error page, an aborted restore). Background ctx fixes that. if s.backupMgr.IsRunning() { - offboxRedirect(w, r, "Egy mentési/visszaállítási művelet már fut.", true) + offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true) return } dest := filepath.Join(s.cfg.Paths.DataDir, "offbox-restore", app) @@ -240,5 +246,5 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) { s.logger.Printf("[INFO] [web] off-box restore %s completed (async) → %s", app, dest) s.backupMgr.EndRestoreOp(true, "A(z) "+app+" visszaállítva ide (ellenőrzésre): "+dest) }() - offboxRedirect(w, r, "A távoli visszaállítás elindult — az állapot itt frissül.", false) + offboxRedirectTo(w, r, "/backups/restore", "A távoli visszaállítás elindult — az állapot itt frissül.", false) } diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 09f3d65..7e9a08a 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -293,6 +293,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.stacksHandler(w, r) case path == "/backups": s.backupsHandler(w, r) + // v0.124.0 IA split: the backups page's four sub-pages (old /backups deep links keep working — + // /backups itself is the Áttekintés page). + case path == "/backups/remote": + s.backupsRemoteHandler(w, r) + case path == "/backups/apps": + s.backupsAppsHandler(w, r) + case path == "/backups/restore": + s.backupsRestoreHandler(w, r) case path == "/monitoring": s.monitoringHandler(w, r) case path == "/settings": diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index 71a2225..9b9bfad 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -6,27 +6,13 @@ {{.Domain}} -{{if .Backup}}{{if .Backup.FlashSuccess}} -
{{.Backup.FlashSuccess}}
-{{end}}{{end}} -{{if .Backup}}{{if .Backup.FlashError}} -
{{.Backup.FlashError}}
-{{end}}{{end}} +{{template "backups_flash" .}} {{if .Backup}}{{if .Backup.SingleCopyWarning}}
{{.Backup.SingleCopyWarning}}
{{end}}{{end}} - - - {{if not .Backup}} -
-
🛡
-

Biztonsági mentés nincs beállítva

-

A biztonsági mentés funkció nem aktív.
- Kérjük, vegye fel a kapcsolatot a Felhom csapattal a beállításhoz.

-
+{{template "backups_empty" .}} {{else}} @@ -123,107 +109,6 @@ {{end}} -{{if .Backup}} - -

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

-

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

-
- {{if .Offbox}} -
-
-
{{if eq .Offbox.LastStatus "ok"}}✓ Rendben{{else if eq .Offbox.LastStatus "error"}}✗ Hiba{{else if eq .Offbox.LastStatus "running"}}Fut…{{else}}–{{end}}
-
Utolsó távoli mentés{{if .Offbox.LastRun}}
{{timeAgoStr .Offbox.LastRun}}{{end}}
-
-
-
{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}–{{end}}
-
Tároló méret · {{.Offbox.SnapshotCount}} pillanatkép
-
-
-
{{if .Offbox.Enabled}}{{.Offbox.User}}@{{.Offbox.Host}}{{else}}Kikapcsolva{{end}}
-
{{if .Offbox.Enabled}}{{.Offbox.RepoPath}}{{else}}A távoli mentés ki van kapcsolva{{end}}
-
-
- {{if and .Offbox.Enabled (gt .Offbox.QuotaGB 0)}} - -
-
Tárhelykeret: {{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%)
-
-
-
-
- {{end}} - {{if .Offbox.LastError}}

Utolsó hiba: {{.Offbox.LastError}}

{{end}} - {{if .Offbox.LastWarning}}

{{.Offbox.LastWarning}}

{{end}} - {{if and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}} -
-

A távoli mentés a kulcs letétbe helyezésére vár — a mentés addig nem fut (így nem keletkezik visszaállíthatatlan másolat). Futtasd a letéti szertartást, majd erősítsd meg.

-
{{.CSRFField}} - -
-
- {{end}} - {{if .OffboxConfigured}} -
-
{{.CSRFField}} - -
-
-

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

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

Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.

- {{end}} - {{if .OffboxApps}} -
- {{range .OffboxApps}} -
-
-
{{.DisplayName}}
-
-
{{$.CSRFField}} - - - -
- {{if .Enabled}} -
{{$.CSRFField}} - - -
- {{end}} -
-
-
- {{end}} -
- {{else}}

Nincs telepített alkalmazás.

{{end}} - {{end}} - {{else}} -

Még nincs beállítva távoli mentési cél.

- {{end}} - -
- Távoli mentési cél beállítása -
{{.CSRFField}} -
-
-
-
-
-
- - 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>
- -
-
-
-{{end}} - -

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ó.

-
{{if .Backup.LastDBDump}} @@ -270,409 +155,9 @@
- -
-

Ütemezés

-
-
- Adatbázis mentés - {{.Backup.DBDumpSchedule}} - Következő: {{nextRunLabel .Backup.NextDBDump}} -
-
-
- {{if .Backup.LastDBDump}} -
- Utolsó adatbázis mentés: - {{fmtTime .Backup.LastDBDump.LastRun}} ({{timeAgo .Backup.LastDBDump.LastRun}}) -
- {{else}} -
- Utolsó adatbázis mentés: - Még nem futott -
- {{end}} -
-
- -
-
- - -
-

Adatbázisok

- {{if or .Backup.DumpFiles .Backup.DiscoveredDBs}} -
- - - - - - - - - - - - - {{if .Backup.LastDBDump}} - {{range .Backup.LastDBDump.Results}} - - - - - - - - - {{end}} - {{else}} - {{range .Backup.DumpFiles}} - - - - - - - - - {{end}} - {{end}} - -
AlkalmazásTípusMéretUtolsóÉrvényesítésÁllapot
{{.DB.StackName}}{{dbTypeLabel .DB.DBType}}{{if .Error}}–{{else}}{{fmtBytes .Size}}{{end}}{{if .Error}}–{{else}}{{fmtTimeShort $.Backup.LastDBDump.LastRun}}{{end}} - {{if .Error}} - - {{else if .Validation.Valid}} - {{.Validation.TableCount}} tábla - {{else if .Validation.Error}} - Hiba - {{else}} - - {{end}} - - {{if .Error}} - Hiba - {{else}} - OK - {{end}} -
{{.StackName}}{{dbTypeLabel .DBType}}{{fmtBytes .Size}}{{fmtTimeShort .ModTime}} - {{if .Validation.Valid}} - {{.Validation.TableCount}} tábla - {{else if .Validation.Error}} - Hiba - {{else}} - - {{end}} - OK
-
- {{else if eq .DBSectionState "pending"}} -
Még nem készült adatbázis-mentés — az első ütemezett mentés éjjel fut le.
- {{else}} -
A telepített alkalmazások beágyazott adatbázist használnak (pl. SQLite) — adatbázisuk a fájl- és kötetmentés része, külön adatbázis-kiírás nem szükséges.
- {{end}} -
- - -{{if .Backup.AppDataInfo}} -
-

Alkalmazások mentési állapota

- - {{if .NoUserDataBackupWarning}} -
- 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 → -
- {{end}} - - {{range .AppBackupRows}} -
-
- - {{.DisplayName}} -
- {{if .DriveDisconnected}} - Meghajtó leválasztva - {{else if .HasHDDData}} - {{if .StorageLabel}}{{.StorageLabel}}{{end}} - {{.HDDSizeHuman}} - {{else if .HasVolumeData}} - Konfig{{if .HasDB}} + DB{{end}} + Adatok - {{else}} - Konfig{{if .HasDB}} + DB{{end}} - {{end}} -
- -
- -
- {{end}} - -
-{{end}} - - -{{if .Backup.AppDataInfo}} -
-

Visszaállítás

-
-
- - -
-
- - -
- - -
- A visszaállítás felülírja az alkalmazás jelenlegi adatait a kiválasztott mentés állapotával. - Az alkalmazás a folyamat során automatikusan leáll és újraindul. -
-
- -
-
- -
-
- Importálás mentett csomagból (.fab) -
-
-
-{{end}} - {{end}} {{template "layout_end" .}} diff --git a/controller/internal/web/templates/backups_apps.html b/controller/internal/web/templates/backups_apps.html new file mode 100644 index 0000000..4022416 --- /dev/null +++ b/controller/internal/web/templates/backups_apps.html @@ -0,0 +1,348 @@ +{{define "backups_apps"}} +{{template "layout_start" .}} + + + +{{template "backups_flash" .}} +{{template "restore_banner" .}} + +{{if not .Backup}} +{{template "backups_empty" .}} +{{else}} + +

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 + {{.Backup.DBDumpSchedule}} + Következő: {{nextRunLabel .Backup.NextDBDump}} +
+
+
+ {{if .Backup.LastDBDump}} +
+ Utolsó adatbázis mentés: + {{fmtTime .Backup.LastDBDump.LastRun}} ({{timeAgo .Backup.LastDBDump.LastRun}}) +
+ {{else}} +
+ Utolsó adatbázis mentés: + Még nem futott +
+ {{end}} +
+
+ +
+
+ + +
+

Adatbázisok

+ {{if or .Backup.DumpFiles .Backup.DiscoveredDBs}} +
+ + + + + + + + + + + + + {{if .Backup.LastDBDump}} + {{range .Backup.LastDBDump.Results}} + + + + + + + + + {{end}} + {{else}} + {{range .Backup.DumpFiles}} + + + + + + + + + {{end}} + {{end}} + +
AlkalmazásTípusMéretUtolsóÉrvényesítésÁllapot
{{.DB.StackName}}{{dbTypeLabel .DB.DBType}}{{if .Error}}–{{else}}{{fmtBytes .Size}}{{end}}{{if .Error}}–{{else}}{{fmtTimeShort $.Backup.LastDBDump.LastRun}}{{end}} + {{if .Error}} + + {{else if .Validation.Valid}} + {{.Validation.TableCount}} tábla + {{else if .Validation.Error}} + Hiba + {{else}} + + {{end}} + + {{if .Error}} + Hiba + {{else}} + OK + {{end}} +
{{.StackName}}{{dbTypeLabel .DBType}}{{fmtBytes .Size}}{{fmtTimeShort .ModTime}} + {{if .Validation.Valid}} + {{.Validation.TableCount}} tábla + {{else if .Validation.Error}} + Hiba + {{else}} + + {{end}} + OK
+
+ {{else if eq .DBSectionState "pending"}} +
Még nem készült adatbázis-mentés — az első ütemezett mentés éjjel fut le.
+ {{else}} +
A telepített alkalmazások beágyazott adatbázist használnak (pl. SQLite) — adatbázisuk a fájl- és kötetmentés része, külön adatbázis-kiírás nem szükséges.
+ {{end}} +
+ + +{{if .Backup.AppDataInfo}} +
+

Alkalmazások mentési állapota

+ + {{if .NoUserDataBackupWarning}} +
+ 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 → +
+ {{end}} + + {{range .AppBackupRows}} +
+
+ + {{.DisplayName}} +
+ {{if .DriveDisconnected}} + Meghajtó leválasztva + {{else if .HasHDDData}} + {{if .StorageLabel}}{{.StorageLabel}}{{end}} + {{.HDDSizeHuman}} + {{else if .HasVolumeData}} + Konfig{{if .HasDB}} + DB{{end}} + Adatok + {{else}} + Konfig{{if .HasDB}} + DB{{end}} + {{end}} +
+ +
+ +
+ {{end}} + +
+{{end}} + +{{end}} + + + +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/templates/backups_remote.html b/controller/internal/web/templates/backups_remote.html new file mode 100644 index 0000000..07865a4 --- /dev/null +++ b/controller/internal/web/templates/backups_remote.html @@ -0,0 +1,107 @@ +{{define "backups_remote"}} +{{template "layout_start" .}} + + + +{{template "backups_flash" .}} + +{{if not .Backup}} +{{template "backups_empty" .}} +{{else}} + + +

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

+

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

+
+ {{if .Offbox}} +
+
+
{{if eq .Offbox.LastStatus "ok"}}✓ Rendben{{else if eq .Offbox.LastStatus "error"}}✗ Hiba{{else if eq .Offbox.LastStatus "running"}}Fut…{{else}}–{{end}}
+
Utolsó távoli mentés{{if .Offbox.LastRun}}
{{timeAgoStr .Offbox.LastRun}}{{end}}
+
+
+
{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}–{{end}}
+
Tároló méret · {{.Offbox.SnapshotCount}} pillanatkép
+
+
+
{{if .Offbox.Enabled}}{{.Offbox.User}}@{{.Offbox.Host}}{{else}}Kikapcsolva{{end}}
+
{{if .Offbox.Enabled}}{{.Offbox.RepoPath}}{{else}}A távoli mentés ki van kapcsolva{{end}}
+
+
+ {{if and .Offbox.Enabled (gt .Offbox.QuotaGB 0)}} + +
+
Tárhelykeret: {{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%)
+
+
+
+
+ {{end}} + {{if .Offbox.LastError}}

Utolsó hiba: {{.Offbox.LastError}}

{{end}} + {{if .Offbox.LastWarning}}

{{.Offbox.LastWarning}}

{{end}} + {{if and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}} +
+

A távoli mentés a kulcs letétbe helyezésére vár — a mentés addig nem fut (így nem keletkezik visszaállíthatatlan másolat). Futtasd a letéti szertartást, majd erősítsd meg.

+
{{.CSRFField}} + +
+
+ {{end}} + {{if .OffboxConfigured}} +
+
{{.CSRFField}} + +
+
+

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

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

Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.

+ {{end}} + {{if .OffboxApps}} +
+ {{range .OffboxApps}} +
+
+
{{.DisplayName}}
+
+
{{$.CSRFField}} + + + +
+
+
+
+ {{end}} +
+ {{else}}

Nincs telepített alkalmazás.

{{end}} + {{end}} + {{else}} +

Még nincs beállítva távoli mentési cél.

+ {{end}} + +
+ Távoli mentési cél beállítása +
{{.CSRFField}} +
+
+
+
+
+
+ + 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>
+ +
+
+
+{{end}} + +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/templates/backups_restore.html b/controller/internal/web/templates/backups_restore.html new file mode 100644 index 0000000..3367ddc --- /dev/null +++ b/controller/internal/web/templates/backups_restore.html @@ -0,0 +1,227 @@ +{{define "backups_restore"}} +{{template "layout_start" .}} + + + +{{template "backups_flash" .}} +{{template "restore_banner" .}} + +{{if not .Backup}} +{{template "backups_empty" .}} +{{else}} + + +{{if .Backup.AppDataInfo}} +
+

Visszaállítás

+
+
+ + +
+
+ + +
+ + +
+ A visszaállítás felülírja az alkalmazás jelenlegi adatait a kiválasztott mentés állapotával. + Az alkalmazás a folyamat során automatikusan leáll és újraindul. +
+
+ +
+
+ +
+
+ Importálás mentett csomagból (.fab) +
+
+
+{{end}} + + +{{if .OffboxConfigured}} +
+

Ellenőrző visszaállítás a távoli tárolóból

+

A távoli mentésre kijelölt alkalmazások legutóbbi pillanatképe egy külön ellenőrző mappába állítható vissza — a meglévő adatok nem változnak.

+ {{if .OffboxToggledCount}} +
+ {{range .OffboxApps}} + {{if .Enabled}} +
+
+
{{.DisplayName}}
+
+
{{$.CSRFField}} + + +
+
+
+
+ {{end}} + {{end}} +
+ {{else}} +

Nincs távoli mentésre jelölt alkalmazás — a kijelölés a Távoli mentés oldalon történik.

+ {{end}} +
+{{end}} + +{{end}} + + + +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/templates/backups_shared.html b/controller/internal/web/templates/backups_shared.html new file mode 100644 index 0000000..f6f5dba --- /dev/null +++ b/controller/internal/web/templates/backups_shared.html @@ -0,0 +1,63 @@ +{{define "backups_flash"}} +{{if .Backup}}{{if .Backup.FlashSuccess}} +
{{.Backup.FlashSuccess}}
+{{end}}{{end}} +{{if .Backup}}{{if .Backup.FlashError}} +
{{.Backup.FlashError}}
+{{end}}{{end}} +{{end}} + +{{define "backups_empty"}} +
+
🛡
+

Biztonsági mentés nincs beállítva

+

A biztonsági mentés funkció nem aktív.
+ Kérjük, vegye fel a kapcsolatot a Felhom csapattal a beállításhoz.

+
+{{end}} + +{{define "restore_banner"}} + + +{{end}} + +{{define "restore_banner_js"}} +// Part B: restore-progress banner. Polls the async restore op-status every 3s. Shows a neutral +// "in progress" while running (including on a fresh page load mid-op), success on completion, and the +// error state ONLY on failure. Stops polling when idle after a terminal result was shown. +(function(){ + var banner = document.getElementById('restore-banner'); + if (!banner) return; + var sawRunning = false; + function opLabel(op){ return op === 'tier2-restore' ? 'Fájl-visszaállítás' + : op === 'offbox-restore' ? 'Távoli visszaállítás' : 'Visszaállítás'; } + function render(st){ + if (st.running) { + sawRunning = true; + banner.className = 'flash'; + banner.style.display = 'block'; + banner.textContent = opLabel(st.op) + ' folyamatban' + (st.stack ? ': ' + st.stack : '') + '…'; + return; + } + if (st.last && sawRunning) { + banner.style.display = 'block'; + if (st.last.ok) { + banner.className = 'flash flash-success'; + banner.textContent = st.last.message || (opLabel(st.last.op) + ' kész.'); + } else { + banner.className = 'flash flash-error'; + banner.textContent = st.last.message || (opLabel(st.last.op) + ' sikertelen.'); + } + } + } + function poll(){ + fetch('/api/backup/restore-status', {headers: {'Accept':'application/json'}}) + .then(function(r){ return r.json(); }) + .then(function(j){ if (j && j.data) render(j.data); }) + .catch(function(){}); + } + poll(); + setInterval(poll, 3000); +})(); +{{end}} diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index fa2fc0f..08af8bf 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -56,7 +56,14 @@
  • Hálózati tárhely
  • -
  • Biztonsági mentés
  • +
  • Biztonsági mentés + +
  • Rendszermonitor
  • Debug
  • diff --git a/controller/internal/web/templates/tier2_config.html b/controller/internal/web/templates/tier2_config.html index 16fb215..b8cdda2 100644 --- a/controller/internal/web/templates/tier2_config.html +++ b/controller/internal/web/templates/tier2_config.html @@ -3,7 +3,7 @@ {{if .Flash}}
    {{.Flash}}
    {{end}} diff --git a/controller/scripts/backups_split_move_check.py b/controller/scripts/backups_split_move_check.py new file mode 100644 index 0000000..299ef8e --- /dev/null +++ b/controller/scripts/backups_split_move_check.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +"""Backups IA-split move check (v0.124.0, one-shot) — the split MOVED the v0.123.0 sections, +it did not rewrite them: every moved block from the pre-split backups.html (commit df7ad37) +must appear, whitespace-normalized, on EXACTLY its mapped page. Allowed divergences are encoded +explicitly (the tier-3 anchor retarget; the restore-to-verify form's relocation). + +Run from controller/: python scripts/backups_split_move_check.py +""" +import io, os, re, subprocess, sys + +BASELINE = "df7ad37" +OLD_PATH = "controller/internal/web/templates/backups.html" +TPL = os.path.join("internal", "web", "templates") + +# (name, 1-indexed start, end inclusive, target template file, normalizer applied to the NEW page) +BLOCKS = [ + ("storage-overview", 32, 77, "backups.html", None), + ("whole-guest", 79, 124, "backups.html", None), + ("stat-cards", 227, 271, "backups.html", None), + ("offbox-pre", 127, 186, "backups_remote.html", None), + ("offbox-post", 193, 221, "backups_remote.html", None), + ("apps-divider", 224, 225, "backups_apps.html", None), + ("schedule", 273, 302, "backups_apps.html", None), + ("databases", 304, 376, "backups_apps.html", None), + # the tier-3 action anchors were retargeted cross-page — normalize them back before comparing + ("per-app-rows", 378, 541, "backups_apps.html", + lambda s: s.replace('href="/backups/remote#offbox-section"', 'href="#offbox-section"')), + ("restore-panel", 543, 586, "backups_restore.html", None), + ("offbox-verify-form", 188, 191, "backups_restore.html", None), + ("banner-js", 591, 627, "backups_shared.html", None), + ("apps-js", 628, 674, "backups_apps.html", None), + ("guest-js", 676, 712, "backups.html", None), + ("restore-js", 714, 841, "backups_restore.html", None), +] + +PAGES = ["backups.html", "backups_remote.html", "backups_apps.html", "backups_restore.html", + "backups_shared.html"] + + +def norm(s): + return re.sub(r"\s+", " ", s).strip() + + +def main(): + old = subprocess.run(["git", "show", BASELINE + ":" + OLD_PATH], + capture_output=True, text=True, encoding="utf-8") + if old.returncode != 0: + print("cannot read baseline %s:%s — %s" % (BASELINE, OLD_PATH, old.stderr.strip())) + sys.exit(2) + old_lines = old.stdout.split("\n") + + pages = {} + for fn in PAGES: + pages[fn] = io.open(os.path.join(TPL, fn), encoding="utf-8").read() + + failed = 0 + for name, a, b, target, fix in BLOCKS: + block = norm("\n".join(old_lines[a - 1:b])) + hits = [] + for fn in PAGES: + content = pages[fn] + if fix: + content = fix(content) + if block in norm(content): + hits.append(fn) + if hits != [target]: + failed += 1 + print("MOVED-BLOCK MISMATCH %-18s (old L%d-%d): expected [%s], found %s" + % (name, a, b, target, hits)) + if failed: + print("MOVE CHECK FAILED: %d block(s) rewritten, duplicated or lost" % failed) + sys.exit(1) + print("move check OK — all %d v0.123.0 blocks moved verbatim to their mapped page" % len(BLOCKS)) + + +if __name__ == "__main__": + main()